Files
Portfolio/tests/unit/adaptive-mixer.test.ts

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);
});
});