From 130ff2d8bb4bbfdc6def79d6f80d9c0ef0a931ec Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 05:22:13 +0300 Subject: [PATCH] feat: add opt-in procedural drive audio --- src/audio/create-audio-controller.ts | 289 ++++++++++++++++++++ src/audio/synth-engine.ts | 144 ++++++++++ src/audio/types.ts | 44 +++ src/components/AppShell.vue | 13 + src/components/DriveControls.vue | 56 +++- src/composables/useAudioEngine.ts | 91 +++++++ src/styles/world.css | 37 ++- tests/browser/audio-controls.spec.ts | 95 +++++++ tests/unit/audio-lifecycle.test.ts | 389 +++++++++++++++++++++++++++ 9 files changed, 1155 insertions(+), 3 deletions(-) create mode 100644 src/audio/create-audio-controller.ts create mode 100644 src/audio/synth-engine.ts create mode 100644 src/audio/types.ts create mode 100644 src/composables/useAudioEngine.ts create mode 100644 tests/browser/audio-controls.spec.ts create mode 100644 tests/unit/audio-lifecycle.test.ts diff --git a/src/audio/create-audio-controller.ts b/src/audio/create-audio-controller.ts new file mode 100644 index 0000000..98d8339 --- /dev/null +++ b/src/audio/create-audio-controller.ts @@ -0,0 +1,289 @@ +import type { + AudioController, + AudioControllerOptions, + AudioGraph, + AudioUiState, +} from '@/audio/types'; + +type ActualState = AudioUiState['kind']; +type DesiredState = 'idle' | 'running' | 'suspended' | 'closed'; +type ResumeIntent = 'none' | 'automatic-after-visibility' | 'user-required'; + +const DEFAULT_VOLUME = 0.4; +const INTENSITY_SMOOTHING = 0.12; + +export function createAudioController(options: AudioControllerOptions): AudioController { + let actual: ActualState = 'idle'; + let desired: DesiredState = 'idle'; + let resumeIntent: ResumeIntent = 'none'; + let explicitlyActivated = false; + let visible = options.initiallyVisible ?? true; + let muted = false; + let volume = clamp(options.initialVolume ?? DEFAULT_VOLUME); + let intensity = 0; + let graph: AudioGraph | null = null; + let graphClosed = false; + let removeStateListener = (): void => {}; + let operationGeneration = 0; + let startOperation: Promise | null = null; + let operationTail = Promise.resolve(); + const listeners = new Set<(state: AudioUiState) => void>(); + + const snapshot = (): AudioUiState => { + switch (actual) { + case 'idle': return { kind: 'idle' }; + case 'starting': return { kind: 'starting' }; + case 'running': return { kind: 'running', muted, volume }; + case 'suspended': return { kind: 'suspended', resumeRequiresGesture: resumeIntent === 'user-required' }; + case 'failed': return { kind: 'failed', message: 'Audio unavailable' }; + case 'disposed': return { kind: 'disposed' }; + } + }; + + const publish = (): void => { + const state = snapshot(); + for (const listener of listeners) listener(state); + }; + + const setActual = (next: ActualState): void => { + actual = next; + publish(); + }; + + const lifecycleInvalidated = (generation: number): boolean => ( + actual === 'disposed' || desired === 'closed' || generation !== operationGeneration + ); + + const silence = (): void => { + if (!graph) return; + graph.setOutputGain(0); + graph.disconnectOutput(); + }; + + const closeGraph = async (): Promise => { + if (!graph || graphClosed) return; + graphClosed = true; + removeStateListener(); + removeStateListener = (): void => {}; + try { + await graph.close(); + } catch { + // Closing is best effort after synchronous silence and disconnection. + } + }; + + const fail = async (): Promise => { + if (actual === 'disposed') { + await closeGraph(); + return; + } + desired = 'suspended'; + resumeIntent = 'user-required'; + silence(); + await closeGraph(); + setActual('failed'); + }; + + const enqueue = (operation: () => Promise): Promise => { + const result = operationTail.then(operation, operation); + operationTail = result.catch(() => undefined); + return result; + }; + + const attachRunning = (): void => { + if (!graph || graphClosed) return; + graph.connectOutput(); + graph.setOutputGain(muted ? 0 : volume); + graph.setIntensity(intensity); + resumeIntent = 'none'; + setActual('running'); + }; + + const settleAfterResume = async (generation: number, firstStart: boolean): Promise => { + if (!graph) return; + try { + await graph.resume(); + } catch { + if (firstStart) await fail(); + else { + desired = 'suspended'; + resumeIntent = 'user-required'; + silence(); + setActual('suspended'); + } + return; + } + + if (lifecycleInvalidated(generation)) { + await closeGraph(); + return; + } + + if (desired === 'running' && visible) { + attachRunning(); + return; + } + + silence(); + try { + await graph.suspend(); + } catch { + await fail(); + return; + } + if (lifecycleInvalidated(generation)) { + await closeGraph(); + return; + } + setActual('suspended'); + }; + + const startFromGesture = (): Promise => { + if (actual === 'disposed' || actual === 'failed') return Promise.resolve(); + if (explicitlyActivated) return startOperation ?? operationTail; + + explicitlyActivated = true; + desired = 'running'; + resumeIntent = 'none'; + setActual('starting'); + const generation = ++operationGeneration; + + try { + // This factory call intentionally remains synchronous in the activating event stack. + graph = options.createGraph(); + removeStateListener = graph.onContextStateChange((state) => { + if (state === 'interrupted') void noteInterruption(); + }); + graph.setOutputGain(0); + graph.setIntensity(intensity); + } catch { + startOperation = enqueue(fail); + return startOperation; + } + + startOperation = enqueue(() => settleAfterResume(generation, true)); + return startOperation; + }; + + const resumeFromGesture = (): Promise => { + if (actual === 'idle') return startFromGesture(); + if (actual !== 'suspended' || !graph || graphClosed) return Promise.resolve(); + desired = 'running'; + resumeIntent = 'none'; + const generation = operationGeneration; + return enqueue(() => settleAfterResume(generation, false)); + }; + + const suspend = (intent: ResumeIntent): Promise => { + if (actual === 'idle' || actual === 'failed' || actual === 'disposed') return Promise.resolve(); + desired = 'suspended'; + resumeIntent = intent; + + if (actual === 'starting') return startOperation ?? Promise.resolve(); + if (actual === 'suspended') { + publish(); + return Promise.resolve(); + } + + silence(); + const generation = operationGeneration; + return enqueue(async () => { + if (!graph || graphClosed || lifecycleInvalidated(generation)) return; + try { + await graph.suspend(); + } catch { + await fail(); + return; + } + if (lifecycleInvalidated(generation)) { + await closeGraph(); + return; + } + if (desired === 'running' && visible) { + await settleAfterResume(generation, false); + return; + } + setActual('suspended'); + }); + }; + + const setVisible = (nextVisible: boolean): Promise => { + visible = nextVisible; + if (actual === 'idle' || actual === 'failed' || actual === 'disposed') return Promise.resolve(); + if (!visible) { + const intent = resumeIntent === 'user-required' ? 'user-required' : 'automatic-after-visibility'; + return suspend(intent); + } + if (resumeIntent === 'automatic-after-visibility') { + desired = 'running'; + resumeIntent = 'none'; + if (actual === 'starting') return startOperation ?? Promise.resolve(); + if (actual === 'running') return operationTail; + if (actual === 'suspended') { + const generation = operationGeneration; + return enqueue(() => settleAfterResume(generation, false)); + } + } + return Promise.resolve(); + }; + + const noteInterruption = (): Promise => suspend('user-required'); + + const pageHide = (persisted: boolean): Promise => { + if (persisted) return setVisible(false); + dispose(); + return operationTail; + }; + + const pageShow = (_persisted: boolean): Promise => setVisible(true); + + const dispose = (): void => { + if (actual === 'disposed') return; + desired = 'closed'; + visible = false; + operationGeneration += 1; + silence(); + setActual('disposed'); + void enqueue(closeGraph); + }; + + return { + startFromGesture, + resumeFromGesture, + setMuted(nextMuted) { + if (actual === 'disposed') return; + muted = nextMuted; + graph?.setOutputGain(muted ? 0 : volume); + if (actual === 'running') publish(); + }, + setVolume(nextVolume) { + if (actual === 'disposed') return; + volume = clamp(nextVolume); + graph?.setOutputGain(muted ? 0 : volume); + if (actual === 'running') publish(); + }, + setIntensity(nextIntensity) { + if (actual === 'disposed') return; + const target = clamp(nextIntensity); + intensity += (target - intensity) * INTENSITY_SMOOTHING; + graph?.setIntensity(intensity); + }, + setVisible, + noteInterruption, + pageHide, + pageShow, + dispose, + whenSettled: () => operationTail, + snapshot, + subscribe(listener) { + listeners.add(listener); + listener(snapshot()); + return () => listeners.delete(listener); + }, + }; +} + +function clamp(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} diff --git a/src/audio/synth-engine.ts b/src/audio/synth-engine.ts new file mode 100644 index 0000000..ccbaa20 --- /dev/null +++ b/src/audio/synth-engine.ts @@ -0,0 +1,144 @@ +import type { AudioContextLifecycleState, AudioGraph } from '@/audio/types'; + +type AudioContextFactory = () => AudioContext; + +export function createBrowserSynthEngine(): AudioGraph { + const AudioContextConstructor = window.AudioContext + ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AudioContextConstructor) throw new Error('Web Audio is unavailable'); + return createSynthEngine(() => new AudioContextConstructor()); +} + +export function createSynthEngine(createContext: AudioContextFactory): AudioGraph { + const context = createContext(); + const master = context.createGain(); + const dynamics = context.createDynamicsCompressor(); + master.gain.setValueAtTime(0, context.currentTime); + master.connect(dynamics); + + const engineFilter = context.createBiquadFilter(); + engineFilter.type = 'lowpass'; + engineFilter.frequency.setValueAtTime(260, context.currentTime); + engineFilter.Q.setValueAtTime(1.25, context.currentTime); + + const engineGain = context.createGain(); + engineGain.gain.setValueAtTime(0.045, context.currentTime); + engineFilter.connect(engineGain); + engineGain.connect(master); + + const fundamental = createOscillator(context, 'sawtooth', 42, 0.16, engineFilter); + const harmonic = createOscillator(context, 'sine', 84, 0.055, engineFilter); + + const roadFilter = context.createBiquadFilter(); + roadFilter.type = 'bandpass'; + roadFilter.frequency.setValueAtTime(410, context.currentTime); + roadFilter.Q.setValueAtTime(0.55, context.currentTime); + const roadGain = context.createGain(); + roadGain.gain.setValueAtTime(0.015, context.currentTime); + roadFilter.connect(roadGain); + roadGain.connect(master); + + const noise = context.createBufferSource(); + noise.buffer = createNoiseBuffer(context, 2); + noise.loop = true; + noise.connect(roadFilter); + + const ignition = context.createOscillator(); + ignition.type = 'triangle'; + ignition.frequency.setValueAtTime(68, context.currentTime); + const ignitionGain = context.createGain(); + ignitionGain.gain.setValueAtTime(0.0001, context.currentTime); + ignitionGain.gain.exponentialRampToValueAtTime(0.07, context.currentTime + 0.12); + ignitionGain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 1.15); + ignition.connect(ignitionGain); + ignitionGain.connect(master); + + const scheduledNodes: Array = [fundamental.source, harmonic.source, noise, ignition]; + for (const node of scheduledNodes) node.start(); + + let outputConnected = false; + let closed = false; + + return { + resume: () => context.resume(), + suspend: () => context.suspend(), + async close() { + if (closed) return; + closed = true; + for (const node of scheduledNodes) { + try { node.stop(); } catch { /* A source may already have ended. */ } + } + if (outputConnected) { + dynamics.disconnect(context.destination); + outputConnected = false; + } + master.disconnect(); + await context.close(); + }, + connectOutput() { + if (closed || outputConnected) return; + dynamics.connect(context.destination); + outputConnected = true; + }, + disconnectOutput() { + if (!outputConnected) return; + dynamics.disconnect(context.destination); + outputConnected = false; + }, + setOutputGain(value) { + const next = clamp(value); + master.gain.cancelScheduledValues(context.currentTime); + master.gain.setValueAtTime(next, context.currentTime); + }, + setIntensity(value) { + const next = clamp(value); + const time = context.currentTime; + fundamental.source.frequency.setTargetAtTime(42 + next * 28, time, 0.32); + harmonic.source.frequency.setTargetAtTime(84 + next * 56, time, 0.32); + engineFilter.frequency.setTargetAtTime(260 + next * 670, time, 0.38); + roadFilter.frequency.setTargetAtTime(410 + next * 520, time, 0.42); + engineGain.gain.setTargetAtTime(0.045 + next * 0.035, time, 0.4); + roadGain.gain.setTargetAtTime(0.015 + next * 0.03, time, 0.45); + }, + onContextStateChange(listener) { + const onStateChange = (): void => listener(context.state as AudioContextLifecycleState); + context.addEventListener('statechange', onStateChange); + return () => context.removeEventListener('statechange', onStateChange); + }, + }; +} + +function createOscillator( + context: AudioContext, + type: OscillatorType, + frequency: number, + level: number, + destination: AudioNode, +): { source: OscillatorNode; gain: GainNode } { + const source = context.createOscillator(); + source.type = type; + source.frequency.setValueAtTime(frequency, context.currentTime); + const gain = context.createGain(); + gain.gain.setValueAtTime(level, context.currentTime); + source.connect(gain); + gain.connect(destination); + return { source, gain }; +} + +function createNoiseBuffer(context: AudioContext, durationSeconds: number): AudioBuffer { + const length = Math.max(1, Math.floor(context.sampleRate * durationSeconds)); + const buffer = context.createBuffer(1, length, context.sampleRate); + const samples = buffer.getChannelData(0); + let previous = 0; + for (let index = 0; index < samples.length; index += 1) { + const white = Math.random() * 2 - 1; + previous = previous * 0.93 + white * 0.07; + samples[index] = previous; + } + return buffer; +} + +function clamp(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} diff --git a/src/audio/types.ts b/src/audio/types.ts new file mode 100644 index 0000000..41bb1be --- /dev/null +++ b/src/audio/types.ts @@ -0,0 +1,44 @@ +export type AudioUiState = + | { kind: 'idle' } + | { kind: 'starting' } + | { kind: 'running'; muted: boolean; volume: number } + | { kind: 'suspended'; resumeRequiresGesture: boolean } + | { kind: 'failed'; message: string } + | { kind: 'disposed' }; + +export type AudioContextLifecycleState = AudioContextState | 'interrupted'; + +export interface AudioGraph { + resume(): Promise; + suspend(): Promise; + close(): Promise; + connectOutput(): void; + disconnectOutput(): void; + setOutputGain(value: number): void; + setIntensity(value: number): void; + onContextStateChange(listener: (state: AudioContextLifecycleState) => void): () => void; +} + +export type AudioGraphFactory = () => AudioGraph; + +export interface AudioControllerOptions { + createGraph: AudioGraphFactory; + initialVolume?: number; + initiallyVisible?: boolean; +} + +export interface AudioController { + startFromGesture(): Promise; + resumeFromGesture(): Promise; + setMuted(muted: boolean): void; + setVolume(volume: number): void; + setIntensity(intensity: number): void; + setVisible(visible: boolean): Promise; + noteInterruption(): Promise; + pageHide(persisted: boolean): Promise; + pageShow(persisted: boolean): Promise; + dispose(): void; + whenSettled(): Promise; + snapshot(): AudioUiState; + subscribe(listener: (state: AudioUiState) => void): () => void; +} diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index 381d65b..f2ccd6a 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -12,6 +12,7 @@ import SceneCanvas from './SceneCanvas.vue'; import SiteNavigation from './SiteNavigation.vue'; import ToolkitGrid from './ToolkitGrid.vue'; import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality'; +import { useAudioEngine } from '@/composables/useAudioEngine'; import { useJourneySections } from '@/composables/useJourneySections'; import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation'; import { useMotionPreferences } from '@/composables/useMotionPreferences'; @@ -43,6 +44,11 @@ const journey = useJourneySections( navigation.state, computed(() => motion.policy.value.allowSceneRequest), ); +const audio = useAudioEngine({ + intensity: computed(() => journey.value.acceleration), + volume: motion.volume, + setVolume: motion.setVolume, +}); const sceneState = shallowRef('idle'); const pageLifecycle = shallowRef<'active' | 'frozen' | 'disposed'>('active'); const controlsReady = shallowRef(false); @@ -175,9 +181,16 @@ onUnmounted(() => removePageListeners()); :policy="displayedPolicy" :maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'" :scene-status="displayedSceneState" + :audio-state="audio.state.value" + :audio-muted="audio.muted.value" + :audio-volume="controlsReady ? motion.volume.value : 0.4" @pause="motion.setPaused(true)" @resume="motion.setPaused(false)" @enable="enableMotion" @quality-change="setQuality" + @audio-start="audio.startFromGesture" + @audio-resume="audio.resumeFromGesture" + @audio-mute-toggle="audio.toggleMuted" + @audio-volume-change="audio.setVolume" /> diff --git a/src/components/DriveControls.vue b/src/components/DriveControls.vue index 2cfc5d8..a51f091 100644 --- a/src/components/DriveControls.vue +++ b/src/components/DriveControls.vue @@ -1,18 +1,30 @@