feat: add adaptive soundtrack mixer
This commit is contained in:
@@ -24,7 +24,7 @@ export interface FakeAudioHarness {
|
||||
readonly context: AudioContext;
|
||||
readonly params: FakeAudioParamRecord[];
|
||||
readonly sources: FakeSourceRecord[];
|
||||
readonly gainNodes: GainNode[];
|
||||
readonly gainNodes: readonly GainNode[];
|
||||
readonly buffers: AudioBuffer[];
|
||||
readonly destination: AudioNode;
|
||||
setCurrentTime(seconds: number): void;
|
||||
|
||||
112
tests/unit/adaptive-mixer.test.ts
Normal file
112
tests/unit/adaptive-mixer.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createAdaptiveMixer } from '@/audio/adaptive-mixer';
|
||||
import { MUSIC_BEAT_SECONDS } from '@/audio/soundtrack-transport';
|
||||
import {
|
||||
createFakeAudioHarness,
|
||||
type FakeAudioHarness,
|
||||
type FakeAudioParamRecord,
|
||||
} from '../helpers/fake-audio-context';
|
||||
|
||||
function recordFor(
|
||||
harness: FakeAudioHarness,
|
||||
parameter: AudioParam,
|
||||
): FakeAudioParamRecord {
|
||||
const record = harness.params.find((candidate) => candidate.param === parameter);
|
||||
if (record === undefined) throw new Error('Missing fake AudioParam record.');
|
||||
return record;
|
||||
}
|
||||
|
||||
describe('adaptive mixer', () => {
|
||||
it('ramps only the four approved mood controls over 1.4 seconds', () => {
|
||||
const harness = createFakeAudioHarness();
|
||||
const mixer = createAdaptiveMixer(harness.context, harness.destination);
|
||||
harness.setCurrentTime(2);
|
||||
mixer.setChapterIntent({
|
||||
sectionId: 'toolkit',
|
||||
mood: 'resolve',
|
||||
atStart: false,
|
||||
entryPhrase: 6,
|
||||
generation: 1,
|
||||
});
|
||||
const events = harness.params.flatMap((record) => record.events);
|
||||
expect(events.filter((event) => event.kind === 'linear' && event.at === 3.4))
|
||||
.toHaveLength(4);
|
||||
});
|
||||
|
||||
it('arms lift after sustained physical input but never for navigation', () => {
|
||||
const harness = createFakeAudioHarness();
|
||||
const mixer = createAdaptiveMixer(harness.context, harness.destination);
|
||||
const liftGate = harness.gainNodes[3]!.gain;
|
||||
mixer.setTransportAnchor(0);
|
||||
mixer.setDriveLiftAvailableAt(0);
|
||||
|
||||
harness.setCurrentTime(1);
|
||||
mixer.setDriveEnergy({ energy: 1, source: 'navigation' });
|
||||
expect(recordFor(harness, liftGate).events)
|
||||
.not.toContainEqual(expect.objectContaining({ kind: 'linear', value: 1 }));
|
||||
|
||||
mixer.setDriveEnergy({ energy: 0.7, source: 'physical' });
|
||||
expect(recordFor(harness, liftGate).events).toContainEqual({
|
||||
kind: 'linear',
|
||||
value: 1,
|
||||
at: 4 * MUSIC_BEAT_SECONDS,
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels an unsustained entry and schedules hysteretic exit', () => {
|
||||
const harness = createFakeAudioHarness();
|
||||
const mixer = createAdaptiveMixer(harness.context, harness.destination);
|
||||
const liftGate = harness.gainNodes[3]!.gain;
|
||||
mixer.setTransportAnchor(0);
|
||||
mixer.setDriveLiftAvailableAt(0);
|
||||
harness.setCurrentTime(1);
|
||||
mixer.setDriveEnergy({ energy: 0.7, source: 'physical' });
|
||||
harness.setCurrentTime(1.2);
|
||||
mixer.setDriveEnergy({ energy: 0.5, source: 'physical' });
|
||||
expect(recordFor(harness, liftGate).events)
|
||||
.toContainEqual({ kind: 'hold', at: 1.2 });
|
||||
|
||||
harness.setCurrentTime(2);
|
||||
mixer.setDriveEnergy({ energy: 0.7, source: 'physical' });
|
||||
harness.setCurrentTime(5);
|
||||
mixer.setDriveEnergy({ energy: 0.2, source: 'physical' });
|
||||
expect(recordFor(harness, liftGate).events)
|
||||
.toContainEqual(expect.objectContaining({ kind: 'linear', value: 0 }));
|
||||
});
|
||||
|
||||
it('replays cached strong drive only after a late loop source is ready', () => {
|
||||
const harness = createFakeAudioHarness();
|
||||
const mixer = createAdaptiveMixer(harness.context, harness.destination);
|
||||
const liftGate = harness.gainNodes[3]!.gain;
|
||||
mixer.setDriveEnergy({ energy: 0.8, source: 'physical' });
|
||||
mixer.setTransportAnchor(0);
|
||||
expect(recordFor(harness, liftGate).events)
|
||||
.not.toContainEqual(expect.objectContaining({ kind: 'linear', value: 1 }));
|
||||
|
||||
harness.setCurrentTime(5);
|
||||
mixer.setDriveLiftAvailableAt(5.08);
|
||||
const entryEnd = recordFor(harness, liftGate).events.findLast(
|
||||
(event) => event.kind === 'linear' && event.value === 1,
|
||||
);
|
||||
expect(entryEnd).toBeDefined();
|
||||
expect(entryEnd!.at).toBeGreaterThan(5.08 + 0.45);
|
||||
expect(entryEnd!.at / MUSIC_BEAT_SECONDS).toBeCloseTo(
|
||||
Math.round(entryEnd!.at / MUSIC_BEAT_SECONDS),
|
||||
8,
|
||||
);
|
||||
});
|
||||
|
||||
it('disposes each owned mixer node once without disconnecting the destination', () => {
|
||||
const harness = createFakeAudioHarness();
|
||||
const mixer = createAdaptiveMixer(harness.context, harness.destination);
|
||||
const destination = harness.destination as AudioNode & { disconnectCount: number };
|
||||
mixer.dispose();
|
||||
mixer.dispose();
|
||||
|
||||
expect(harness.gainNodes).toHaveLength(8);
|
||||
for (const node of harness.gainNodes) {
|
||||
expect((node as GainNode & { disconnectCount: number }).disconnectCount).toBe(1);
|
||||
}
|
||||
expect(destination.disconnectCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AFTERGLOW_LOOP_END_SAMPLE,
|
||||
AFTERGLOW_LOOP_START_SAMPLE,
|
||||
AFTERGLOW_TRANSITION_SECONDS,
|
||||
DRIVE_LIFT_LOOP_SAMPLES,
|
||||
DRIVE_LIFT_LOOP_SECONDS,
|
||||
MAIN_DURATION_SAMPLES,
|
||||
MAIN_DURATION_SECONDS,
|
||||
MAIN_PHRASE_BARS,
|
||||
MUSIC_BAR_SAMPLES,
|
||||
MUSIC_BAR_SECONDS,
|
||||
MUSIC_BEAT_SAMPLES,
|
||||
MUSIC_BEAT_SECONDS,
|
||||
INTRO_IN_END_SECONDS,
|
||||
SOUNDTRACK_SAMPLE_RATE,
|
||||
mainPhraseOffsetSeconds,
|
||||
nextBeatTime,
|
||||
nextSafeBarTime,
|
||||
phaseAlignedLoopOffset,
|
||||
resolveMainSchedule,
|
||||
resolveInitialPlayback,
|
||||
} from '@/audio/soundtrack-transport';
|
||||
|
||||
@@ -50,4 +63,44 @@ describe('soundtrack transport decisions', () => {
|
||||
);
|
||||
expect(earliestIntroExit).toBeCloseTo(anchorTime + MUSIC_BAR_SECONDS * 2, 8);
|
||||
});
|
||||
|
||||
it('pins the short adaptive regions to whole samples', () => {
|
||||
expect(MUSIC_BEAT_SAMPLES).toBe(33_488);
|
||||
expect(MUSIC_BAR_SAMPLES).toBe(133_953);
|
||||
expect(DRIVE_LIFT_LOOP_SAMPLES).toBe(535_814);
|
||||
expect(AFTERGLOW_LOOP_START_SAMPLE).toBe(133_953);
|
||||
expect(AFTERGLOW_LOOP_END_SAMPLE).toBe(1_205_581);
|
||||
expect(MAIN_DURATION_SAMPLES).toBe(8_573_023);
|
||||
});
|
||||
|
||||
it('derives one immutable main anchor and the final-bar afterglow handoff', () => {
|
||||
expect(resolveMainSchedule({
|
||||
startTime: 10,
|
||||
offsetSeconds: mainPhraseOffsetSeconds(6),
|
||||
bufferDuration: MAIN_DURATION_SECONDS,
|
||||
})).toEqual({
|
||||
sourceStartTime: 10,
|
||||
sourceOffsetSeconds: mainPhraseOffsetSeconds(6),
|
||||
musicalAnchorTime: 10 - mainPhraseOffsetSeconds(6),
|
||||
sourceEndTime: 10 + MAIN_DURATION_SECONDS - mainPhraseOffsetSeconds(6),
|
||||
afterglowStartTime: 10 + MAIN_DURATION_SECONDS - mainPhraseOffsetSeconds(6) - AFTERGLOW_TRANSITION_SECONDS,
|
||||
});
|
||||
});
|
||||
|
||||
it('aligns a late loop source and sustained entry to the master beat', () => {
|
||||
expect(phaseAlignedLoopOffset(13, 10, DRIVE_LIFT_LOOP_SECONDS))
|
||||
.toBeCloseTo(3, 8);
|
||||
expect(nextBeatTime(11.45, 10)).toBeCloseTo(
|
||||
10 + 3 * MUSIC_BEAT_SECONDS,
|
||||
8,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps one-sample-rounded beat and bar boundaries on the current boundary', () => {
|
||||
const oneSample = 1 / SOUNDTRACK_SAMPLE_RATE;
|
||||
expect(nextBeatTime(MUSIC_BEAT_SECONDS + oneSample, 0))
|
||||
.toBeCloseTo(MUSIC_BEAT_SECONDS, 8);
|
||||
expect(nextSafeBarTime(MUSIC_BAR_SECONDS + oneSample, 0, 0))
|
||||
.toBeCloseTo(MUSIC_BAR_SECONDS, 8);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user