feat: couple terrain surge and sun bloom to the soundtrack's low end
This commit is contained in:
41
src/audio/music-pulse.ts
Normal file
41
src/audio/music-pulse.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
28
tests/unit/music-pulse.test.ts
Normal file
28
tests/unit/music-pulse.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user