241 lines
8.0 KiB
TypeScript
241 lines
8.0 KiB
TypeScript
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;
|
|
/** END OF SHIFT: the dawn chord, summed after the mood controls. */
|
|
readonly dawnInput: 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();
|
|
// The dawn chord joins the sum directly: the output gain (mute, volume) and
|
|
// the pulse tap govern it, and the chapter moods leave it alone.
|
|
const dawnInput = 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);
|
|
dawnInput.connect(sum);
|
|
sum.connect(destination);
|
|
|
|
const ownedNodes: readonly AudioNode[] = [
|
|
bedInput,
|
|
bedTrim,
|
|
highShelf,
|
|
driveLiftInput,
|
|
driveLiftGate,
|
|
driveLiftCap,
|
|
driveTextureInput,
|
|
textureSend,
|
|
dawnInput,
|
|
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;
|
|
const entryWillComplete = liftPhase === 'entering';
|
|
scheduledStart = nextBeatTime(
|
|
now + LIFT_EXIT_DWELL_SECONDS,
|
|
transportAnchor,
|
|
);
|
|
scheduledEnd = scheduledStart + MUSIC_BEAT_SECONDS;
|
|
if (!entryWillComplete) 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,
|
|
dawnInput,
|
|
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();
|
|
},
|
|
};
|
|
};
|