feat: add adaptive soundtrack mixer

This commit is contained in:
2026-09-04 15:57:16 +03:00
parent 6fce20a228
commit 4c7d46ba0e
5 changed files with 466 additions and 1 deletions

231
src/audio/adaptive-mixer.ts Normal file
View 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();
},
};
};

View File

@@ -2,9 +2,30 @@ import type { ChapterIntent } from '@/audio/types';
export const SOUNDTRACK_SAMPLE_RATE = 48_000;
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_SAMPLES = 133_953;
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_FIRST_PASS_END_SAMPLE = 1_339_535;
export const INTRO_LOOP_START_SAMPLE = INTRO_FIRST_PASS_END_SAMPLE;
@@ -51,3 +72,51 @@ export function nextSafeBarTime(
const nextBar = Math.ceil(elapsedBars - oneSampleInBars);
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,
};
}