diff --git a/src/audio/drive-texture.ts b/src/audio/drive-texture.ts index 7c70140..d6b030b 100644 --- a/src/audio/drive-texture.ts +++ b/src/audio/drive-texture.ts @@ -8,9 +8,13 @@ const CRUISE_ENGINE_GAIN = 0.055; const MAX_ENGINE_GAIN = 0.12; const CRUISE_ROAD_GAIN = 0.006; const MAX_ROAD_GAIN = 0.024; +const PASS_BY_GAIN = 0.05; +const PASS_BY_SECONDS = 1.6; export interface DriveTexture { setEnergy(sample: DriveEnergySample): void; + /** The exit gates going by: a filtered air sweep across the stereo field. */ + passBy?(): void; dispose(): void; } @@ -95,11 +99,29 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) => roadLowpass.connect(roadGain); roadGain.connect(destination); + // Pass-by: preallocated and silent until an exit, so a chapter change + // schedules automation only and never creates nodes. + const passSource = context.createBufferSource(); + passSource.buffer = roadSource.buffer; + passSource.loop = true; + const passBand = context.createBiquadFilter(); + passBand.type = 'bandpass'; + passBand.frequency.value = 500; + passBand.Q.value = 0.9; + const passPan = context.createStereoPanner(); + const passGain = context.createGain(); + passGain.gain.value = 0; + passSource.connect(passBand); + passBand.connect(passPan); + passPan.connect(passGain); + passGain.connect(destination); + const sources: readonly AudioScheduledSourceNode[] = [ fundamental.source, harmonic.source, ignition.source, roadSource, + passSource, ]; const ownedNodes: readonly AudioNode[] = [ ...sources, @@ -111,6 +133,9 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) => roadHighpass, roadLowpass, roadGain, + passBand, + passPan, + passGain, ]; for (const source of sources) source.start(); @@ -135,6 +160,19 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) => automate(roadGain.gain, CRUISE_ROAD_GAIN + next * (MAX_ROAD_GAIN - CRUISE_ROAD_GAIN), duration); currentEnergy = next; }, + passBy() { + if (disposed) return; + const now = context.currentTime; + for (const parameter of [passGain.gain, passBand.frequency, passPan.pan]) { + holdParameter(parameter, now); + } + passGain.gain.linearRampToValueAtTime(PASS_BY_GAIN, now + 0.5); + passGain.gain.linearRampToValueAtTime(0, now + PASS_BY_SECONDS); + passBand.frequency.linearRampToValueAtTime(2_600, now + 0.5); + passBand.frequency.linearRampToValueAtTime(380, now + PASS_BY_SECONDS); + passPan.pan.linearRampToValueAtTime(-0.75, now + 0.05); + passPan.pan.linearRampToValueAtTime(0.75, now + PASS_BY_SECONDS); + }, dispose() { if (disposed) return; disposed = true; diff --git a/src/audio/soundtrack-engine.ts b/src/audio/soundtrack-engine.ts index b129d5a..468cd84 100644 --- a/src/audio/soundtrack-engine.ts +++ b/src/audio/soundtrack-engine.ts @@ -554,6 +554,9 @@ export function createSoundtrackEngine( }, setChapterIntent(intent) { if (closed) return; + // The scene runs its exit gates on every chapter change; the drive + // texture answers with the air of the gates going by. + if (intent.sectionId !== latestChapter.sectionId) driveTexture?.passBy?.(); latestChapter = intent; mixer.setChapterIntent(intent); if (!mainCommitted && introStarted) {