refactor: split soundtrack chapter and drive commands
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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([
|
||||
{ atStart: false, bias: 'resolve', entryPhrase: 7, intensity: 1 },
|
||||
]);
|
||||
|
||||
controller.setJourneyState({ atStart: false, bias: 'shadow', entryPhrase: 2, intensity: 0.73 });
|
||||
|
||||
expect(harness.graph.setJourneyState).toHaveBeenLastCalledWith({
|
||||
expect(harness.initialStates).toEqual([{
|
||||
chapter: {
|
||||
sectionId: 'contact',
|
||||
mood: 'resolve',
|
||||
atStart: false,
|
||||
entryPhrase: 7,
|
||||
generation: 0,
|
||||
},
|
||||
drive: { energy: 1, source: 'navigation' },
|
||||
}]);
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user