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 {
|
import {
|
||||||
AFTERGLOW_LOOP_END_SECONDS,
|
AFTERGLOW_LOOP_END_SECONDS,
|
||||||
AFTERGLOW_LOOP_START_SECONDS,
|
AFTERGLOW_LOOP_START_SECONDS,
|
||||||
@@ -596,12 +597,17 @@ export function createBrowserSoundtrackEngine(
|
|||||||
sampleRate: SOUNDTRACK_SAMPLE_RATE,
|
sampleRate: SOUNDTRACK_SAMPLE_RATE,
|
||||||
}),
|
}),
|
||||||
loadBuffer: loadAudioBuffer,
|
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,
|
createDriveTexture,
|
||||||
});
|
});
|
||||||
let resumeCount = 0;
|
let resumeCount = 0;
|
||||||
return {
|
return {
|
||||||
...graph,
|
...graph,
|
||||||
|
async close() {
|
||||||
|
attachMusicPulse(null);
|
||||||
|
await graph.close();
|
||||||
|
},
|
||||||
async resume() {
|
async resume() {
|
||||||
resumeCount += 1;
|
resumeCount += 1;
|
||||||
if (isTestFault('F11-audio-resume-rejection') && resumeCount > 1) {
|
if (isTestFault('F11-audio-resume-rejection') && resumeCount > 1) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { createAudioController } from '@/audio/create-audio-controller';
|
import { createAudioController } from '@/audio/create-audio-controller';
|
||||||
import { createBrowserSoundtrackEngine } from '@/audio/soundtrack-engine';
|
import { createBrowserSoundtrackEngine } from '@/audio/soundtrack-engine';
|
||||||
|
import { setMusicPulseAudible } from '@/audio/music-pulse';
|
||||||
import type {
|
import type {
|
||||||
AudioController,
|
AudioController,
|
||||||
AudioUiState,
|
AudioUiState,
|
||||||
@@ -65,6 +66,10 @@ export function useAudioEngine(sources: AudioEngineSources): AudioEngineBinding
|
|||||||
watch(sources.chapter, (intent) => controller?.setChapterIntent(intent), { flush: 'sync' });
|
watch(sources.chapter, (intent) => controller?.setChapterIntent(intent), { flush: 'sync' });
|
||||||
watch(sources.drive, (sample) => controller?.setDriveEnergy(sample), { flush: 'sync' });
|
watch(sources.drive, (sample) => controller?.setDriveEnergy(sample), { flush: 'sync' });
|
||||||
watch(sources.volume, (value) => controller?.setVolume(value));
|
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(() => {
|
onMounted(() => {
|
||||||
ensureController();
|
ensureController();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { JourneySnapshot } from '@/journey/types';
|
import type { JourneySnapshot } from '@/journey/types';
|
||||||
|
import { readMusicPulse } from '@/audio/music-pulse';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
|
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
|
||||||
import { renderQualityFor } from '@/scene/render-quality';
|
import { renderQualityFor } from '@/scene/render-quality';
|
||||||
@@ -165,6 +166,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
let signCameraWeight = 0;
|
let signCameraWeight = 0;
|
||||||
let overdrive = 0;
|
let overdrive = 0;
|
||||||
let terrainSurge = 0;
|
let terrainSurge = 0;
|
||||||
|
let musicPulse = 0;
|
||||||
let terrainSpectrumPhase = 0;
|
let terrainSpectrumPhase = 0;
|
||||||
let firstFrameSuppressionRecorded = false;
|
let firstFrameSuppressionRecorded = false;
|
||||||
|
|
||||||
@@ -264,10 +266,17 @@ export async function createProceduralSceneGeneration(
|
|||||||
const surgeTarget = Math.sqrt(
|
const surgeTarget = Math.sqrt(
|
||||||
THREE.MathUtils.clamp(snapshot.acceleration, 0, 1),
|
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 +=
|
terrainSurge +=
|
||||||
(surgeTarget - terrainSurge) *
|
(Math.max(surgeTarget, beat * 0.55) - terrainSurge) *
|
||||||
(1 -
|
(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:
|
// Integrate color travel instead of multiplying elapsed time by speed:
|
||||||
// lifting off slows the spectrum to rest without rewinding its phase.
|
// lifting off slows the spectrum to rest without rewinding its phase.
|
||||||
terrainSpectrumPhase =
|
terrainSpectrumPhase =
|
||||||
@@ -314,7 +323,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
currentPalette,
|
currentPalette,
|
||||||
snapshot.routeProgress,
|
snapshot.routeProgress,
|
||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
response.bloomEnergy,
|
Math.min(1, response.bloomEnergy + beat * 0.35),
|
||||||
snapshot.arrived,
|
snapshot.arrived,
|
||||||
);
|
);
|
||||||
car.update(
|
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