diff --git a/docs/audio-sources.md b/docs/audio-sources.md index eec1cf3..f2434ea 100644 --- a/docs/audio-sources.md +++ b/docs/audio-sources.md @@ -1,6 +1,6 @@ # Audio Sources -Downloaded from Pixabay on 2026-06-22 and 2026-07-21 and used under the Pixabay Content License. +Downloaded from Pixabay on 2026-06-22, 2026-07-21, and 2026-07-22 and used under the Pixabay Content License. License reference: https://pixabay.com/service/license-summary/ @@ -45,6 +45,16 @@ The source files in this section were downloaded from their official Pixabay det All seven files were resampled to 48 kHz stereo MP3. Tactical cues were silence-trimmed, equalized by role, and balanced to approximately -17.5 to -19 LUFS. The rain and fire recordings were reduced to stable excerpts, balanced near -25 LUFS, and rebuilt with a one-second end-to-start crossfade so repeated playback does not introduce a hard seam. +## Narrative Interface Cues + +The source file in this section was downloaded from its official Pixabay detail page on 2026-07-22 and uses the Pixabay Content License linked above. + +| Project file | Role | Pixabay title | Creator | Source | +| --- | --- | --- | --- | --- | +| `src/assets/audio/sfx/story-page-turn.mp3` | Story page advance feedback | Flipping Book Page | DRAGON-STUDIO | https://pixabay.com/sound-effects/film-special-effects-flipping-book-page-499646/ | + +The narrative cue was resampled to 48 kHz stereo MP3, silence-trimmed to an approximately 1.0-second excerpt, fade-in/out processed, and high-pass and low-pass filtered for unobtrusive playback over story music. + ## Sound Effects | Project file | Pixabay title | Creator | Source | diff --git a/scripts/verify-audio-asset-data.mjs b/scripts/verify-audio-asset-data.mjs index 9ccadd5..b43a090 100644 --- a/scripts/verify-audio-asset-data.mjs +++ b/scripts/verify-audio-asset-data.mjs @@ -19,6 +19,7 @@ const requiredTacticalCueKeys = [ 'objective-success', 'objective-failure' ]; +const requiredNarrativeCueKeys = ['story-page-turn']; const requiredBattleSceneAudioMethods = [ 'playSoundscape', 'playAllyTurnCue', @@ -49,6 +50,20 @@ const battlefieldSourceRecords = [ source: 'https://pixabay.com/sound-effects/film-special-effects-failure-1-89170/' } ]; +const narrativeSourceRecord = { + projectFile: 'src/assets/audio/sfx/story-page-turn.mp3', + source: 'https://pixabay.com/sound-effects/film-special-effects-flipping-book-page-499646/', + requiredDocumentation: [ + '2026-07-22', + 'Flipping Book Page', + 'DRAGON-STUDIO', + '48 kHz stereo MP3', + '1.0-second', + 'silence-trimmed', + 'fade-in/out', + 'high-pass and low-pass filtered' + ] +}; const server = await createServer({ logLevel: 'error', @@ -69,7 +84,9 @@ try { validateTrackMap(errors, 'effect', effectTracks, 'sfx'); validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys); validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys); + validateRequiredTrackKeys(errors, 'narrative cue', effectTracks, requiredNarrativeCueKeys); validateBattlefieldSourceDocumentation(errors); + validateNarrativeSourceDocumentation(errors); validateBattleSceneAudioIntegration(errors); validateEffectPools(errors, effectPools, effectTracks); validateEffectPoolRegistration(errors, effectPools); @@ -145,6 +162,21 @@ function validateBattlefieldSourceDocumentation(errors) { }); } +function validateNarrativeSourceDocumentation(errors) { + const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8'); + if (!docs.includes(narrativeSourceRecord.projectFile)) { + errors.push(`docs/audio-sources.md is missing narrative audio file "${narrativeSourceRecord.projectFile}"`); + } + if (!docs.includes(narrativeSourceRecord.source)) { + errors.push(`docs/audio-sources.md is missing Pixabay source "${narrativeSourceRecord.source}"`); + } + narrativeSourceRecord.requiredDocumentation.forEach((value) => { + if (!docs.includes(value)) { + errors.push(`docs/audio-sources.md is missing narrative audio detail "${value}"`); + } + }); +} + function validateBattleSceneAudioIntegration(errors) { const path = join('src', 'game', 'scenes', 'BattleScene.ts'); const source = readFileSync(path, 'utf8'); diff --git a/scripts/verify-sound-director.mjs b/scripts/verify-sound-director.mjs index 3f5a8ea..0a09fc4 100644 --- a/scripts/verify-sound-director.mjs +++ b/scripts/verify-sound-director.mjs @@ -102,6 +102,7 @@ try { 'operation-alert': '/audio/operation-alert.ogg', 'objective-success': '/audio/objective-success.ogg', 'objective-failure': '/audio/objective-failure.ogg', + 'story-page-turn': '/audio/story-page-turn.ogg', 'ui-select': '/audio/ui-select.ogg' }); director.registerEffectPools({ @@ -387,7 +388,7 @@ try { debug = director.getDebugState(); assert.deepEqual( debug.semanticCues.cooldownMs, - { turn: 900, operation: 650, objective: 1200 }, + { turn: 900, operation: 650, objective: 1200, narrative: 220 }, 'semantic cue cooldowns should be visible in debug state' ); assert.deepEqual( @@ -417,6 +418,35 @@ try { assert.equal(debug.activeEffectCount, 0, 'tactical cues should retire after their playback windows'); assert.equal(audioHarness.activeInstances().length, 1, 'tactical cue cleanup should leave only music active'); + const narrativeCueStart = audioHarness.instances.length; + const musicDuckRevisionBeforeNarrativeCue = debug.musicDuck.revision; + assert.equal(director.playStoryAdvanceCue(), true, 'the first story advance cue should play'); + assertClose(audioHarness.byOriginalSrc('/audio/story-page-turn.ogg').volume, 0.15, 'story advance cue volume'); + debug = director.getDebugState(); + assert.equal( + debug.musicDuck.revision, + musicDuckRevisionBeforeNarrativeCue, + 'story advance cues must not duck music' + ); + assert.deepEqual( + debug.semanticCues.lastPlayed, + { group: 'narrative', key: 'story-page-turn', at: clock.now }, + 'debug state should expose a played story advance cue' + ); + assert.equal(director.playStoryAdvanceCue(), false, 'rapid story advance cues should be coalesced'); + debug = director.getDebugState(); + assert.deepEqual( + debug.semanticCues.lastSuppressed, + { group: 'narrative', key: 'story-page-turn', at: clock.now, remainingMs: 220 }, + 'debug state should expose a coalesced story advance cue' + ); + assert.equal(audioHarness.instances.length, narrativeCueStart + 1, 'coalesced story cues must not construct Audio'); + clock.advance(220); + assert.equal(director.playStoryAdvanceCue(), true, 'story advance cues should replay after the narrative cooldown'); + assert.equal(audioHarness.instances.length, narrativeCueStart + 2, 'expired narrative cooldown should permit new Audio'); + clock.advance(1100); + assert.equal(director.getDebugState().activeEffectCount, 0, 'story advance cues should retire after playback'); + const semanticEffectStart = audioHarness.instances.length; director.playMeleeSwing(true, false); assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool'); @@ -455,7 +485,7 @@ try { console.log( `Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` + `${debug.ambience.elementRevision} ambience element revisions, no-repeat effect pools, ` + - 'coalesced tactical cues, semantic combat effects, category multipliers, and deterministic music ducking.' + 'coalesced tactical and narrative cues, semantic combat effects, category multipliers, and deterministic music ducking.' ); } finally { restoreGlobals(); diff --git a/src/assets/audio/sfx/story-page-turn.mp3 b/src/assets/audio/sfx/story-page-turn.mp3 new file mode 100644 index 0000000..4bed881 Binary files /dev/null and b/src/assets/audio/sfx/story-page-turn.mp3 differ diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index c24623e..8b376c0 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -57,7 +57,7 @@ export type MusicDuckOptions = { releaseMs?: number; }; -type SemanticCueGroup = 'turn' | 'operation' | 'objective'; +type SemanticCueGroup = 'turn' | 'operation' | 'objective' | 'narrative'; type MusicDuckState = { multiplier: number; @@ -102,7 +102,8 @@ export class SoundDirector { private readonly semanticCueCooldownMs: Record = { turn: 900, operation: 650, - objective: 1200 + objective: 1200, + narrative: 220 }; private readonly semanticCuePlayedAt = new Map(); private lastPlayedSemanticCue?: { group: SemanticCueGroup; key: string; at: number }; @@ -434,6 +435,13 @@ export class SoundDirector { }); } + playStoryAdvanceCue() { + return this.playSemanticCue('narrative', 'story-page-turn', { + volume: 0.15, + stopAfterMs: 1100 + }); + } + playStrategyPulse() { this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 }); this.playTone(460, 0.08, 0.012, 'sine'); @@ -500,7 +508,7 @@ export class SoundDirector { private playSemanticCue( group: SemanticCueGroup, key: string, - options: PlayEffectOptions & { duck: MusicDuckOptions } + options: PlayEffectOptions & { duck?: MusicDuckOptions } ) { if (!this.started || this.muted || !this.effectTracks[key]) { return false; @@ -519,7 +527,9 @@ export class SoundDirector { return false; } - this.duckMusic(options.duck); + if (options.duck) { + this.duckMusic(options.duck); + } const played = this.playEffect(key, options); if (played) { this.semanticCuePlayedAt.set(group, now); diff --git a/src/game/audio/audioAssets.ts b/src/game/audio/audioAssets.ts index d7f5538..9cc03b2 100644 --- a/src/game/audio/audioAssets.ts +++ b/src/game/audio/audioAssets.ts @@ -29,6 +29,7 @@ import objectiveSuccessUrl from '../../assets/audio/sfx/objective-success.mp3'; import operationAlertUrl from '../../assets/audio/sfx/operation-alert.mp3'; import shieldBlockUrl from '../../assets/audio/sfx/shield-block.mp3'; import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3'; +import storyPageTurnUrl from '../../assets/audio/sfx/story-page-turn.mp3'; import swordSlashUrl from '../../assets/audio/sfx/sword-slash.mp3'; import swordSwingVariantUrl from '../../assets/audio/sfx/sword-swing-variant.mp3'; import uiSelectUrl from '../../assets/audio/sfx/ui-select.mp3'; @@ -80,6 +81,7 @@ export const effectTracks = { 'critical-impact': criticalImpactUrl, 'critical-boom': criticalBoomUrl, 'strategy-cast': strategyCastUrl, + 'story-page-turn': storyPageTurnUrl, 'cart-roll': cartRollUrl, 'exp-gain': expGainUrl, 'level-up': levelUpUrl,