feat: add opt-in procedural drive audio

This commit is contained in:
2026-09-02 05:22:13 +03:00
parent 190ac1ce62
commit 130ff2d8bb
9 changed files with 1155 additions and 3 deletions

View File

@@ -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<void> | 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<void> => {
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<void> => {
if (actual === 'disposed') {
await closeGraph();
return;
}
desired = 'suspended';
resumeIntent = 'user-required';
silence();
await closeGraph();
setActual('failed');
};
const enqueue = (operation: () => Promise<void>): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => suspend('user-required');
const pageHide = (persisted: boolean): Promise<void> => {
if (persisted) return setVisible(false);
dispose();
return operationTail;
};
const pageShow = (_persisted: boolean): Promise<void> => 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));
}

144
src/audio/synth-engine.ts Normal file
View File

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

44
src/audio/types.ts Normal file
View File

@@ -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<void>;
suspend(): Promise<void>;
close(): Promise<void>;
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<void>;
resumeFromGesture(): Promise<void>;
setMuted(muted: boolean): void;
setVolume(volume: number): void;
setIntensity(intensity: number): void;
setVisible(visible: boolean): Promise<void>;
noteInterruption(): Promise<void>;
pageHide(persisted: boolean): Promise<void>;
pageShow(persisted: boolean): Promise<void>;
dispose(): void;
whenSettled(): Promise<void>;
snapshot(): AudioUiState;
subscribe(listener: (state: AudioUiState) => void): () => void;
}

View File

