refactor: split soundtrack chapter and drive commands

This commit is contained in:
2026-09-04 15:43:41 +03:00
parent 5250a3174c
commit b427d101fa
9 changed files with 170 additions and 136 deletions

View File

@@ -2,10 +2,11 @@ import type {
AudioController,
AudioControllerOptions,
AudioGraph,
AudioJourneyState,
ChapterIntent,
DriveEnergySample,
AudioUiState,
} from '@/audio/types';
import { DEFAULT_AUDIO_JOURNEY_STATE } from '@/audio/journey-music-state';
import { DEFAULT_AUDIO_INITIAL_STATE } from '@/audio/journey-music-state';
type ActualState = AudioUiState['kind'];
type DesiredState = 'idle' | 'running' | 'suspended' | 'closed';
@@ -21,7 +22,12 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
let visible = options.initiallyVisible ?? true;
let muted = false;
let volume = clamp(options.initialVolume ?? DEFAULT_VOLUME);
let journeyState = normalizeJourneyState(options.initialJourneyState ?? DEFAULT_AUDIO_JOURNEY_STATE);
let chapterIntent = normalizeChapterIntent(
options.initialState?.chapter ?? DEFAULT_AUDIO_INITIAL_STATE.chapter,
);
let driveEnergy = normalizeDriveEnergy(
options.initialState?.drive ?? DEFAULT_AUDIO_INITIAL_STATE.drive,
);
let graph: AudioGraph | null = null;
let graphClosed = false;
let removeStateListener = (): void => {};
@@ -96,7 +102,8 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
if (!graph || graphClosed) return;
graph.connectOutput();
graph.setOutputGain(muted ? 0 : volume);
graph.setJourneyState(journeyState);
graph.setChapterIntent(chapterIntent);
graph.setDriveEnergy(driveEnergy);
resumeIntent = 'none';
setActual('running');
};
@@ -152,12 +159,13 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
try {
// This factory call intentionally remains synchronous in the activating event stack.
graph = options.createGraph(journeyState);
graph = options.createGraph({ chapter: chapterIntent, drive: driveEnergy });
removeStateListener = graph.onContextStateChange((state) => {
if (state === 'interrupted') void noteInterruption();
});
graph.setOutputGain(0);
graph.setJourneyState(journeyState);
graph.setChapterIntent(chapterIntent);
graph.setDriveEnergy(driveEnergy);
} catch {
startOperation = enqueue(fail);
return startOperation;
@@ -265,10 +273,15 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
graph?.setOutputGain(muted ? 0 : volume);
if (actual === 'running') publish();
},
setJourneyState(nextState) {
setChapterIntent(nextIntent) {
if (actual === 'disposed') return;
journeyState = normalizeJourneyState(nextState);
graph?.setJourneyState(journeyState);
chapterIntent = normalizeChapterIntent(nextIntent);
graph?.setChapterIntent(chapterIntent);
},
setDriveEnergy(nextSample) {
if (actual === 'disposed') return;
driveEnergy = normalizeDriveEnergy(nextSample);
graph?.setDriveEnergy(driveEnergy);
},
setVisible,
noteInterruption,
@@ -290,12 +303,22 @@ function clamp(value: number): number {
return Math.min(1, Math.max(0, value));
}
function normalizeJourneyState(state: AudioJourneyState): AudioJourneyState {
const entryPhrase = Number.isFinite(state.entryPhrase) ? Math.trunc(state.entryPhrase) : 0;
function normalizeChapterIntent(intent: ChapterIntent): ChapterIntent {
const entryPhrase = Number.isFinite(intent.entryPhrase)
? Math.trunc(intent.entryPhrase)
: 0;
const generation = Number.isFinite(intent.generation)
? Math.trunc(intent.generation)
: 0;
return Object.freeze({
atStart: state.atStart === true,
bias: state.bias,
sectionId: intent.sectionId,
mood: intent.mood,
atStart: intent.atStart === true,
entryPhrase: Math.min(7, Math.max(0, entryPhrase)),
intensity: clamp(state.intensity),
generation: Math.max(0, generation),
});
}
function normalizeDriveEnergy(sample: DriveEnergySample): DriveEnergySample {
return Object.freeze({ energy: clamp(sample.energy), source: sample.source });
}

View File

@@ -1,6 +1,6 @@
import type { SectionId } from '@/content/types';
import type {
AudioJourneyState,
AudioInitialState,
ChapterIntent,
DriveEnergySample,
MusicMood,
@@ -40,18 +40,7 @@ export const DEFAULT_DRIVE_ENERGY = Object.freeze({
source: 'idle',
} as const satisfies DriveEnergySample);
export function resolveJourneyMusicState(sectionId: string, intensity = 0): AudioJourneyState {
const canonicalId = Object.hasOwn(JOURNEY_MUSIC, sectionId)
? sectionId as SectionId
: 'start';
const chapter = resolveChapterIntent(canonicalId);
const finiteIntensity = Number.isFinite(intensity) ? intensity : 0;
return Object.freeze({
atStart: sectionId === 'start',
intensity: Math.min(1, Math.max(0, finiteIntensity)),
bias: chapter.mood,
entryPhrase: chapter.entryPhrase,
});
}
export const DEFAULT_AUDIO_JOURNEY_STATE = resolveJourneyMusicState('start');
export const DEFAULT_AUDIO_INITIAL_STATE = Object.freeze({
chapter: DEFAULT_CHAPTER_INTENT,
drive: DEFAULT_DRIVE_ENERGY,
} as const satisfies AudioInitialState);

View File

@@ -13,8 +13,10 @@ import {
import type {
AudioContextLifecycleState,
AudioGraph,
AudioJourneyState,
MusicChapterBias,
AudioInitialState,
ChapterIntent,
DriveEnergySample,
MusicMood,
SoundtrackSources,
} from '@/audio/types';
@@ -35,7 +37,7 @@ interface MixProfile {
readonly highShelfDb: number;
}
const MIX_PROFILES: Readonly<Record<MusicChapterBias, MixProfile>> = Object.freeze({
const MIX_PROFILES: Readonly<Record<MusicMood, MixProfile>> = Object.freeze({
open: { gain: 0.985, highShelfDb: -0.35 },
shadow: { gain: 0.97, highShelfDb: -0.9 },
lift: { gain: 1, highShelfDb: 0.35 },
@@ -44,7 +46,7 @@ const MIX_PROFILES: Readonly<Record<MusicChapterBias, MixProfile>> = Object.free
export function createBrowserSoundtrackEngine(
sources: SoundtrackSources,
initialState: AudioJourneyState,
initialState: AudioInitialState,
): AudioGraph {
const BrowserAudioContext = window.AudioContext;
if (BrowserAudioContext === undefined) throw new Error('Web Audio is unavailable.');
@@ -58,10 +60,10 @@ export function createBrowserSoundtrackEngine(
const outputGain = context.createGain();
const sourceNodes = new Set<AudioBufferSourceNode>();
const stateListeners = new Set<(state: AudioContextLifecycleState) => void>();
let latestState = initialState;
let latestChapter = initialState.chapter;
let connected = false;
let closed = false;
const activatedFromStart = initialState.atStart;
const activatedFromStart = initialState.chapter.atStart;
let transportStarted = false;
let introStarted = false;
let mainCommitted = false;
@@ -106,11 +108,10 @@ export function createBrowserSoundtrackEngine(
parameter.linearRampToValueAtTime(value, now + duration);
};
const applyMix = (state: AudioJourneyState): void => {
const profile = MIX_PROFILES[state.bias];
const intensity = clamp(state.intensity);
ramp(profileGain.gain, profile.gain + intensity * 0.012, PROFILE_RAMP_SECONDS);
ramp(tone.gain, profile.highShelfDb + intensity * 0.25, PROFILE_RAMP_SECONDS);
const applyMix = (chapter: ChapterIntent): void => {
const profile = MIX_PROFILES[chapter.mood];
ramp(profileGain.gain, profile.gain, PROFILE_RAMP_SECONDS);
ramp(tone.gain, profile.highShelfDb, PROFILE_RAMP_SECONDS);
};
const startMain = (
@@ -207,7 +208,7 @@ export function createBrowserSoundtrackEngine(
if (activatedFromStart) {
loadedIntroBuffer = await loadAudioBuffer(context, sources.intro, signal);
if (closed || signal.aborted) throw abortError();
if (latestState.atStart) {
if (latestChapter.atStart) {
transportStarted = true;
startIntro(loadedIntroBuffer, context.currentTime + SOURCE_LEAD_SECONDS);
return;
@@ -216,7 +217,7 @@ export function createBrowserSoundtrackEngine(
const mainBuffer = await mainBufferPromise;
if (closed || signal.aborted) throw abortError();
if (activatedFromStart && latestState.atStart && loadedIntroBuffer !== null) {
if (activatedFromStart && latestChapter.atStart && loadedIntroBuffer !== null) {
transportStarted = true;
startIntro(loadedIntroBuffer, context.currentTime + SOURCE_LEAD_SECONDS);
return;
@@ -225,7 +226,7 @@ export function createBrowserSoundtrackEngine(
startMain(
mainBuffer,
context.currentTime + SOURCE_LEAD_SECONDS,
mainPhraseOffsetSeconds(latestState.entryPhrase),
mainPhraseOffsetSeconds(latestChapter.entryPhrase),
START_FADE_SECONDS,
);
};
@@ -234,24 +235,30 @@ export function createBrowserSoundtrackEngine(
if (!introStarted || mainCommitted || exitTimer !== undefined || closed) return;
exitTimer = window.setTimeout(() => {
exitTimer = undefined;
if (closed || latestState.atStart || mainCommitted) return;
if (closed || latestChapter.atStart || mainCommitted) return;
const introBuffer = loadedIntroBuffer;
const pendingMain = mainBufferPromise;
if (introBuffer === null || pendingMain === null) return;
void pendingMain.then((mainBuffer) => {
if (!closed && !latestState.atStart && !mainCommitted) {
if (!closed && !latestChapter.atStart && !mainCommitted) {
scheduleIntroExit(introBuffer, mainBuffer);
}
}).catch(() => undefined);
}, EXIT_COALESCE_MS);
};
const cancelUnscheduledIntroExit = (): void => {
if (exitTimer === undefined) return;
window.clearTimeout(exitTimer);
exitTimer = undefined;
};
const onStateChange = (): void => {
const state = context.state as AudioContextLifecycleState;
for (const listener of stateListeners) listener(state);
};
context.addEventListener('statechange', onStateChange);
applyMix(initialState);
applyMix(initialState.chapter);
return {
async resume() {
@@ -291,15 +298,15 @@ export function createBrowserSoundtrackEngine(
setOutputGain(value) {
ramp(outputGain.gain, clamp(value), 0.08);
},
setJourneyState(state) {
setChapterIntent(intent) {
if (closed) return;
latestChapter = intent;
applyMix(intent);
if (!intent.atStart) requestIntroExit();
else cancelUnscheduledIntroExit();
},
setDriveEnergy(_sample: DriveEnergySample) {
if (closed) return;
latestState = state;
applyMix(state);
if (!state.atStart) requestIntroExit();
else if (exitTimer !== undefined) {
window.clearTimeout(exitTimer);
exitTimer = undefined;
}
},
cancelPending() {
loadController?.abort();

View File

@@ -1,4 +1,4 @@
import type { AudioJourneyState } from '@/audio/types';
import type { ChapterIntent } from '@/audio/types';
export const SOUNDTRACK_SAMPLE_RATE = 48_000;
export const MUSIC_BPM = 86;
@@ -25,7 +25,7 @@ export type InitialPlayback =
| { readonly kind: 'intro' }
| { readonly kind: 'main'; readonly offsetSeconds: number };
export function resolveInitialPlayback(state: AudioJourneyState): InitialPlayback {
export function resolveInitialPlayback(state: ChapterIntent): InitialPlayback {
return state.atStart
? { kind: 'intro' }
: { kind: 'main', offsetSeconds: mainPhraseOffsetSeconds(state.entryPhrase) };

View File

@@ -11,7 +11,6 @@ export type AudioUiState =
export type AudioContextLifecycleState = AudioContextState | 'interrupted';
export type MusicMood = 'open' | 'shadow' | 'lift' | 'resolve';
export type MusicChapterBias = MusicMood;
export type DriveEnergySource = 'physical' | 'navigation' | 'idle';
export interface ChapterIntent {
@@ -32,13 +31,6 @@ export interface AudioInitialState {
readonly drive: DriveEnergySample;
}
export interface AudioJourneyState {
readonly atStart: boolean;
readonly intensity: number;
readonly bias: MusicChapterBias;
readonly entryPhrase: number;
}
export interface AudioSourceVariant {
readonly url: string;
readonly mediaType: 'audio/ogg' | 'audio/mpeg';
@@ -47,6 +39,8 @@ export interface AudioSourceVariant {
export interface SoundtrackSources {
readonly intro: readonly AudioSourceVariant[];
readonly main: readonly AudioSourceVariant[];
readonly afterglow: readonly AudioSourceVariant[];
readonly driveLift: readonly AudioSourceVariant[];
}
export interface AudioGraph {
@@ -56,18 +50,19 @@ export interface AudioGraph {
connectOutput(): void;
disconnectOutput(): void;
setOutputGain(value: number): void;
setJourneyState(state: AudioJourneyState): void;
setChapterIntent(intent: ChapterIntent): void;
setDriveEnergy(sample: DriveEnergySample): void;
cancelPending(): void;
onContextStateChange(listener: (state: AudioContextLifecycleState) => void): () => void;
}
export type AudioGraphFactory = (initialState: AudioJourneyState) => AudioGraph;
export type AudioGraphFactory = (initialState: AudioInitialState) => AudioGraph;
export interface AudioControllerOptions {
createGraph: AudioGraphFactory;
initialVolume?: number;
initiallyVisible?: boolean;
initialJourneyState?: AudioJourneyState;
readonly createGraph: AudioGraphFactory;
readonly initialVolume?: number;
readonly initiallyVisible?: boolean;
readonly initialState?: AudioInitialState;
}
export interface AudioController {
@@ -75,7 +70,8 @@ export interface AudioController {
resumeFromGesture(): Promise<void>;
setMuted(muted: boolean): void;
setVolume(volume: number): void;
setJourneyState(state: AudioJourneyState): void;
setChapterIntent(intent: ChapterIntent): void;
setDriveEnergy(sample: DriveEnergySample): void;
setVisible(visible: boolean): Promise<void>;
noteInterruption(): Promise<void>;
pageHide(persisted: boolean): Promise<void>;