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