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>;

View File

@@ -14,6 +14,7 @@ import SiteNavigation from './SiteNavigation.vue';
import ToolkitGrid from './ToolkitGrid.vue';
import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
import { useAudioEngine } from '@/composables/useAudioEngine';
import { useAudioIntent } from '@/composables/useAudioIntent';
import { useJourneySections } from '@/composables/useJourneySections';
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
import { useMotionPreferences } from '@/composables/useMotionPreferences';
@@ -21,7 +22,7 @@ import { assetUrl } from '@/content/asset-url';
import type { PortfolioContent, PublicAssetManifest, SectionId, SectionLayout } from '@/content/types';
import type { SceneHostState } from '@/composables/useSceneController';
import type { MotionPolicy } from '@/journey/motion-policy';
import type { SoundtrackSources } from '@/audio/types';
import type { AudioSourceVariant, SoundtrackSources } from '@/audio/types';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
@@ -65,43 +66,30 @@ const journeyBinding = useJourneySections(
computed(() => motion.policy.value.allowSceneRequest),
);
const journey = journeyBinding.snapshot;
const pendingAudioDestination = shallowRef<SectionId | null>(null);
let audioNavigationEpoch = -1;
watch(
[navigation.state, () => journey.value.activeSectionId],
([navigationState, activeSectionId]) => {
if (navigationState.epoch !== audioNavigationEpoch) {
audioNavigationEpoch = navigationState.epoch;
pendingAudioDestination.value = navigationState.source === 'passive-scroll'
? null
: navigationState.destinationId;
}
if (pendingAudioDestination.value === activeSectionId) {
pendingAudioDestination.value = null;
}
},
{ flush: 'sync', immediate: true },
);
const audioSectionId = computed(() => (
pendingAudioDestination.value ?? journey.value.activeSectionId
));
const audioIntent = useAudioIntent({
canonicalIds: canonicalSectionIds,
journey: journeyBinding,
navigation: navigation.state,
});
const variants = (
sources: { readonly opusAssetId: string; readonly mp3AssetId: string },
): readonly AudioSourceVariant[] => [
{ url: assetUrl(props.manifest, sources.opusAssetId), mediaType: 'audio/ogg' },
{ url: assetUrl(props.manifest, sources.mp3AssetId), mediaType: 'audio/mpeg' },
];
const soundtrackSources = computed<SoundtrackSources | null>(() => {
const soundtrack = props.content.soundtrack;
if (soundtrack === undefined) return null;
return {
intro: [
{ url: assetUrl(props.manifest, soundtrack.intro.opusAssetId), mediaType: 'audio/ogg' },
{ url: assetUrl(props.manifest, soundtrack.intro.mp3AssetId), mediaType: 'audio/mpeg' },
],
main: [
{ url: assetUrl(props.manifest, soundtrack.main.opusAssetId), mediaType: 'audio/ogg' },
{ url: assetUrl(props.manifest, soundtrack.main.mp3AssetId), mediaType: 'audio/mpeg' },
],
intro: variants(soundtrack.intro),
main: variants(soundtrack.main),
afterglow: variants(soundtrack.afterglow),
driveLift: variants(soundtrack.driveLift),
};
});
const audio = useAudioEngine({
activeSectionId: audioSectionId,
intensity: computed(() => journey.value.acceleration),
chapter: audioIntent.chapter,
drive: audioIntent.drive,
soundtrack: soundtrackSources,
volume: motion.volume,
setVolume: motion.setVolume,

View File

@@ -1,5 +1,4 @@
import {
computed,
onMounted,
onUnmounted,
readonly,
@@ -9,17 +8,22 @@ import {
} from 'vue';
import { createAudioController } from '@/audio/create-audio-controller';
import { createBrowserSoundtrackEngine } from '@/audio/soundtrack-engine';
import { resolveJourneyMusicState } from '@/audio/journey-music-state';
import type { AudioController, AudioUiState, SoundtrackSources } from '@/audio/types';
import type {
AudioController,
AudioUiState,
ChapterIntent,
DriveEnergySample,
SoundtrackSources,
} from '@/audio/types';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
export interface AudioEngineSources {
activeSectionId: Readonly<Ref<string>>;
intensity: Readonly<Ref<number>>;
soundtrack: Readonly<Ref<SoundtrackSources | null>>;
volume: Readonly<Ref<number>>;
readonly chapter: Readonly<Ref<ChapterIntent>>;
readonly drive: Readonly<Ref<DriveEnergySample>>;
readonly soundtrack: Readonly<Ref<SoundtrackSources | null>>;
readonly volume: Readonly<Ref<number>>;
setVolume(value: number): void;
}
@@ -38,10 +42,6 @@ export function useAudioEngine(sources: AudioEngineSources): AudioEngineBinding
let controller: AudioController | null = null;
let removeSubscription = (): void => {};
let removePageListeners = (): void => {};
const journeyState = computed(() => resolveJourneyMusicState(
sources.activeSectionId.value,
sources.intensity.value,
));
const ensureController = (): AudioController => {
if (controller) return controller;
@@ -53,13 +53,17 @@ export function useAudioEngine(sources: AudioEngineSources): AudioEngineBinding
},
initialVolume: sources.volume.value,
initiallyVisible: document.visibilityState !== 'hidden',
initialJourneyState: journeyState.value,
initialState: {
chapter: sources.chapter.value,
drive: sources.drive.value,
},
});
removeSubscription = controller.subscribe((next) => { state.value = next; });
return controller;
};
watch(journeyState, (state) => controller?.setJourneyState(state), { flush: 'sync' });
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));
onMounted(() => {

View File

@@ -3,7 +3,7 @@ import { createAudioController } from '@/audio/create-audio-controller';
import type {
AudioGraph,
AudioGraphFactory,
AudioJourneyState,
AudioInitialState,
} from '@/audio/types';
describe('audio controller lifecycle', () => {
@@ -196,25 +196,49 @@ describe('audio controller lifecycle', () => {
expect(controller.snapshot().kind).toBe('failed');
});
it('stores the latest semantic state before activation and forwards raw clamped updates', async () => {
it('stores and normalizes chapter and drive independently before activation', async () => {
const harness = createHarness();
const controller = createAudioController({ createGraph: harness.createGraph });
controller.setChapterIntent({
sectionId: 'contact',
mood: 'resolve',
atStart: false,
entryPhrase: 99,
generation: -3,
});
controller.setDriveEnergy({ energy: 2, source: 'navigation' });
controller.setJourneyState({ atStart: false, bias: 'resolve', entryPhrase: 7, intensity: 2 });
expect(harness.constructed).toBe(0);
await controller.startFromGesture();
expect(harness.initialStates).toEqual([{
chapter: {
sectionId: 'contact',
mood: 'resolve',
atStart: false,
entryPhrase: 7,
generation: 0,
},
drive: { energy: 1, source: 'navigation' },
}]);
expect(harness.initialStates).toEqual([
{ atStart: false, bias: 'resolve', entryPhrase: 7, intensity: 1 },
]);
controller.setJourneyState({ atStart: false, bias: 'shadow', entryPhrase: 2, intensity: 0.73 });
expect(harness.graph.setJourneyState).toHaveBeenLastCalledWith({
controller.setChapterIntent({
sectionId: 'experience',
mood: 'shadow',
atStart: false,
bias: 'shadow',
entryPhrase: 2,
intensity: 0.73,
generation: 4,
});
controller.setDriveEnergy({ energy: 0.73, source: 'physical' });
expect(harness.graph.setChapterIntent).toHaveBeenLastCalledWith({
sectionId: 'experience',
mood: 'shadow',
atStart: false,
entryPhrase: 2,
generation: 4,
});
expect(harness.graph.setDriveEnergy).toHaveBeenLastCalledWith({
energy: 0.73,
source: 'physical',
});
});
});
@@ -245,7 +269,8 @@ function createHarness(options: HarnessOptions = {}) {
connectOutput: vi.fn(),
disconnectOutput: vi.fn(),
setOutputGain: vi.fn(),
setJourneyState: vi.fn(),
setChapterIntent: vi.fn(),
setDriveEnergy: vi.fn(),
cancelPending: vi.fn(),
onContextStateChange: vi.fn((listener) => {
stateListeners.add(listener);
@@ -253,10 +278,10 @@ function createHarness(options: HarnessOptions = {}) {
}),
} satisfies AudioGraph;
let constructed = 0;
const initialStates: AudioJourneyState[] = [];
const createGraph: AudioGraphFactory = (initialState?: AudioJourneyState) => {
const initialStates: AudioInitialState[] = [];
const createGraph: AudioGraphFactory = (initialState: AudioInitialState) => {
constructed += 1;
if (initialState) initialStates.push(initialState);
initialStates.push(initialState);
return graph;
};
return {

View File

@@ -12,17 +12,19 @@ import {
describe('soundtrack transport decisions', () => {
it('starts at the intro only on Start and otherwise chooses the matching main phrase', () => {
expect(resolveInitialPlayback({
sectionId: 'start',
mood: 'open',
atStart: true,
bias: 'open',
entryPhrase: 0,
intensity: 0,
generation: 0,
})).toEqual({ kind: 'intro' });
expect(resolveInitialPlayback({
sectionId: 'infrastructure',
mood: 'shadow',
atStart: false,
bias: 'shadow',
entryPhrase: 3,
intensity: 0.5,
generation: 1,
})).toEqual({ kind: 'main', offsetSeconds: mainPhraseOffsetSeconds(3) });
expect(mainPhraseOffsetSeconds(Number.NaN)).toBe(0);