139 lines
5.3 KiB
TypeScript
139 lines
5.3 KiB
TypeScript
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('finishes an audible lift entry smoothly before scheduling its exit', () => {
|
|
const harness = createFakeAudioHarness();
|
|
const mixer = createAdaptiveMixer(harness.context, harness.destination);
|
|
const liftGate = harness.gainNodes[3]!.gain;
|
|
const liftEvents = recordFor(harness, liftGate).events;
|
|
mixer.setTransportAnchor(0);
|
|
mixer.setDriveLiftAvailableAt(0);
|
|
harness.setCurrentTime(1);
|
|
mixer.setDriveEnergy({ energy: 0.7, source: 'physical' });
|
|
const eventsBeforeDrop = liftEvents.length;
|
|
|
|
const entryMidpoint = 3.5 * MUSIC_BEAT_SECONDS;
|
|
harness.setCurrentTime(entryMidpoint);
|
|
mixer.setDriveEnergy({ energy: 0.2, source: 'physical' });
|
|
|
|
expect(liftEvents).toContainEqual({
|
|
kind: 'linear',
|
|
value: 1,
|
|
at: 4 * MUSIC_BEAT_SECONDS,
|
|
});
|
|
expect(liftEvents.slice(eventsBeforeDrop)).toEqual([
|
|
{ kind: 'set', value: 1, at: 5 * MUSIC_BEAT_SECONDS },
|
|
{ kind: 'linear', value: 0, at: 6 * MUSIC_BEAT_SECONDS },
|
|
]);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|