feat: couple terrain surge and sun bloom to the soundtrack's low end

This commit is contained in:
2026-10-10 17:46:17 +03:00
parent 416b930523
commit 0821b542b5
5 changed files with 93 additions and 4 deletions

41
src/audio/music-pulse.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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