refactor: split soundtrack chapter and drive commands
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user