feat(audio): sweep a pass-by through the stereo field on each exit

This commit is contained in:
2026-10-10 19:50:38 +03:00
parent 27da28c7cc
commit b5ef8a25a2
2 changed files with 41 additions and 0 deletions

View File

@@ -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;

View File

@@ -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) {