feat: refine exploration presentation and audio flow
This commit is contained in:
@@ -284,10 +284,16 @@ try {
|
||||
assert.equal(debug.lastMovementStep.surface, 'stone', 'movement debug state should expose the resolved surface');
|
||||
assert.equal(debug.lastMovementStep.terrain, 'road', 'movement debug state should retain the caller terrain');
|
||||
assert(['footstep-stone-1', 'footstep-stone-2'].includes(debug.lastMovementStep.trackKey));
|
||||
assertClose(firstStoneStep.playbackRate, 0.94 * 0.985, 'first footstep deterministic rate variation');
|
||||
assertClose(firstStoneStep.volume, 0.2 * 0.96, 'first footstep deterministic gain variation');
|
||||
assertClose(debug.lastMovementStep.rate, firstStoneStep.playbackRate, 'movement debug rate should expose variation');
|
||||
assertClose(debug.lastMovementStep.volume, firstStoneStep.volume, 'movement debug volume should expose variation');
|
||||
|
||||
director.playMovementStep(false, 1, { terrain: 'road', volume: 0.2, rate: 1.04, stopAfterMs: 120 });
|
||||
const secondStoneStep = audioHarness.instances.at(-1);
|
||||
assert.notEqual(firstStoneStep.originalSrc, secondStoneStep.originalSrc, 'terrain movement pools should avoid immediate repetition');
|
||||
assertClose(secondStoneStep.playbackRate, 1.04 * 1.015, 'second footstep deterministic rate variation');
|
||||
assertClose(secondStoneStep.volume, 0.2 * 1.03, 'second footstep deterministic gain variation');
|
||||
assert.equal(
|
||||
director.playMovementStep(false, 2, { terrain: 'road', volume: 0.2, rate: 0.94, stopAfterMs: 120 }),
|
||||
false,
|
||||
@@ -385,6 +391,7 @@ try {
|
||||
|
||||
assert.equal(director.playEffect('ui-select'), true, 'effect should be tracked until an error or end event');
|
||||
const erroredEffect = audioHarness.instances.at(-1);
|
||||
assertClose(erroredEffect.playbackRate, 1, 'UI effects must not receive pooled combat rate variation');
|
||||
erroredEffect.emit('error');
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.activeEffectCount, 0, 'errored effects should be released from active tracking');
|
||||
@@ -409,8 +416,65 @@ try {
|
||||
);
|
||||
assert.equal(debug.music.targetVolume, 0.18, 'same-key music should update its desired target volume');
|
||||
assert.equal(debug.ambience.targetVolume, 0.05, 'same-key ambience should update its desired target volume');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.18, 'same-key music element volume');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.05, 'same-key ambience element volume');
|
||||
assert.equal(debug.music.volumeRamp.active, true, 'same-key music should start a gain ramp');
|
||||
assert.equal(debug.ambience.volumeRamp.active, true, 'same-key ambience should start a gain ramp');
|
||||
assert.deepEqual(
|
||||
debug.music.volumeRamp.last,
|
||||
{ fromVolume: 0.22, toVolume: 0.18, durationMs: 400 },
|
||||
'same-key music ramp should retain its endpoints'
|
||||
);
|
||||
assert.deepEqual(
|
||||
debug.ambience.volumeRamp.last,
|
||||
{ fromVolume: 0.08, toVolume: 0.05, durationMs: 400 },
|
||||
'same-key ambience ramp should retain its endpoints'
|
||||
);
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'same-key music must not jump at ramp start');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'same-key ambience must not jump at ramp start');
|
||||
|
||||
clock.advance(192);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.music.transition.revision, musicRevisionBeforeSameKey, 'music gain ramp must not revise loop transition');
|
||||
assert.equal(debug.ambience.transition.revision, ambienceRevisionBeforeSameKey, 'ambience gain ramp must not revise loop transition');
|
||||
assert.equal(audioHarness.instances.length, countBeforeSameKey, 'gain ramps must retain the current loop elements');
|
||||
assertClose(
|
||||
audioHarness.byOriginalSrc('/audio/music-a.ogg').volume,
|
||||
0.22 + (0.18 - 0.22) * 0.48,
|
||||
'same-key music midpoint'
|
||||
);
|
||||
assertClose(
|
||||
audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume,
|
||||
0.08 + (0.05 - 0.08) * 0.48,
|
||||
'same-key ambience midpoint'
|
||||
);
|
||||
const musicRampRevisionAtMidpoint = debug.music.volumeRamp.revision;
|
||||
const ambienceRampRevisionAtMidpoint = debug.ambience.volumeRamp.revision;
|
||||
director.playSoundscape({
|
||||
musicKey: 'music-a',
|
||||
ambienceKey: 'ambience-a',
|
||||
musicVolume: 0.18,
|
||||
ambienceVolume: 0.05
|
||||
});
|
||||
debug = director.getDebugState();
|
||||
assert.equal(
|
||||
debug.music.volumeRamp.revision,
|
||||
musicRampRevisionAtMidpoint,
|
||||
'an identical same-key request must not restart the active music ramp'
|
||||
);
|
||||
assert.equal(
|
||||
debug.ambience.volumeRamp.revision,
|
||||
ambienceRampRevisionAtMidpoint,
|
||||
'an identical same-key request must not restart the active ambience ramp'
|
||||
);
|
||||
assert.equal(audioHarness.instances.length, countBeforeSameKey, 'an identical ramp request must retain loop elements');
|
||||
|
||||
clock.advance(208);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.music.volumeRamp.active, false, 'same-key music ramp should complete after 400ms');
|
||||
assert.equal(debug.ambience.volumeRamp.active, false, 'same-key ambience ramp should complete after 400ms');
|
||||
assert.equal(debug.music.volumeRamp.completedRevision, 1, 'music gain ramp completion revision');
|
||||
assert.equal(debug.ambience.volumeRamp.completedRevision, 1, 'ambience gain ramp completion revision');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.18, 'same-key music settled volume');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.05, 'same-key ambience settled volume');
|
||||
|
||||
director.playSoundscape({
|
||||
musicKey: 'music-b',
|
||||
@@ -479,6 +543,183 @@ try {
|
||||
assertCompletedChannel(debug.ambience, 3, 0.04, 'rapid ambience fade');
|
||||
assert.equal(audioHarness.activeInstances().length, 2, 'only the current music and ambience may remain active');
|
||||
|
||||
director.playSoundscape({
|
||||
musicKey: 'music-b',
|
||||
ambienceKey: 'ambience-b',
|
||||
musicVolume: 0.2,
|
||||
ambienceVolume: 0.06
|
||||
});
|
||||
director.playSoundscape({
|
||||
musicKey: 'music-b',
|
||||
ambienceKey: 'ambience-b',
|
||||
musicVolume: 0.17,
|
||||
ambienceVolume: 0.045
|
||||
});
|
||||
debug = director.getDebugState();
|
||||
assert.equal(
|
||||
debug.music.transition.active,
|
||||
true,
|
||||
'same-key music retargeting during a crossfade must keep the transition active'
|
||||
);
|
||||
assert.equal(
|
||||
debug.ambience.transition.active,
|
||||
true,
|
||||
'same-key ambience retargeting during a crossfade must keep the transition active'
|
||||
);
|
||||
assert.equal(
|
||||
debug.music.volumeRamp.active,
|
||||
false,
|
||||
'music retargeting must wait until the active crossfade completes'
|
||||
);
|
||||
assert.equal(
|
||||
debug.ambience.volumeRamp.active,
|
||||
false,
|
||||
'ambience retargeting must wait until the active crossfade completes'
|
||||
);
|
||||
assert.equal(debug.music.targetVolume, 0.17, 'music must retain the newest deferred target');
|
||||
assert.equal(debug.ambience.targetVolume, 0.045, 'ambience must retain the newest deferred target');
|
||||
|
||||
clock.advance(1300);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.music.transition.active, false, 'deferred music crossfade should complete');
|
||||
assert.equal(debug.ambience.transition.active, false, 'deferred ambience crossfade should complete');
|
||||
assert.equal(
|
||||
debug.music.volumeRamp.active,
|
||||
true,
|
||||
'music must begin a deferred gain ramp after its crossfade'
|
||||
);
|
||||
assert.equal(
|
||||
debug.ambience.volumeRamp.active,
|
||||
true,
|
||||
'ambience must begin a deferred gain ramp after its crossfade'
|
||||
);
|
||||
const delayedMusicB = audioHarness.instances
|
||||
.filter((element) => element.originalSrc === '/audio/music-b.ogg')
|
||||
.at(-1);
|
||||
const delayedAmbienceB = audioHarness.instances
|
||||
.filter((element) => element.originalSrc === '/audio/ambience-b.ogg')
|
||||
.at(-1);
|
||||
assert(
|
||||
delayedMusicB.volume > 0.17 && delayedMusicB.volume < 0.2,
|
||||
`deferred music ramp must interpolate from the transition target: ${delayedMusicB.volume}`
|
||||
);
|
||||
assert(
|
||||
delayedAmbienceB.volume > 0.045 && delayedAmbienceB.volume < 0.06,
|
||||
`deferred ambience ramp must interpolate from the transition target: ${delayedAmbienceB.volume}`
|
||||
);
|
||||
|
||||
clock.advance(420);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.music.volumeRamp.active, false, 'deferred music ramp should settle');
|
||||
assert.equal(debug.ambience.volumeRamp.active, false, 'deferred ambience ramp should settle');
|
||||
assertClose(delayedMusicB.volume, 0.17, 'deferred music settled volume');
|
||||
assertClose(delayedAmbienceB.volume, 0.045, 'deferred ambience settled volume');
|
||||
|
||||
director.playSoundscape({
|
||||
musicKey: 'music-b',
|
||||
ambienceKey: 'ambience-b',
|
||||
musicVolume: 0.14,
|
||||
ambienceVolume: 0.035
|
||||
});
|
||||
clock.advance(96);
|
||||
const interruptedMusicVolume = delayedMusicB.volume;
|
||||
const interruptedAmbienceVolume = delayedAmbienceB.volume;
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.music.volumeRamp.active, true, 'music interruption fixture must have an active ramp');
|
||||
assert.equal(debug.ambience.volumeRamp.active, true, 'ambience interruption fixture must have an active ramp');
|
||||
|
||||
director.playSoundscape({
|
||||
musicKey: 'music-c',
|
||||
ambienceKey: 'ambience-c',
|
||||
musicVolume: 0.16,
|
||||
ambienceVolume: 0.04
|
||||
});
|
||||
debug = director.getDebugState();
|
||||
assert.equal(
|
||||
debug.music.volumeRamp.active,
|
||||
false,
|
||||
'a new music key must cancel the superseded gain ramp'
|
||||
);
|
||||
assert.equal(
|
||||
debug.ambience.volumeRamp.active,
|
||||
false,
|
||||
'a new ambience key must cancel the superseded gain ramp'
|
||||
);
|
||||
assertClose(
|
||||
delayedMusicB.volume,
|
||||
interruptedMusicVolume,
|
||||
'the interrupted music ramp must hand its current gain to the outgoing fade'
|
||||
);
|
||||
assertClose(
|
||||
delayedAmbienceB.volume,
|
||||
interruptedAmbienceVolume,
|
||||
'the interrupted ambience ramp must hand its current gain to the outgoing fade'
|
||||
);
|
||||
|
||||
clock.advance(1300);
|
||||
debug = director.getDebugState();
|
||||
assertRetired(delayedMusicB, 'interrupted music ramp outgoing retirement');
|
||||
assertRetired(delayedAmbienceB, 'interrupted ambience ramp outgoing retirement');
|
||||
assertCompletedChannel(debug.music, 5, 0.16, 'interrupted music ramp transition');
|
||||
assertCompletedChannel(debug.ambience, 5, 0.04, 'interrupted ambience ramp transition');
|
||||
assert.equal(
|
||||
audioHarness.activeInstances().length,
|
||||
2,
|
||||
'ramp interruption verification must leave one music and one ambience loop'
|
||||
);
|
||||
const resumedMusicC = audioHarness.instances
|
||||
.filter((element) => element.originalSrc === '/audio/music-c.ogg')
|
||||
.at(-1);
|
||||
const resumedAmbienceC = audioHarness.instances
|
||||
.filter((element) => element.originalSrc === '/audio/ambience-c.ogg')
|
||||
.at(-1);
|
||||
|
||||
director.playSoundscape({
|
||||
musicKey: 'music-c',
|
||||
ambienceKey: 'ambience-c',
|
||||
musicVolume: 0.12,
|
||||
ambienceVolume: 0.03
|
||||
});
|
||||
clock.advance(96);
|
||||
director.playSoundscape({
|
||||
musicKey: 'music-late',
|
||||
ambienceKey: 'ambience-late',
|
||||
musicVolume: 0.71,
|
||||
ambienceVolume: 0.33
|
||||
});
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.pendingMusicKey, 'music-late', 'an unregistered music request must remain pending');
|
||||
assert.equal(debug.pendingAmbienceKey, 'ambience-late', 'an unregistered ambience request must remain pending');
|
||||
assert.equal(debug.music.volumeRamp.active, true, 'the current music ramp must continue while a future key is pending');
|
||||
assert.equal(debug.ambience.volumeRamp.active, true, 'the current ambience ramp must continue while a future key is pending');
|
||||
clock.advance(420);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.music.volumeRamp.active, false, 'the current music ramp must settle while a future key remains pending');
|
||||
assert.equal(debug.ambience.volumeRamp.active, false, 'the current ambience ramp must settle while a future key remains pending');
|
||||
assertClose(
|
||||
resumedMusicC.volume,
|
||||
0.12,
|
||||
'a pending unregistered music target must not replace the active ramp endpoint'
|
||||
);
|
||||
assertClose(
|
||||
resumedAmbienceC.volume,
|
||||
0.03,
|
||||
'a pending unregistered ambience target must not replace the active ramp endpoint'
|
||||
);
|
||||
|
||||
director.playSoundscape({
|
||||
musicKey: 'music-c',
|
||||
ambienceKey: 'ambience-c',
|
||||
musicVolume: 0.16,
|
||||
ambienceVolume: 0.04
|
||||
});
|
||||
clock.advance(420);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.pendingMusicKey, null, 'returning to the current music key must clear the stale pending request');
|
||||
assert.equal(debug.pendingAmbienceKey, null, 'returning to the current ambience key must clear the stale pending request');
|
||||
assertClose(resumedMusicC.volume, 0.16, 'music should ramp back after clearing an unresolved request');
|
||||
assertClose(resumedAmbienceC.volume, 0.04, 'ambience should ramp back after clearing an unresolved request');
|
||||
|
||||
director.stopAmbience();
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.currentAmbienceKey, null, 'stopAmbience should clear the current ambience key immediately');
|
||||
@@ -492,7 +733,7 @@ try {
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.ambience.transition.active, false, 'ambience stop transition should complete');
|
||||
assert.equal(debug.ambience.activeElementCount, 0, 'ambience stop must retire its final element');
|
||||
assertRetired(audioHarness.byOriginalSrc('/audio/ambience-c.ogg'), 'stopped ambience');
|
||||
assertRetired(resumedAmbienceC, 'stopped ambience');
|
||||
assert.equal(debug.music.activeElementCount, 1, 'stopping ambience must not disturb music');
|
||||
|
||||
director.playAmbience('battle-fire', 0.13);
|
||||
@@ -522,12 +763,19 @@ try {
|
||||
assert.equal(debug.currentAmbienceKey, null, 'legacy playMusic must clear camp ambience');
|
||||
clock.advance(1300);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.music.volumeRamp.active, false, 'legacy same-key music ramp should settle');
|
||||
assertClose(resumedMusicC.volume, 0.22, 'legacy music settled volume');
|
||||
assert.equal(debug.ambience.activeElementCount, 0, 'legacy playMusic ambience cleanup should complete');
|
||||
assert.equal(audioHarness.activeInstances().length, 1, 'the verifier must finish without orphaned loop elements');
|
||||
|
||||
const tacticalCueStart = audioHarness.instances.length;
|
||||
assert.equal(director.playAllyTurnCue(), true, 'the first allied turn cue should play');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/ally-turn.ogg').volume, 0.32, 'allied turn cue volume');
|
||||
assertClose(
|
||||
audioHarness.byOriginalSrc('/audio/ally-turn.ogg').playbackRate,
|
||||
1,
|
||||
'semantic cues must not receive pooled combat rate variation'
|
||||
);
|
||||
assert.equal(director.playAllyTurnCue(), false, 'a repeated allied turn cue should be coalesced');
|
||||
assert.equal(director.playEnemyTurnCue(), false, 'turn cue cooldown should be shared across factions');
|
||||
|
||||
@@ -637,9 +885,18 @@ try {
|
||||
|
||||
const semanticEffectStart = audioHarness.instances.length;
|
||||
director.playMeleeSwing(true, false);
|
||||
assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool');
|
||||
const firstMeleeSwing = director.getDebugState().lastEffect;
|
||||
assert.equal(firstMeleeSwing?.key, 'melee-swing', 'melee API should use the swing pool');
|
||||
assertClose(firstMeleeSwing?.rate, 0.99, 'first pooled melee swing rate variation');
|
||||
director.playMeleeSwing(true, false);
|
||||
const secondMeleeSwing = director.getDebugState().lastEffect;
|
||||
assert.equal(secondMeleeSwing?.key, 'melee-swing', 'repeated melee API should keep using the swing pool');
|
||||
assertClose(secondMeleeSwing?.rate, 1.015, 'second pooled melee swing rate variation');
|
||||
assert.notEqual(firstMeleeSwing?.rate, secondMeleeSwing?.rate, 'pooled combat rate variation should advance by logical key');
|
||||
director.playArrowShot();
|
||||
assert.equal(director.getDebugState().lastEffect?.key, 'bow-release', 'arrow API should play the bow release');
|
||||
const arrowShot = director.getDebugState().lastEffect;
|
||||
assert.equal(arrowShot?.key, 'bow-release', 'arrow API should play the bow release');
|
||||
assertClose(arrowShot?.rate, 1, 'direct combat effects must not receive pooled variation');
|
||||
director.playArrowImpact(false, false);
|
||||
assert.equal(director.getDebugState().lastEffect?.key, 'arrow-miss', 'missed arrows should use the miss pool');
|
||||
director.playArrowImpact(true, true);
|
||||
@@ -696,10 +953,12 @@ try {
|
||||
1,
|
||||
`semantic API verification should leave only music active after ${audioHarness.instances.length - semanticEffectStart} effects`
|
||||
);
|
||||
assert.equal(clock.timers.size, 0, 'completed and superseded audio ramps must not leave timers behind');
|
||||
|
||||
console.log(
|
||||
`Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` +
|
||||
`${debug.ambience.elementRevision} ambience element revisions, peak-aware loop gain, gain-balanced no-repeat effect pools, ` +
|
||||
'400ms same-key gain ramps, deterministic movement and pooled-combat micro-variation, ' +
|
||||
'route-sampled terrain movement with bounded foot and hoof voices, ' +
|
||||
'coalesced tactical, narrative, and feedback cues, semantic combat effects, category multipliers, and deterministic music ducking.'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user