feat: add adaptive soundtrack mixer
This commit is contained in:
231
src/audio/adaptive-mixer.ts
Normal file
231
src/audio/adaptive-mixer.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { MUSIC_BEAT_SECONDS, nextBeatTime } from '@/audio/soundtrack-transport';
|
||||||
|
import type { ChapterIntent, DriveEnergySample, MusicMood } from '@/audio/types';
|
||||||
|
|
||||||
|
const MOOD_RAMP_SECONDS = 1.4;
|
||||||
|
const LIFT_ENTRY_ENERGY = 0.62;
|
||||||
|
const LIFT_EXIT_ENERGY = 0.32;
|
||||||
|
const LIFT_ENTRY_DWELL_SECONDS = 0.45;
|
||||||
|
const LIFT_EXIT_DWELL_SECONDS = 0.8;
|
||||||
|
|
||||||
|
interface MixProfile {
|
||||||
|
readonly bedGain: number;
|
||||||
|
readonly highShelfDb: number;
|
||||||
|
readonly textureSend: number;
|
||||||
|
readonly liftCap: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIX_PROFILES = Object.freeze({
|
||||||
|
open: { bedGain: 0.82, highShelfDb: 0, textureSend: 0.82, liftCap: 0.13 },
|
||||||
|
shadow: { bedGain: 0.81, highShelfDb: -0.75, textureSend: 0.9, liftCap: 0.1 },
|
||||||
|
lift: { bedGain: 0.82, highShelfDb: 0.65, textureSend: 0.9, liftCap: 0.17 },
|
||||||
|
resolve: { bedGain: 0.81, highShelfDb: -0.15, textureSend: 0.74, liftCap: 0.11 },
|
||||||
|
} as const satisfies Readonly<Record<MusicMood, MixProfile>>);
|
||||||
|
|
||||||
|
type LiftPhase = 'off' | 'entering' | 'on' | 'exiting';
|
||||||
|
|
||||||
|
export interface AdaptiveMixer {
|
||||||
|
readonly bedInput: GainNode;
|
||||||
|
readonly driveLiftInput: GainNode;
|
||||||
|
readonly driveTextureInput: GainNode;
|
||||||
|
setChapterIntent(intent: ChapterIntent): void;
|
||||||
|
setDriveEnergy(sample: DriveEnergySample): void;
|
||||||
|
setTransportAnchor(anchorTime: number | null): void;
|
||||||
|
setDriveLiftAvailableAt(startTime: number | null): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdaptiveMixerFactory = (
|
||||||
|
context: AudioContext,
|
||||||
|
destination: AudioNode,
|
||||||
|
) => AdaptiveMixer;
|
||||||
|
|
||||||
|
function holdParameter(parameter: AudioParam, atTime: number): void {
|
||||||
|
if (typeof parameter.cancelAndHoldAtTime === 'function') {
|
||||||
|
parameter.cancelAndHoldAtTime(atTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parameter.cancelScheduledValues(atTime);
|
||||||
|
parameter.setValueAtTime(parameter.value, atTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination) => {
|
||||||
|
const bedInput = context.createGain();
|
||||||
|
const bedTrim = context.createGain();
|
||||||
|
const driveLiftInput = context.createGain();
|
||||||
|
const driveLiftGate = context.createGain();
|
||||||
|
const driveLiftCap = context.createGain();
|
||||||
|
const driveTextureInput = context.createGain();
|
||||||
|
const textureSend = context.createGain();
|
||||||
|
const sum = context.createGain();
|
||||||
|
const highShelf = context.createBiquadFilter();
|
||||||
|
|
||||||
|
const initialProfile = MIX_PROFILES.open;
|
||||||
|
bedTrim.gain.value = initialProfile.bedGain;
|
||||||
|
highShelf.type = 'highshelf';
|
||||||
|
highShelf.frequency.value = 5_200;
|
||||||
|
highShelf.Q.value = 0.55;
|
||||||
|
highShelf.gain.value = initialProfile.highShelfDb;
|
||||||
|
driveLiftGate.gain.value = 0;
|
||||||
|
driveLiftCap.gain.value = initialProfile.liftCap;
|
||||||
|
textureSend.gain.value = initialProfile.textureSend;
|
||||||
|
|
||||||
|
bedInput.connect(bedTrim);
|
||||||
|
bedTrim.connect(highShelf);
|
||||||
|
highShelf.connect(sum);
|
||||||
|
driveLiftInput.connect(driveLiftGate);
|
||||||
|
driveLiftGate.connect(driveLiftCap);
|
||||||
|
driveLiftCap.connect(sum);
|
||||||
|
driveTextureInput.connect(textureSend);
|
||||||
|
textureSend.connect(sum);
|
||||||
|
sum.connect(destination);
|
||||||
|
|
||||||
|
const ownedNodes: readonly AudioNode[] = [
|
||||||
|
bedInput,
|
||||||
|
bedTrim,
|
||||||
|
highShelf,
|
||||||
|
driveLiftInput,
|
||||||
|
driveLiftGate,
|
||||||
|
driveLiftCap,
|
||||||
|
driveTextureInput,
|
||||||
|
textureSend,
|
||||||
|
sum,
|
||||||
|
];
|
||||||
|
let transportAnchor: number | null = null;
|
||||||
|
let driveLiftAvailableAt: number | null = null;
|
||||||
|
let latestDrive: DriveEnergySample = { energy: 0, source: 'idle' };
|
||||||
|
let liftPhase: LiftPhase = 'off';
|
||||||
|
let scheduledStart = 0;
|
||||||
|
let scheduledEnd = 0;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
const ramp = (parameter: AudioParam, value: number, duration: number): void => {
|
||||||
|
const now = context.currentTime;
|
||||||
|
holdParameter(parameter, now);
|
||||||
|
parameter.linearRampToValueAtTime(value, now + duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconcileLift = (now: number): void => {
|
||||||
|
if (liftPhase === 'entering' && now >= scheduledEnd) liftPhase = 'on';
|
||||||
|
if (liftPhase === 'exiting' && now >= scheduledEnd) liftPhase = 'off';
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleEntry = (now: number): void => {
|
||||||
|
if (
|
||||||
|
transportAnchor === null ||
|
||||||
|
driveLiftAvailableAt === null ||
|
||||||
|
liftPhase === 'entering' ||
|
||||||
|
liftPhase === 'on'
|
||||||
|
) return;
|
||||||
|
scheduledStart = nextBeatTime(
|
||||||
|
Math.max(now, driveLiftAvailableAt) + LIFT_ENTRY_DWELL_SECONDS,
|
||||||
|
transportAnchor,
|
||||||
|
);
|
||||||
|
scheduledEnd = scheduledStart + MUSIC_BEAT_SECONDS;
|
||||||
|
holdParameter(driveLiftGate.gain, now);
|
||||||
|
driveLiftGate.gain.setValueAtTime(0, scheduledStart);
|
||||||
|
driveLiftGate.gain.linearRampToValueAtTime(1, scheduledEnd);
|
||||||
|
liftPhase = 'entering';
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEntry = (now: number): void => {
|
||||||
|
if (liftPhase !== 'entering' || now >= scheduledStart) return;
|
||||||
|
holdParameter(driveLiftGate.gain, now);
|
||||||
|
driveLiftGate.gain.linearRampToValueAtTime(0, now + 0.05);
|
||||||
|
liftPhase = 'off';
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleExit = (now: number): void => {
|
||||||
|
if (
|
||||||
|
transportAnchor === null ||
|
||||||
|
driveLiftAvailableAt === null ||
|
||||||
|
liftPhase === 'off' ||
|
||||||
|
liftPhase === 'exiting'
|
||||||
|
) return;
|
||||||
|
scheduledStart = nextBeatTime(
|
||||||
|
now + LIFT_EXIT_DWELL_SECONDS,
|
||||||
|
transportAnchor,
|
||||||
|
);
|
||||||
|
scheduledEnd = scheduledStart + MUSIC_BEAT_SECONDS;
|
||||||
|
holdParameter(driveLiftGate.gain, now);
|
||||||
|
driveLiftGate.gain.setValueAtTime(1, scheduledStart);
|
||||||
|
driveLiftGate.gain.linearRampToValueAtTime(0, scheduledEnd);
|
||||||
|
liftPhase = 'exiting';
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelExit = (now: number): void => {
|
||||||
|
if (liftPhase !== 'exiting' || now >= scheduledStart) return;
|
||||||
|
holdParameter(driveLiftGate.gain, now);
|
||||||
|
driveLiftGate.gain.linearRampToValueAtTime(1, now + MUSIC_BEAT_SECONDS);
|
||||||
|
liftPhase = 'on';
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyLatestDrive = (): void => {
|
||||||
|
if (transportAnchor === null || driveLiftAvailableAt === null) return;
|
||||||
|
const now = context.currentTime;
|
||||||
|
reconcileLift(now);
|
||||||
|
const physical = latestDrive.source === 'physical';
|
||||||
|
if (physical && latestDrive.energy > LIFT_ENTRY_ENERGY) {
|
||||||
|
if (liftPhase === 'exiting') {
|
||||||
|
holdParameter(driveLiftGate.gain, now);
|
||||||
|
driveLiftGate.gain.linearRampToValueAtTime(1, now + MUSIC_BEAT_SECONDS);
|
||||||
|
liftPhase = 'on';
|
||||||
|
} else {
|
||||||
|
scheduleEntry(now);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (physical && latestDrive.energy >= LIFT_EXIT_ENERGY) {
|
||||||
|
if (liftPhase === 'entering' && now < scheduledStart) cancelEntry(now);
|
||||||
|
if (liftPhase === 'exiting') cancelExit(now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (liftPhase === 'entering' && now < scheduledStart) cancelEntry(now);
|
||||||
|
if (!physical || latestDrive.energy < LIFT_EXIT_ENERGY) scheduleExit(now);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetLiftGate = (): void => {
|
||||||
|
const now = context.currentTime;
|
||||||
|
holdParameter(driveLiftGate.gain, now);
|
||||||
|
driveLiftGate.gain.linearRampToValueAtTime(0, now + 0.05);
|
||||||
|
liftPhase = 'off';
|
||||||
|
scheduledStart = 0;
|
||||||
|
scheduledEnd = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
bedInput,
|
||||||
|
driveLiftInput,
|
||||||
|
driveTextureInput,
|
||||||
|
setChapterIntent(intent) {
|
||||||
|
if (disposed) return;
|
||||||
|
const profile = MIX_PROFILES[intent.mood];
|
||||||
|
ramp(bedTrim.gain, profile.bedGain, MOOD_RAMP_SECONDS);
|
||||||
|
ramp(highShelf.gain, profile.highShelfDb, MOOD_RAMP_SECONDS);
|
||||||
|
ramp(textureSend.gain, profile.textureSend, MOOD_RAMP_SECONDS);
|
||||||
|
ramp(driveLiftCap.gain, profile.liftCap, MOOD_RAMP_SECONDS);
|
||||||
|
},
|
||||||
|
setDriveEnergy(sample) {
|
||||||
|
if (disposed) return;
|
||||||
|
latestDrive = sample;
|
||||||
|
applyLatestDrive();
|
||||||
|
},
|
||||||
|
setTransportAnchor(anchorTime) {
|
||||||
|
if (disposed) return;
|
||||||
|
transportAnchor = anchorTime;
|
||||||
|
if (anchorTime === null) resetLiftGate();
|
||||||
|
else applyLatestDrive();
|
||||||
|
},
|
||||||
|
setDriveLiftAvailableAt(startTime) {
|
||||||
|
if (disposed) return;
|
||||||
|
driveLiftAvailableAt = startTime;
|
||||||
|
if (startTime === null) resetLiftGate();
|
||||||
|
else applyLatestDrive();
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (disposed) return;
|
||||||
|
resetLiftGate();
|
||||||
|
disposed = true;
|
||||||
|
for (const node of ownedNodes) node.disconnect();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -2,9 +2,30 @@ import type { ChapterIntent } from '@/audio/types';
|
|||||||
|
|
||||||
export const SOUNDTRACK_SAMPLE_RATE = 48_000;
|
export const SOUNDTRACK_SAMPLE_RATE = 48_000;
|
||||||
export const MUSIC_BPM = 86;
|
export const MUSIC_BPM = 86;
|
||||||
|
export const MUSIC_BEAT_SECONDS = 60 / MUSIC_BPM;
|
||||||
|
export const MUSIC_BEAT_SAMPLES = Math.round(
|
||||||
|
SOUNDTRACK_SAMPLE_RATE * MUSIC_BEAT_SECONDS,
|
||||||
|
);
|
||||||
export const MUSIC_BAR_SECONDS = 4 * 60 / MUSIC_BPM;
|
export const MUSIC_BAR_SECONDS = 4 * 60 / MUSIC_BPM;
|
||||||
|
export const MUSIC_BAR_SAMPLES = 133_953;
|
||||||
export const MAIN_PHRASE_BARS = 8;
|
export const MAIN_PHRASE_BARS = 8;
|
||||||
|
|
||||||
|
export const MAIN_DURATION_SAMPLES = 8_573_023;
|
||||||
|
export const MAIN_DURATION_SECONDS = MAIN_DURATION_SAMPLES / SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
export const AFTERGLOW_TRANSITION_SAMPLES = MUSIC_BAR_SAMPLES;
|
||||||
|
export const AFTERGLOW_TRANSITION_SECONDS = AFTERGLOW_TRANSITION_SAMPLES /
|
||||||
|
SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
export const AFTERGLOW_LOOP_START_SAMPLE = AFTERGLOW_TRANSITION_SAMPLES;
|
||||||
|
export const AFTERGLOW_LOOP_SAMPLES = 1_071_628;
|
||||||
|
export const AFTERGLOW_LOOP_END_SAMPLE = 1_205_581;
|
||||||
|
export const AFTERGLOW_LOOP_START_SECONDS = AFTERGLOW_LOOP_START_SAMPLE /
|
||||||
|
SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
export const AFTERGLOW_LOOP_END_SECONDS = AFTERGLOW_LOOP_END_SAMPLE /
|
||||||
|
SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
export const DRIVE_LIFT_LOOP_SAMPLES = 535_814;
|
||||||
|
export const DRIVE_LIFT_LOOP_SECONDS = DRIVE_LIFT_LOOP_SAMPLES /
|
||||||
|
SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
|
||||||
export const INTRO_IN_END_SAMPLE = 267_907;
|
export const INTRO_IN_END_SAMPLE = 267_907;
|
||||||
export const INTRO_FIRST_PASS_END_SAMPLE = 1_339_535;
|
export const INTRO_FIRST_PASS_END_SAMPLE = 1_339_535;
|
||||||
export const INTRO_LOOP_START_SAMPLE = INTRO_FIRST_PASS_END_SAMPLE;
|
export const INTRO_LOOP_START_SAMPLE = INTRO_FIRST_PASS_END_SAMPLE;
|
||||||
@@ -51,3 +72,51 @@ export function nextSafeBarTime(
|
|||||||
const nextBar = Math.ceil(elapsedBars - oneSampleInBars);
|
const nextBar = Math.ceil(elapsedBars - oneSampleInBars);
|
||||||
return anchorTime + nextBar * MUSIC_BAR_SECONDS;
|
return anchorTime + nextBar * MUSIC_BAR_SECONDS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function nextBeatTime(earliest: number, anchorTime: number): number {
|
||||||
|
const elapsedBeats = (Math.max(anchorTime, earliest) - anchorTime) /
|
||||||
|
MUSIC_BEAT_SECONDS;
|
||||||
|
const oneSampleInBeats = 1 / SOUNDTRACK_SAMPLE_RATE / MUSIC_BEAT_SECONDS;
|
||||||
|
return anchorTime + Math.ceil(elapsedBeats - oneSampleInBeats) *
|
||||||
|
MUSIC_BEAT_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function phaseAlignedLoopOffset(
|
||||||
|
when: number,
|
||||||
|
anchorTime: number,
|
||||||
|
loopDuration: number,
|
||||||
|
): number {
|
||||||
|
if (!Number.isFinite(loopDuration) || loopDuration <= 0) return 0;
|
||||||
|
const elapsed = when - anchorTime;
|
||||||
|
return ((elapsed % loopDuration) + loopDuration) % loopDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MainScheduleInput {
|
||||||
|
readonly startTime: number;
|
||||||
|
readonly offsetSeconds: number;
|
||||||
|
readonly bufferDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MainSchedule {
|
||||||
|
readonly sourceStartTime: number;
|
||||||
|
readonly sourceOffsetSeconds: number;
|
||||||
|
readonly musicalAnchorTime: number;
|
||||||
|
readonly sourceEndTime: number;
|
||||||
|
readonly afterglowStartTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMainSchedule(input: MainScheduleInput): MainSchedule {
|
||||||
|
const sourceOffsetSeconds = Math.min(
|
||||||
|
Math.max(0, input.offsetSeconds),
|
||||||
|
Math.max(0, input.bufferDuration - 0.05),
|
||||||
|
);
|
||||||
|
const sourceEndTime = input.startTime + input.bufferDuration -
|
||||||
|
sourceOffsetSeconds;
|
||||||
|
return {
|
||||||
|
sourceStartTime: input.startTime,
|
||||||
|
sourceOffsetSeconds,
|
||||||
|
musicalAnchorTime: input.startTime - sourceOffsetSeconds,
|
||||||
|
sourceEndTime,
|
||||||
|
afterglowStartTime: sourceEndTime - AFTERGLOW_TRANSITION_SECONDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface FakeAudioHarness {
|
|||||||
readonly context: AudioContext;
|
readonly context: AudioContext;
|
||||||
readonly params: FakeAudioParamRecord[];
|
readonly params: FakeAudioParamRecord[];
|
||||||
readonly sources: FakeSourceRecord[];
|
readonly sources: FakeSourceRecord[];
|
||||||
readonly gainNodes: GainNode[];
|
readonly gainNodes: readonly GainNode[];
|
||||||
readonly buffers: AudioBuffer[];
|
readonly buffers: AudioBuffer[];
|
||||||
readonly destination: AudioNode;
|
readonly destination: AudioNode;
|
||||||
setCurrentTime(seconds: number): void;
|
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 { describe, expect, it } from 'vitest';
|
||||||
import {
|
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,
|
MAIN_PHRASE_BARS,
|
||||||
|
MUSIC_BAR_SAMPLES,
|
||||||
MUSIC_BAR_SECONDS,
|
MUSIC_BAR_SECONDS,
|
||||||
|
MUSIC_BEAT_SAMPLES,
|
||||||
|
MUSIC_BEAT_SECONDS,
|
||||||
INTRO_IN_END_SECONDS,
|
INTRO_IN_END_SECONDS,
|
||||||
SOUNDTRACK_SAMPLE_RATE,
|
SOUNDTRACK_SAMPLE_RATE,
|
||||||
mainPhraseOffsetSeconds,
|
mainPhraseOffsetSeconds,
|
||||||
|
nextBeatTime,
|
||||||
nextSafeBarTime,
|
nextSafeBarTime,
|
||||||
|
phaseAlignedLoopOffset,
|
||||||
|
resolveMainSchedule,
|
||||||
resolveInitialPlayback,
|
resolveInitialPlayback,
|
||||||
} from '@/audio/soundtrack-transport';
|
} from '@/audio/soundtrack-transport';
|
||||||
|
|
||||||
@@ -50,4 +63,44 @@ describe('soundtrack transport decisions', () => {
|
|||||||
);
|
);
|
||||||
expect(earliestIntroExit).toBeCloseTo(anchorTime + MUSIC_BAR_SECONDS * 2, 8);
|
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