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 MAX_ENGINE_GAIN = 0.12;
const CRUISE_ROAD_GAIN = 0.006; const CRUISE_ROAD_GAIN = 0.006;
const MAX_ROAD_GAIN = 0.024; const MAX_ROAD_GAIN = 0.024;
const PASS_BY_GAIN = 0.05;
const PASS_BY_SECONDS = 1.6;
export interface DriveTexture { export interface DriveTexture {
setEnergy(sample: DriveEnergySample): void; setEnergy(sample: DriveEnergySample): void;
/** The exit gates going by: a filtered air sweep across the stereo field. */
passBy?(): void;
dispose(): void; dispose(): void;
} }
@@ -95,11 +99,29 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) =>
roadLowpass.connect(roadGain); roadLowpass.connect(roadGain);
roadGain.connect(destination); 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[] = [ const sources: readonly AudioScheduledSourceNode[] = [
fundamental.source, fundamental.source,
harmonic.source, harmonic.source,
ignition.source, ignition.source,
roadSource, roadSource,
passSource,
]; ];
const ownedNodes: readonly AudioNode[] = [ const ownedNodes: readonly AudioNode[] = [
...sources, ...sources,
@@ -111,6 +133,9 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) =>
roadHighpass, roadHighpass,
roadLowpass, roadLowpass,
roadGain, roadGain,
passBand,
passPan,
passGain,
]; ];
for (const source of sources) source.start(); 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); automate(roadGain.gain, CRUISE_ROAD_GAIN + next * (MAX_ROAD_GAIN - CRUISE_ROAD_GAIN), duration);
currentEnergy = next; 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() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;

View File

@@ -554,6 +554,9 @@ export function createSoundtrackEngine(
}, },
setChapterIntent(intent) { setChapterIntent(intent) {
if (closed) return; 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; latestChapter = intent;
mixer.setChapterIntent(intent); mixer.setChapterIntent(intent);
if (!mainCommitted && introStarted) { if (!mainCommitted && introStarted) {