diff --git a/src/audio/music-pulse.ts b/src/audio/music-pulse.ts new file mode 100644 index 0000000..5c56625 --- /dev/null +++ b/src/audio/music-pulse.ts @@ -0,0 +1,41 @@ +/** + * A single low-band level shared between the soundtrack and the scene. + * The scene samples it once inside its existing render loop; nothing here + * schedules work of its own. When no soundtrack is audible it reads zero. + */ +let analyser: AnalyserNode | null = null; +let bins: Uint8Array | null = null; +let audible = false; + +export function attachMusicPulse(node: AnalyserNode | null): void { + analyser = node; + bins = node === null ? null : new Uint8Array(new ArrayBuffer(node.frequencyBinCount)); +} + +export function setMusicPulseAudible(value: boolean): void { + audible = value; +} + +/** Bass energy in [0, 1]: the mean of roughly 90-380 Hz. */ +export function readMusicPulse(): number { + if (!audible || analyser === null || bins === null) return 0; + analyser.getByteFrequencyData(bins); + const sum = (bins[1] ?? 0) + (bins[2] ?? 0) + (bins[3] ?? 0) + (bins[4] ?? 0); + return sum / (4 * 255); +} + +/** Insert a pass-through tap in front of `destination` and analyse it. */ +export function createPulseTap(context: BaseAudioContext, destination: AudioNode): AudioNode { + const tap = context.createGain(); + tap.connect(destination); + try { + const node = context.createAnalyser(); + node.fftSize = 512; + node.smoothingTimeConstant = 0.55; + tap.connect(node); + attachMusicPulse(node); + } catch { + attachMusicPulse(null); + } + return tap; +} diff --git a/src/audio/soundtrack-engine.ts b/src/audio/soundtrack-engine.ts index 594f7dd..b129d5a 100644 --- a/src/audio/soundtrack-engine.ts +++ b/src/audio/soundtrack-engine.ts @@ -1,3 +1,4 @@ +import { attachMusicPulse, createPulseTap } from './music-pulse'; import { AFTERGLOW_LOOP_END_SECONDS, AFTERGLOW_LOOP_START_SECONDS, @@ -596,12 +597,17 @@ export function createBrowserSoundtrackEngine( sampleRate: SOUNDTRACK_SAMPLE_RATE, }), loadBuffer: loadAudioBuffer, - createMixer: createAdaptiveMixer, + // The mixer feeds a pass-through tap so the scene can read the bass level. + createMixer: (context, destination) => createAdaptiveMixer(context, createPulseTap(context, destination)), createDriveTexture, }); let resumeCount = 0; return { ...graph, + async close() { + attachMusicPulse(null); + await graph.close(); + }, async resume() { resumeCount += 1; if (isTestFault('F11-audio-resume-rejection') && resumeCount > 1) { diff --git a/src/composables/useAudioEngine.ts b/src/composables/useAudioEngine.ts index f6abce3..b80481f 100644 --- a/src/composables/useAudioEngine.ts +++ b/src/composables/useAudioEngine.ts @@ -8,6 +8,7 @@ import { } from 'vue'; import { createAudioController } from '@/audio/create-audio-controller'; import { createBrowserSoundtrackEngine } from '@/audio/soundtrack-engine'; +import { setMusicPulseAudible } from '@/audio/music-pulse'; import type { AudioController, AudioUiState, @@ -65,6 +66,10 @@ export function useAudioEngine(sources: AudioEngineSources): AudioEngineBinding watch(sources.chapter, (intent) => controller?.setChapterIntent(intent), { flush: 'sync' }); watch(sources.drive, (sample) => controller?.setDriveEnergy(sample), { flush: 'sync' }); watch(sources.volume, (value) => controller?.setVolume(value)); + // The world only answers the music the visitor can actually hear. + watch(state, (next) => { + setMusicPulseAudible(next.kind === 'running' && !next.muted && next.volume > 0); + }); onMounted(() => { ensureController(); diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index c4febf0..6488086 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -1,5 +1,6 @@ import * as THREE from 'three'; import type { JourneySnapshot } from '@/journey/types'; +import { readMusicPulse } from '@/audio/music-pulse'; import type { SceneResourceBag } from '@/scene/resources'; import type { SceneBuildContext, SceneGeneration } from '@/scene/types'; import { renderQualityFor } from '@/scene/render-quality'; @@ -165,6 +166,7 @@ export async function createProceduralSceneGeneration( let signCameraWeight = 0; let overdrive = 0; let terrainSurge = 0; + let musicPulse = 0; let terrainSpectrumPhase = 0; let firstFrameSuppressionRecorded = false; @@ -264,10 +266,17 @@ export async function createProceduralSceneGeneration( const surgeTarget = Math.sqrt( THREE.MathUtils.clamp(snapshot.acceleration, 0, 1), ); + // Soundtrack coupling: the bass the visitor hears lifts the terrain and + // the sun. One analyser read per frame; zero when nothing is audible. + const pulseTarget = readMusicPulse(); + musicPulse += + (pulseTarget - musicPulse) * + (1 - Math.exp(-deltaSeconds / (pulseTarget > musicPulse ? 0.05 : 0.32))); + const beat = THREE.MathUtils.smoothstep(musicPulse, 0.42, 0.92); terrainSurge += - (surgeTarget - terrainSurge) * + (Math.max(surgeTarget, beat * 0.55) - terrainSurge) * (1 - - Math.exp(-deltaSeconds / (surgeTarget > terrainSurge ? 0.11 : 1.15))); + Math.exp(-deltaSeconds / (Math.max(surgeTarget, beat * 0.55) > terrainSurge ? 0.11 : 1.15))); // Integrate color travel instead of multiplying elapsed time by speed: // lifting off slows the spectrum to rest without rewinding its phase. terrainSpectrumPhase = @@ -314,7 +323,7 @@ export async function createProceduralSceneGeneration( currentPalette, snapshot.routeProgress, elapsedSeconds, - response.bloomEnergy, + Math.min(1, response.bloomEnergy + beat * 0.35), snapshot.arrived, ); car.update( diff --git a/tests/unit/music-pulse.test.ts b/tests/unit/music-pulse.test.ts new file mode 100644 index 0000000..517a3f5 --- /dev/null +++ b/tests/unit/music-pulse.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { attachMusicPulse, readMusicPulse, setMusicPulseAudible } from '@/audio/music-pulse'; + +const fakeAnalyser = (level: number) => ({ + frequencyBinCount: 8, + getByteFrequencyData(target: Uint8Array) { target.fill(level); }, +}) as unknown as AnalyserNode; + +afterEach(() => { + attachMusicPulse(null); + setMusicPulseAudible(false); +}); + +describe('music pulse', () => { + it('reads zero without an analyser', () => { + setMusicPulseAudible(true); + expect(readMusicPulse()).toBe(0); + }); + it('reads zero while the soundtrack is not audible', () => { + attachMusicPulse(fakeAnalyser(255)); + expect(readMusicPulse()).toBe(0); + }); + it('normalizes the low-band level when audible', () => { + attachMusicPulse(fakeAnalyser(51)); + setMusicPulseAudible(true); + expect(readMusicPulse()).toBeCloseTo(0.2); + }); +});