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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { createAudioController } from '@/audio/create-audio-controller';
import type { import type {
AudioGraph, AudioGraph,
AudioGraphFactory, AudioGraphFactory,
AudioJourneyState, AudioInitialState,
} from '@/audio/types'; } from '@/audio/types';
describe('audio controller lifecycle', () => { describe('audio controller lifecycle', () => {
@@ -196,25 +196,49 @@ describe('audio controller lifecycle', () => {
expect(controller.snapshot().kind).toBe('failed'); 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 harness = createHarness();
const controller = createAudioController({ createGraph: harness.createGraph }); 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); expect(harness.constructed).toBe(0);
await controller.startFromGesture(); 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([ controller.setChapterIntent({
{ atStart: false, bias: 'resolve', entryPhrase: 7, intensity: 1 }, sectionId: 'experience',
]); mood: 'shadow',
controller.setJourneyState({ atStart: false, bias: 'shadow', entryPhrase: 2, intensity: 0.73 });
expect(harness.graph.setJourneyState).toHaveBeenLastCalledWith({
atStart: false, atStart: false,
bias: 'shadow',
entryPhrase: 2, 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(), connectOutput: vi.fn(),
disconnectOutput: vi.fn(), disconnectOutput: vi.fn(),
setOutputGain: vi.fn(), setOutputGain: vi.fn(),
setJourneyState: vi.fn(), setChapterIntent: vi.fn(),
setDriveEnergy: vi.fn(),
cancelPending: vi.fn(), cancelPending: vi.fn(),
onContextStateChange: vi.fn((listener) => { onContextStateChange: vi.fn((listener) => {
stateListeners.add(listener); stateListeners.add(listener);
@@ -253,10 +278,10 @@ function createHarness(options: HarnessOptions = {}) {
}), }),
} satisfies AudioGraph; } satisfies AudioGraph;
let constructed = 0; let constructed = 0;
const initialStates: AudioJourneyState[] = []; const initialStates: AudioInitialState[] = [];
const createGraph: AudioGraphFactory = (initialState?: AudioJourneyState) => { const createGraph: AudioGraphFactory = (initialState: AudioInitialState) => {
constructed += 1; constructed += 1;
if (initialState) initialStates.push(initialState); initialStates.push(initialState);
return graph; return graph;
}; };
return { return {

View File

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