feat: add narrative page turn audio cue
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Audio Sources
|
# 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/
|
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.
|
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
|
## Sound Effects
|
||||||
|
|
||||||
| Project file | Pixabay title | Creator | Source |
|
| Project file | Pixabay title | Creator | Source |
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const requiredTacticalCueKeys = [
|
|||||||
'objective-success',
|
'objective-success',
|
||||||
'objective-failure'
|
'objective-failure'
|
||||||
];
|
];
|
||||||
|
const requiredNarrativeCueKeys = ['story-page-turn'];
|
||||||
const requiredBattleSceneAudioMethods = [
|
const requiredBattleSceneAudioMethods = [
|
||||||
'playSoundscape',
|
'playSoundscape',
|
||||||
'playAllyTurnCue',
|
'playAllyTurnCue',
|
||||||
@@ -49,6 +50,20 @@ const battlefieldSourceRecords = [
|
|||||||
source: 'https://pixabay.com/sound-effects/film-special-effects-failure-1-89170/'
|
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({
|
const server = await createServer({
|
||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
@@ -69,7 +84,9 @@ try {
|
|||||||
validateTrackMap(errors, 'effect', effectTracks, 'sfx');
|
validateTrackMap(errors, 'effect', effectTracks, 'sfx');
|
||||||
validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys);
|
validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys);
|
||||||
validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys);
|
validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys);
|
||||||
|
validateRequiredTrackKeys(errors, 'narrative cue', effectTracks, requiredNarrativeCueKeys);
|
||||||
validateBattlefieldSourceDocumentation(errors);
|
validateBattlefieldSourceDocumentation(errors);
|
||||||
|
validateNarrativeSourceDocumentation(errors);
|
||||||
validateBattleSceneAudioIntegration(errors);
|
validateBattleSceneAudioIntegration(errors);
|
||||||
validateEffectPools(errors, effectPools, effectTracks);
|
validateEffectPools(errors, effectPools, effectTracks);
|
||||||
validateEffectPoolRegistration(errors, effectPools);
|
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) {
|
function validateBattleSceneAudioIntegration(errors) {
|
||||||
const path = join('src', 'game', 'scenes', 'BattleScene.ts');
|
const path = join('src', 'game', 'scenes', 'BattleScene.ts');
|
||||||
const source = readFileSync(path, 'utf8');
|
const source = readFileSync(path, 'utf8');
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ try {
|
|||||||
'operation-alert': '/audio/operation-alert.ogg',
|
'operation-alert': '/audio/operation-alert.ogg',
|
||||||
'objective-success': '/audio/objective-success.ogg',
|
'objective-success': '/audio/objective-success.ogg',
|
||||||
'objective-failure': '/audio/objective-failure.ogg',
|
'objective-failure': '/audio/objective-failure.ogg',
|
||||||
|
'story-page-turn': '/audio/story-page-turn.ogg',
|
||||||
'ui-select': '/audio/ui-select.ogg'
|
'ui-select': '/audio/ui-select.ogg'
|
||||||
});
|
});
|
||||||
director.registerEffectPools({
|
director.registerEffectPools({
|
||||||
@@ -387,7 +388,7 @@ try {
|
|||||||
debug = director.getDebugState();
|
debug = director.getDebugState();
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
debug.semanticCues.cooldownMs,
|
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'
|
'semantic cue cooldowns should be visible in debug state'
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -417,6 +418,35 @@ try {
|
|||||||
assert.equal(debug.activeEffectCount, 0, 'tactical cues should retire after their playback windows');
|
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');
|
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;
|
const semanticEffectStart = audioHarness.instances.length;
|
||||||
director.playMeleeSwing(true, false);
|
director.playMeleeSwing(true, false);
|
||||||
assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool');
|
assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool');
|
||||||
@@ -455,7 +485,7 @@ try {
|
|||||||
console.log(
|
console.log(
|
||||||
`Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` +
|
`Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` +
|
||||||
`${debug.ambience.elementRevision} ambience element revisions, no-repeat effect pools, ` +
|
`${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 {
|
} finally {
|
||||||
restoreGlobals();
|
restoreGlobals();
|
||||||
|
|||||||
BIN
src/assets/audio/sfx/story-page-turn.mp3
Normal file
BIN
src/assets/audio/sfx/story-page-turn.mp3
Normal file
Binary file not shown.
@@ -57,7 +57,7 @@ export type MusicDuckOptions = {
|
|||||||
releaseMs?: number;
|
releaseMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SemanticCueGroup = 'turn' | 'operation' | 'objective';
|
type SemanticCueGroup = 'turn' | 'operation' | 'objective' | 'narrative';
|
||||||
|
|
||||||
type MusicDuckState = {
|
type MusicDuckState = {
|
||||||
multiplier: number;
|
multiplier: number;
|
||||||
@@ -102,7 +102,8 @@ export class SoundDirector {
|
|||||||
private readonly semanticCueCooldownMs: Record<SemanticCueGroup, number> = {
|
private readonly semanticCueCooldownMs: Record<SemanticCueGroup, number> = {
|
||||||
turn: 900,
|
turn: 900,
|
||||||
operation: 650,
|
operation: 650,
|
||||||
objective: 1200
|
objective: 1200,
|
||||||
|
narrative: 220
|
||||||
};
|
};
|
||||||
private readonly semanticCuePlayedAt = new Map<SemanticCueGroup, number>();
|
private readonly semanticCuePlayedAt = new Map<SemanticCueGroup, number>();
|
||||||
private lastPlayedSemanticCue?: { group: SemanticCueGroup; key: string; at: number };
|
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() {
|
playStrategyPulse() {
|
||||||
this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 });
|
this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 });
|
||||||
this.playTone(460, 0.08, 0.012, 'sine');
|
this.playTone(460, 0.08, 0.012, 'sine');
|
||||||
@@ -500,7 +508,7 @@ export class SoundDirector {
|
|||||||
private playSemanticCue(
|
private playSemanticCue(
|
||||||
group: SemanticCueGroup,
|
group: SemanticCueGroup,
|
||||||
key: string,
|
key: string,
|
||||||
options: PlayEffectOptions & { duck: MusicDuckOptions }
|
options: PlayEffectOptions & { duck?: MusicDuckOptions }
|
||||||
) {
|
) {
|
||||||
if (!this.started || this.muted || !this.effectTracks[key]) {
|
if (!this.started || this.muted || !this.effectTracks[key]) {
|
||||||
return false;
|
return false;
|
||||||
@@ -519,7 +527,9 @@ export class SoundDirector {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.duckMusic(options.duck);
|
if (options.duck) {
|
||||||
|
this.duckMusic(options.duck);
|
||||||
|
}
|
||||||
const played = this.playEffect(key, options);
|
const played = this.playEffect(key, options);
|
||||||
if (played) {
|
if (played) {
|
||||||
this.semanticCuePlayedAt.set(group, now);
|
this.semanticCuePlayedAt.set(group, now);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import objectiveSuccessUrl from '../../assets/audio/sfx/objective-success.mp3';
|
|||||||
import operationAlertUrl from '../../assets/audio/sfx/operation-alert.mp3';
|
import operationAlertUrl from '../../assets/audio/sfx/operation-alert.mp3';
|
||||||
import shieldBlockUrl from '../../assets/audio/sfx/shield-block.mp3';
|
import shieldBlockUrl from '../../assets/audio/sfx/shield-block.mp3';
|
||||||
import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.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 swordSlashUrl from '../../assets/audio/sfx/sword-slash.mp3';
|
||||||
import swordSwingVariantUrl from '../../assets/audio/sfx/sword-swing-variant.mp3';
|
import swordSwingVariantUrl from '../../assets/audio/sfx/sword-swing-variant.mp3';
|
||||||
import uiSelectUrl from '../../assets/audio/sfx/ui-select.mp3';
|
import uiSelectUrl from '../../assets/audio/sfx/ui-select.mp3';
|
||||||
@@ -80,6 +81,7 @@ export const effectTracks = {
|
|||||||
'critical-impact': criticalImpactUrl,
|
'critical-impact': criticalImpactUrl,
|
||||||
'critical-boom': criticalBoomUrl,
|
'critical-boom': criticalBoomUrl,
|
||||||
'strategy-cast': strategyCastUrl,
|
'strategy-cast': strategyCastUrl,
|
||||||
|
'story-page-turn': storyPageTurnUrl,
|
||||||
'cart-roll': cartRollUrl,
|
'cart-roll': cartRollUrl,
|
||||||
'exp-gain': expGainUrl,
|
'exp-gain': expGainUrl,
|
||||||
'level-up': levelUpUrl,
|
'level-up': levelUpUrl,
|
||||||
|
|||||||
Reference in New Issue
Block a user