@@ -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<SceneHostState>('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"
/>
</template>

View File

@@ -1,18 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { AudioUiState } from '@/audio/types';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
const props = defineProps<{
const props = withDefaults(defineProps<{
policy: MotionPolicy;
maximumQuality: QualityTier;
sceneStatus: string;
}>();
audioState?: AudioUiState;
audioMuted?: boolean;
audioVolume?: number;
}>(), {
audioState: () => ({ kind: 'idle' }),
audioMuted: false,
audioVolume: 0.4,
});
const emit = defineEmits<{
pause: [];
resume: [];
enable: [];
qualityChange: [quality: QualityTier];
audioStart: [];
audioResume: [];
audioMuteToggle: [];
audioVolumeChange: [volume: number];
}>();
const action = computed(() => {
@@ -45,11 +57,51 @@ const changeQuality = (event: Event): void => {
emit('qualityChange', value);
}
};
const changeVolume = (event: Event): void => {
emit('audioVolumeChange', Number((event.currentTarget as HTMLInputElement).value));
};
</script>
<template>
<div class="drive-controls">
<button v-if="action" type="button" @click="runAction">{{ action.label }}</button>
<div v-if="audioState.kind === 'idle'" class="drive-controls__audio-start">
<button type="button" @click="emit('audioStart')">START THE DRIVE</button>
<span>Enables the interactive soundtrack.</span>
</div>
<div v-else-if="audioState.kind === 'starting'" class="drive-controls__audio-start" aria-live="polite">
<button type="button" disabled>Starting drive…</button>
<span>Preparing the interactive soundtrack.</span>
</div>
<p v-else-if="audioState.kind === 'failed'" class="drive-controls__audio-status" role="status">
Audio unavailable. Continue without sound.
</p>
<div
v-else-if="audioState.kind === 'running' || audioState.kind === 'suspended'"
class="drive-controls__audio"
>
<button
v-if="audioState.kind === 'suspended' && audioState.resumeRequiresGesture"
type="button"
@click="emit('audioResume')"
>Resume sound</button>
<button type="button" @click="emit('audioMuteToggle')">
{{ audioMuted ? 'Unmute sound' : 'Mute sound' }}
</button>
<label>
<span>Sound volume</span>
<input
aria-label="Sound volume"
type="range"
min="0"
max="1"
step="0.05"
:value="audioVolume"
@input="changeVolume"
>
</label>
</div>
<label>
<span>Quality</span>
<select :value="maximumQuality" aria-label="Quality control" @change="changeQuality">

View File

@@ -0,0 +1,91 @@
import {
onMounted,
onUnmounted,
readonly,
shallowRef,
watch,
type Ref,
} from 'vue';
import { createAudioController } from '@/audio/create-audio-controller';
import { createBrowserSynthEngine } from '@/audio/synth-engine';
import type { AudioController, AudioUiState } from '@/audio/types';
export interface AudioEngineSources {
intensity: Readonly<Ref<number>>;
volume: Readonly<Ref<number>>;
setVolume(value: number): void;
}
export interface AudioEngineBinding {
state: Readonly<Ref<AudioUiState>>;
muted: Readonly<Ref<boolean>>;
startFromGesture(): void;
resumeFromGesture(): void;
toggleMuted(): void;
setVolume(value: number): void;
}
export function useAudioEngine(sources: AudioEngineSources): AudioEngineBinding {
const state = shallowRef<AudioUiState>({ kind: 'idle' });
const muted = shallowRef(false);
let controller: AudioController | null = null;
let removeSubscription = (): void => {};
let removePageListeners = (): void => {};
const ensureController = (): AudioController => {
if (controller) return controller;
controller = createAudioController({
createGraph: createBrowserSynthEngine,
initialVolume: sources.volume.value,
initiallyVisible: document.visibilityState !== 'hidden',
});
removeSubscription = controller.subscribe((next) => { state.value = next; });
controller.setIntensity(sources.intensity.value);
return controller;
};
watch(sources.intensity, (value) => controller?.setIntensity(value));
watch(sources.volume, (value) => controller?.setVolume(value));
onMounted(() => {
ensureController();
const onVisibilityChange = (): void => {
void controller?.setVisible(document.visibilityState !== 'hidden');
};
const onPageHide = (event: PageTransitionEvent): void => { void controller?.pageHide(event.persisted); };
const onPageShow = (event: PageTransitionEvent): void => { void controller?.pageShow(event.persisted); };
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('pagehide', onPageHide);
window.addEventListener('pageshow', onPageShow);
removePageListeners = () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('pagehide', onPageHide);
window.removeEventListener('pageshow', onPageShow);
};
});
onUnmounted(() => {
removePageListeners();
removeSubscription();
controller?.dispose();
controller = null;
});
return {
state: readonly(state),
muted: readonly(muted),
startFromGesture() {
// Keep this call direct: the controller constructs AudioContext synchronously here.
void ensureController().startFromGesture();
},
resumeFromGesture() { void ensureController().resumeFromGesture(); },
toggleMuted() {
muted.value = !muted.value;
ensureController().setMuted(muted.value);
},
setVolume(value) {
sources.setVolume(value);
ensureController().setVolume(value);
},
};
}

View File

@@ -93,14 +93,33 @@
font-size: var(--step--1);
}
.drive-controls__audio-start,
.drive-controls__audio {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
}
.drive-controls__audio-start span,
.drive-controls__audio-status {
color: var(--text-muted);
}
.drive-controls button,
.drive-controls select {
.drive-controls select,
.drive-controls input {
min-block-size: 44px;
border: 1px solid var(--line-strong);
background: #101925;
color: var(--text);
}
.drive-controls input[type='range'] {
inline-size: 7rem;
accent-color: var(--cyan);
}
.drive-controls button {
padding-inline: 0.8rem;
}
@@ -120,6 +139,18 @@
}
@media (max-width: 767px) {
html {
scroll-padding-block-end: 11rem;
}
#contact {
padding-block-end: 11rem;
}
#contact a {
scroll-margin-block-end: 11rem;
}
.journey-hud {
inset: 0.5rem 0.5rem auto auto;
min-inline-size: 0;
@@ -138,6 +169,10 @@
.drive-controls__status {
flex-basis: 100%;
}
.drive-controls__audio-start span {
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {