feat: integrate authored nightdrive soundtrack
This commit is contained in:
@@ -13,6 +13,6 @@ defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||
|
||||
<template>
|
||||
<div class="portfolio-shell">
|
||||
<AppShell :content="inputs.content" />
|
||||
<AppShell :content="inputs.content" :manifest="inputs.manifest" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,15 +2,16 @@ import type {
|
||||
AudioController,
|
||||
AudioControllerOptions,
|
||||
AudioGraph,
|
||||
AudioJourneyState,
|
||||
AudioUiState,
|
||||
} from '@/audio/types';
|
||||
import { DEFAULT_AUDIO_JOURNEY_STATE } from '@/audio/journey-music-state';
|
||||
|
||||
type ActualState = AudioUiState['kind'];
|
||||
type DesiredState = 'idle' | 'running' | 'suspended' | 'closed';
|
||||
type ResumeIntent = 'none' | 'automatic-after-visibility' | 'user-required';
|
||||
|
||||
const DEFAULT_VOLUME = 0.4;
|
||||
const INTENSITY_SMOOTHING = 0.12;
|
||||
|
||||
export function createAudioController(options: AudioControllerOptions): AudioController {
|
||||
let actual: ActualState = 'idle';
|
||||
@@ -20,7 +21,7 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
||||
let visible = options.initiallyVisible ?? true;
|
||||
let muted = false;
|
||||
let volume = clamp(options.initialVolume ?? DEFAULT_VOLUME);
|
||||
let intensity = 0;
|
||||
let journeyState = normalizeJourneyState(options.initialJourneyState ?? DEFAULT_AUDIO_JOURNEY_STATE);
|
||||
let graph: AudioGraph | null = null;
|
||||
let graphClosed = false;
|
||||
let removeStateListener = (): void => {};
|
||||
@@ -63,6 +64,7 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
||||
const closeGraph = async (): Promise<void> => {
|
||||
if (!graph || graphClosed) return;
|
||||
graphClosed = true;
|
||||
graph.cancelPending();
|
||||
removeStateListener();
|
||||
removeStateListener = (): void => {};
|
||||
try {
|
||||
@@ -94,7 +96,7 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
||||
if (!graph || graphClosed) return;
|
||||
graph.connectOutput();
|
||||
graph.setOutputGain(muted ? 0 : volume);
|
||||
graph.setIntensity(intensity);
|
||||
graph.setJourneyState(journeyState);
|
||||
resumeIntent = 'none';
|
||||
setActual('running');
|
||||
};
|
||||
@@ -150,12 +152,12 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
||||
|
||||
try {
|
||||
// This factory call intentionally remains synchronous in the activating event stack.
|
||||
graph = options.createGraph();
|
||||
graph = options.createGraph(journeyState);
|
||||
removeStateListener = graph.onContextStateChange((state) => {
|
||||
if (state === 'interrupted') void noteInterruption();
|
||||
});
|
||||
graph.setOutputGain(0);
|
||||
graph.setIntensity(intensity);
|
||||
graph.setJourneyState(journeyState);
|
||||
} catch {
|
||||
startOperation = enqueue(fail);
|
||||
return startOperation;
|
||||
@@ -243,6 +245,7 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
||||
visible = false;
|
||||
operationGeneration += 1;
|
||||
silence();
|
||||
graph?.cancelPending();
|
||||
setActual('disposed');
|
||||
void enqueue(closeGraph);
|
||||
};
|
||||
@@ -262,11 +265,10 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
||||
graph?.setOutputGain(muted ? 0 : volume);
|
||||
if (actual === 'running') publish();
|
||||
},
|
||||
setIntensity(nextIntensity) {
|
||||
setJourneyState(nextState) {
|
||||
if (actual === 'disposed') return;
|
||||
const target = clamp(nextIntensity);
|
||||
intensity += (target - intensity) * INTENSITY_SMOOTHING;
|
||||
graph?.setIntensity(intensity);
|
||||
journeyState = normalizeJourneyState(nextState);
|
||||
graph?.setJourneyState(journeyState);
|
||||
},
|
||||
setVisible,
|
||||
noteInterruption,
|
||||
@@ -287,3 +289,13 @@ function clamp(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
function normalizeJourneyState(state: AudioJourneyState): AudioJourneyState {
|
||||
const entryPhrase = Number.isFinite(state.entryPhrase) ? Math.trunc(state.entryPhrase) : 0;
|
||||
return Object.freeze({
|
||||
atStart: state.atStart === true,
|
||||
bias: state.bias,
|
||||
entryPhrase: Math.min(7, Math.max(0, entryPhrase)),
|
||||
intensity: clamp(state.intensity),
|
||||
});
|
||||
}
|
||||
|
||||
26
src/audio/journey-music-state.ts
Normal file
26
src/audio/journey-music-state.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { AudioJourneyState } from '@/audio/types';
|
||||
|
||||
const JOURNEY_MUSIC = Object.freeze({
|
||||
start: { bias: 'open', entryPhrase: 0 },
|
||||
profile: { bias: 'open', entryPhrase: 1 },
|
||||
experience: { bias: 'shadow', entryPhrase: 2 },
|
||||
infrastructure: { bias: 'shadow', entryPhrase: 3 },
|
||||
'selected-builds': { bias: 'lift', entryPhrase: 4 },
|
||||
'additional-work': { bias: 'lift', entryPhrase: 5 },
|
||||
toolkit: { bias: 'resolve', entryPhrase: 6 },
|
||||
contact: { bias: 'resolve', entryPhrase: 7 },
|
||||
} as const);
|
||||
|
||||
export function resolveJourneyMusicState(sectionId: string, intensity = 0): AudioJourneyState {
|
||||
const chapter = Object.hasOwn(JOURNEY_MUSIC, sectionId)
|
||||
? JOURNEY_MUSIC[sectionId as keyof typeof JOURNEY_MUSIC]
|
||||
: JOURNEY_MUSIC.start;
|
||||
const finiteIntensity = Number.isFinite(intensity) ? intensity : 0;
|
||||
return Object.freeze({
|
||||
...chapter,
|
||||
atStart: sectionId === 'start',
|
||||
intensity: Math.min(1, Math.max(0, finiteIntensity)),
|
||||
});
|
||||
}
|
||||
|
||||
export const DEFAULT_AUDIO_JOURNEY_STATE = resolveJourneyMusicState('start');
|
||||
@@ -1,215 +0,0 @@
|
||||
import type { MusicSequencer } from '@/audio/types';
|
||||
|
||||
export const MUSIC_BPM = 86;
|
||||
export const MUSIC_SCHEDULER_INTERVAL_MS = 100;
|
||||
export const MUSIC_LOOKAHEAD_SECONDS = 0.25;
|
||||
|
||||
export const MUSIC_PROGRESSION = [
|
||||
{ name: 'F minor', notes: [174.61, 207.65, 261.63], bass: [87.31, 130.81] },
|
||||
{ name: 'D-flat major', notes: [138.59, 174.61, 207.65], bass: [69.3, 103.83] },
|
||||
{ name: 'A-flat major', notes: [207.65, 261.63, 311.13], bass: [103.83, 155.56] },
|
||||
{ name: 'E-flat major', notes: [155.56, 196, 233.08], bass: [77.78, 116.54] },
|
||||
] as const;
|
||||
|
||||
const SIXTEENTH_SECONDS = 60 / MUSIC_BPM / 4;
|
||||
const STEPS_PER_BAR = 16;
|
||||
const LOOP_STEPS = STEPS_PER_BAR * MUSIC_PROGRESSION.length;
|
||||
const MAX_STEPS_PER_SCHEDULE = LOOP_STEPS * 2;
|
||||
const SILENCE = 0.0001;
|
||||
|
||||
interface VoiceRecord {
|
||||
source: AudioScheduledSourceNode;
|
||||
nodes: AudioNode[];
|
||||
}
|
||||
|
||||
export function createMusicSequencer(
|
||||
context: AudioContext,
|
||||
destination: AudioNode,
|
||||
): MusicSequencer {
|
||||
const voices = new Map<AudioScheduledSourceNode, VoiceRecord>();
|
||||
const clapFilter = context.createBiquadFilter();
|
||||
clapFilter.type = 'bandpass';
|
||||
clapFilter.frequency.setValueAtTime(1_650, context.currentTime);
|
||||
clapFilter.Q.setValueAtTime(0.7, context.currentTime);
|
||||
clapFilter.connect(destination);
|
||||
const clapBuffer = createNoiseBuffer(context, 0.12);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let playing = false;
|
||||
let intensity = 0;
|
||||
let stepCursor = 0;
|
||||
let nextStepTime = 0;
|
||||
|
||||
const cleanupVoice = (source: AudioScheduledSourceNode): void => {
|
||||
const voice = voices.get(source);
|
||||
if (!voice) return;
|
||||
voices.delete(source);
|
||||
source.disconnect();
|
||||
for (const node of voice.nodes) node.disconnect();
|
||||
};
|
||||
|
||||
const registerVoice = (
|
||||
source: AudioScheduledSourceNode,
|
||||
nodes: AudioNode[],
|
||||
atTime: number,
|
||||
duration: number,
|
||||
): void => {
|
||||
voices.set(source, { source, nodes });
|
||||
source.addEventListener('ended', () => cleanupVoice(source), { once: true });
|
||||
source.start(atTime);
|
||||
source.stop(atTime + duration);
|
||||
};
|
||||
|
||||
const scheduleTone = (
|
||||
frequency: number,
|
||||
type: OscillatorType,
|
||||
atTime: number,
|
||||
duration: number,
|
||||
peak: number,
|
||||
attack: number,
|
||||
output: AudioNode = destination,
|
||||
): void => {
|
||||
const source = context.createOscillator();
|
||||
source.type = type;
|
||||
source.frequency.setValueAtTime(frequency, atTime);
|
||||
const gain = context.createGain();
|
||||
gain.gain.setValueAtTime(SILENCE, atTime);
|
||||
gain.gain.exponentialRampToValueAtTime(peak, atTime + attack);
|
||||
gain.gain.setValueAtTime(peak, Math.max(atTime + attack, atTime + duration * 0.72));
|
||||
gain.gain.exponentialRampToValueAtTime(SILENCE, atTime + duration);
|
||||
source.connect(gain);
|
||||
gain.connect(output);
|
||||
registerVoice(source, [gain], atTime, duration);
|
||||
};
|
||||
|
||||
const schedulePad = (notes: readonly number[], atTime: number): void => {
|
||||
const duration = SIXTEENTH_SECONDS * 15.6;
|
||||
for (const [index, frequency] of notes.entries()) {
|
||||
scheduleTone(frequency, index === 1 ? 'triangle' : 'sawtooth', atTime, duration, 0.012, 0.34);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleBass = (frequency: number, atTime: number): void => {
|
||||
scheduleTone(frequency, 'triangle', atTime, SIXTEENTH_SECONDS * 6.8, 0.052, 0.035);
|
||||
};
|
||||
|
||||
const scheduleKick = (atTime: number, accent = 1): void => {
|
||||
const source = context.createOscillator();
|
||||
source.type = 'sine';
|
||||
source.frequency.setValueAtTime(118, atTime);
|
||||
source.frequency.exponentialRampToValueAtTime(46, atTime + 0.16);
|
||||
const gain = context.createGain();
|
||||
gain.gain.setValueAtTime(0.07 * accent, atTime);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENCE, atTime + 0.22);
|
||||
source.connect(gain);
|
||||
gain.connect(destination);
|
||||
registerVoice(source, [gain], atTime, 0.23);
|
||||
};
|
||||
|
||||
const scheduleClap = (atTime: number): void => {
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = clapBuffer;
|
||||
const gain = context.createGain();
|
||||
gain.gain.setValueAtTime(SILENCE, atTime);
|
||||
gain.gain.linearRampToValueAtTime(0.026, atTime + 0.008);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENCE, atTime + 0.11);
|
||||
source.connect(gain);
|
||||
gain.connect(clapFilter);
|
||||
registerVoice(source, [gain], atTime, 0.12);
|
||||
};
|
||||
|
||||
const schedulePulse = (atTime: number, step: number): void => {
|
||||
const level = 0.006 + intensity * 0.016;
|
||||
const frequency = step % 8 === 2 ? 2_140 : 1_820;
|
||||
scheduleTone(frequency, 'square', atTime, 0.045, level, 0.003);
|
||||
};
|
||||
|
||||
const scheduleMotif = (atTime: number, frequency: number): void => {
|
||||
scheduleTone(frequency, 'sine', atTime, SIXTEENTH_SECONDS * 2.7, 0.019, 0.025);
|
||||
};
|
||||
|
||||
const scheduleStep = (absoluteStep: number, atTime: number): void => {
|
||||
const loopStep = absoluteStep % LOOP_STEPS;
|
||||
const stepInBar = loopStep % STEPS_PER_BAR;
|
||||
const bar = Math.floor(loopStep / STEPS_PER_BAR);
|
||||
const chord = MUSIC_PROGRESSION[bar]!;
|
||||
|
||||
if (stepInBar === 0) {
|
||||
schedulePad(chord.notes, atTime);
|
||||
scheduleBass(chord.bass[0], atTime);
|
||||
scheduleKick(atTime, bar === 0 ? 1 : 0.82);
|
||||
}
|
||||
if (stepInBar === 8) {
|
||||
scheduleBass(chord.bass[1], atTime);
|
||||
scheduleClap(atTime);
|
||||
}
|
||||
if (stepInBar === 10 && bar % 2 === 0) scheduleKick(atTime, 0.56);
|
||||
if (stepInBar === 2 || stepInBar === 6 || stepInBar === 10 || stepInBar === 14) {
|
||||
schedulePulse(atTime, stepInBar);
|
||||
}
|
||||
if (loopStep === 52) scheduleMotif(atTime, 466.16);
|
||||
if (loopStep === 60) scheduleMotif(atTime, 523.25);
|
||||
};
|
||||
|
||||
const scheduleThrough = (horizonTime: number): void => {
|
||||
if (!playing || !Number.isFinite(horizonTime)) return;
|
||||
if (nextStepTime < context.currentTime - SIXTEENTH_SECONDS * 2) {
|
||||
const skipped = Math.floor((context.currentTime - nextStepTime) / SIXTEENTH_SECONDS);
|
||||
stepCursor += skipped;
|
||||
nextStepTime += skipped * SIXTEENTH_SECONDS;
|
||||
}
|
||||
let scheduled = 0;
|
||||
while (nextStepTime <= horizonTime + Number.EPSILON && scheduled < MAX_STEPS_PER_SCHEDULE) {
|
||||
scheduleStep(stepCursor, nextStepTime);
|
||||
stepCursor += 1;
|
||||
nextStepTime += SIXTEENTH_SECONDS;
|
||||
scheduled += 1;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start(atTime) {
|
||||
if (playing) return;
|
||||
playing = true;
|
||||
stepCursor = 0;
|
||||
nextStepTime = Math.max(context.currentTime, Number.isFinite(atTime) ? atTime : context.currentTime);
|
||||
scheduleThrough(nextStepTime + MUSIC_LOOKAHEAD_SECONDS);
|
||||
timer = setInterval(() => {
|
||||
scheduleThrough(context.currentTime + MUSIC_LOOKAHEAD_SECONDS);
|
||||
}, MUSIC_SCHEDULER_INTERVAL_MS);
|
||||
},
|
||||
scheduleThrough,
|
||||
setIntensity(value, _atTime) {
|
||||
intensity = clamp(value);
|
||||
},
|
||||
stop() {
|
||||
if (!playing && timer === null) return;
|
||||
playing = false;
|
||||
if (timer !== null) clearInterval(timer);
|
||||
timer = null;
|
||||
for (const source of [...voices.keys()]) {
|
||||
try { source.stop(context.currentTime); } catch { /* The voice may already have ended. */ }
|
||||
cleanupVoice(source);
|
||||
}
|
||||
clapFilter.disconnect();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNoiseBuffer(context: AudioContext, durationSeconds: number): AudioBuffer {
|
||||
const length = Math.max(1, Math.floor(context.sampleRate * durationSeconds));
|
||||
const buffer = context.createBuffer(1, length, context.sampleRate);
|
||||
const samples = buffer.getChannelData(0);
|
||||
let state = 0x9e3779b9;
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
samples[index] = ((state >>> 0) / 0xffff_ffff) * 2 - 1;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function clamp(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
383
src/audio/soundtrack-engine.ts
Normal file
383
src/audio/soundtrack-engine.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import {
|
||||
INTRO_FIRST_PASS_END_SECONDS,
|
||||
INTRO_IN_END_SECONDS,
|
||||
INTRO_LOOP_END_SECONDS,
|
||||
INTRO_LOOP_START_SECONDS,
|
||||
INTRO_OUT_END_SECONDS,
|
||||
INTRO_OUT_START_SECONDS,
|
||||
INTRO_RUNTIME_END_SECONDS,
|
||||
SOUNDTRACK_SAMPLE_RATE,
|
||||
mainPhraseOffsetSeconds,
|
||||
nextSafeBarTime,
|
||||
} from '@/audio/soundtrack-transport';
|
||||
import type {
|
||||
AudioContextLifecycleState,
|
||||
AudioGraph,
|
||||
AudioJourneyState,
|
||||
MusicChapterBias,
|
||||
SoundtrackSources,
|
||||
} from '@/audio/types';
|
||||
|
||||
const SOURCE_LEAD_SECONDS = 0.08;
|
||||
const START_FADE_SECONDS = 0.8;
|
||||
const EXIT_CROSSFADE_SECONDS = 0.25;
|
||||
const EXIT_MINIMUM_LEAD_SECONDS = 0.35;
|
||||
const EXIT_COALESCE_MS = 120;
|
||||
const PROFILE_RAMP_SECONDS = 0.85;
|
||||
|
||||
interface SourceHandle {
|
||||
readonly node: AudioBufferSourceNode;
|
||||
readonly gain: GainNode;
|
||||
}
|
||||
|
||||
interface MixProfile {
|
||||
readonly gain: number;
|
||||
readonly highShelfDb: number;
|
||||
}
|
||||
|
||||
const MIX_PROFILES: Readonly<Record<MusicChapterBias, MixProfile>> = Object.freeze({
|
||||
open: { gain: 0.985, highShelfDb: -0.35 },
|
||||
shadow: { gain: 0.97, highShelfDb: -0.9 },
|
||||
lift: { gain: 1, highShelfDb: 0.35 },
|
||||
resolve: { gain: 0.985, highShelfDb: -0.15 },
|
||||
});
|
||||
|
||||
export function createBrowserSoundtrackEngine(
|
||||
sources: SoundtrackSources,
|
||||
initialState: AudioJourneyState,
|
||||
): AudioGraph {
|
||||
const BrowserAudioContext = window.AudioContext;
|
||||
if (BrowserAudioContext === undefined) throw new Error('Web Audio is unavailable.');
|
||||
|
||||
const context = new BrowserAudioContext({
|
||||
latencyHint: 'playback',
|
||||
sampleRate: SOUNDTRACK_SAMPLE_RATE,
|
||||
});
|
||||
const profileGain = context.createGain();
|
||||
const tone = context.createBiquadFilter();
|
||||
const outputGain = context.createGain();
|
||||
const sourceNodes = new Set<AudioBufferSourceNode>();
|
||||
const stateListeners = new Set<(state: AudioContextLifecycleState) => void>();
|
||||
let latestState = initialState;
|
||||
let connected = false;
|
||||
let closed = false;
|
||||
const activatedFromStart = initialState.atStart;
|
||||
let transportStarted = false;
|
||||
let introStarted = false;
|
||||
let mainCommitted = false;
|
||||
let introAnchorTime = 0;
|
||||
let introProgramGain: GainNode | null = null;
|
||||
let introSourceNodes: AudioBufferSourceNode[] = [];
|
||||
let loadedIntroBuffer: AudioBuffer | null = null;
|
||||
let mainBufferPromise: Promise<AudioBuffer> | null = null;
|
||||
let loadController: AbortController | null = null;
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
let exitTimer: number | undefined;
|
||||
|
||||
tone.type = 'highshelf';
|
||||
tone.frequency.value = 5_200;
|
||||
tone.Q.value = 0.55;
|
||||
outputGain.gain.value = 0;
|
||||
profileGain.connect(tone);
|
||||
tone.connect(outputGain);
|
||||
|
||||
const trackSource = (node: AudioBufferSourceNode): void => {
|
||||
sourceNodes.add(node);
|
||||
node.addEventListener('ended', () => {
|
||||
sourceNodes.delete(node);
|
||||
node.disconnect();
|
||||
}, { once: true });
|
||||
};
|
||||
|
||||
const makeSource = (buffer: AudioBuffer): SourceHandle => {
|
||||
const node = context.createBufferSource();
|
||||
const gain = context.createGain();
|
||||
node.buffer = buffer;
|
||||
node.connect(gain);
|
||||
gain.connect(profileGain);
|
||||
node.addEventListener('ended', () => gain.disconnect(), { once: true });
|
||||
trackSource(node);
|
||||
return { node, gain };
|
||||
};
|
||||
|
||||
const ramp = (parameter: AudioParam, value: number, duration: number): void => {
|
||||
const now = context.currentTime;
|
||||
holdParameter(parameter, now);
|
||||
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 startMain = (
|
||||
buffer: AudioBuffer,
|
||||
when: number,
|
||||
offsetSeconds: number,
|
||||
fadeSeconds: number,
|
||||
): SourceHandle => {
|
||||
const main = makeSource(buffer);
|
||||
const offset = Math.min(Math.max(0, offsetSeconds), Math.max(0, buffer.duration - 0.05));
|
||||
main.gain.gain.setValueAtTime(0, when);
|
||||
main.gain.gain.linearRampToValueAtTime(1, when + fadeSeconds);
|
||||
main.node.start(when, offset);
|
||||
mainCommitted = true;
|
||||
return main;
|
||||
};
|
||||
|
||||
const startIntro = (buffer: AudioBuffer, when: number): void => {
|
||||
const lead = context.createBufferSource();
|
||||
const loop = context.createBufferSource();
|
||||
const programGain = context.createGain();
|
||||
|
||||
lead.buffer = buffer;
|
||||
loop.buffer = buffer;
|
||||
loop.loop = true;
|
||||
loop.loopStart = INTRO_LOOP_START_SECONDS;
|
||||
loop.loopEnd = INTRO_LOOP_END_SECONDS;
|
||||
lead.connect(programGain);
|
||||
loop.connect(programGain);
|
||||
programGain.connect(profileGain);
|
||||
programGain.gain.setValueAtTime(0, when);
|
||||
programGain.gain.linearRampToValueAtTime(1, when + START_FADE_SECONDS);
|
||||
|
||||
trackSource(lead);
|
||||
trackSource(loop);
|
||||
lead.start(when, 0, INTRO_FIRST_PASS_END_SECONDS);
|
||||
loop.start(when + INTRO_FIRST_PASS_END_SECONDS, INTRO_LOOP_START_SECONDS);
|
||||
|
||||
introProgramGain = programGain;
|
||||
introSourceNodes = [lead, loop];
|
||||
introAnchorTime = when;
|
||||
introStarted = true;
|
||||
};
|
||||
|
||||
const scheduleIntroExit = (introBuffer: AudioBuffer, mainBuffer: AudioBuffer): void => {
|
||||
if (!introStarted || mainCommitted || introProgramGain === null || closed) return;
|
||||
mainCommitted = true;
|
||||
const schedulingTime = Math.max(
|
||||
context.currentTime,
|
||||
introAnchorTime + INTRO_IN_END_SECONDS - EXIT_MINIMUM_LEAD_SECONDS,
|
||||
);
|
||||
const transitionTime = nextSafeBarTime(
|
||||
schedulingTime,
|
||||
introAnchorTime,
|
||||
EXIT_MINIMUM_LEAD_SECONDS,
|
||||
);
|
||||
const outro = makeSource(introBuffer);
|
||||
outro.gain.gain.setValueAtTime(0, transitionTime);
|
||||
outro.gain.gain.linearRampToValueAtTime(1, transitionTime + EXIT_CROSSFADE_SECONDS);
|
||||
outro.node.start(
|
||||
transitionTime,
|
||||
INTRO_OUT_START_SECONDS,
|
||||
INTRO_RUNTIME_END_SECONDS - INTRO_OUT_START_SECONDS,
|
||||
);
|
||||
|
||||
introProgramGain.gain.cancelScheduledValues(transitionTime);
|
||||
introProgramGain.gain.setValueAtTime(1, transitionTime);
|
||||
introProgramGain.gain.linearRampToValueAtTime(0, transitionTime + EXIT_CROSSFADE_SECONDS);
|
||||
for (const source of introSourceNodes) {
|
||||
stopSourceAt(source, transitionTime + EXIT_CROSSFADE_SECONDS);
|
||||
}
|
||||
|
||||
const mainTime = transitionTime + INTRO_OUT_END_SECONDS - INTRO_OUT_START_SECONDS;
|
||||
const main = startMain(mainBuffer, mainTime, 0, EXIT_CROSSFADE_SECONDS);
|
||||
outro.gain.gain.cancelScheduledValues(mainTime);
|
||||
outro.gain.gain.setValueAtTime(1, mainTime);
|
||||
outro.gain.gain.linearRampToValueAtTime(0, mainTime + EXIT_CROSSFADE_SECONDS);
|
||||
const finishedProgramGain = introProgramGain;
|
||||
outro.node.addEventListener('ended', () => {
|
||||
finishedProgramGain.disconnect();
|
||||
if (introProgramGain === finishedProgramGain) introProgramGain = null;
|
||||
introSourceNodes = [];
|
||||
loadedIntroBuffer = null;
|
||||
introStarted = false;
|
||||
}, { once: true });
|
||||
};
|
||||
|
||||
const loadAndStart = async (): Promise<void> => {
|
||||
loadController = new AbortController();
|
||||
const signal = loadController.signal;
|
||||
mainBufferPromise = loadAudioBuffer(context, sources.main, signal);
|
||||
void mainBufferPromise.catch(() => undefined);
|
||||
|
||||
if (activatedFromStart) {
|
||||
loadedIntroBuffer = await loadAudioBuffer(context, sources.intro, signal);
|
||||
if (closed || signal.aborted) throw abortError();
|
||||
if (latestState.atStart) {
|
||||
transportStarted = true;
|
||||
startIntro(loadedIntroBuffer, context.currentTime + SOURCE_LEAD_SECONDS);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const mainBuffer = await mainBufferPromise;
|
||||
if (closed || signal.aborted) throw abortError();
|
||||
if (activatedFromStart && latestState.atStart && loadedIntroBuffer !== null) {
|
||||
transportStarted = true;
|
||||
startIntro(loadedIntroBuffer, context.currentTime + SOURCE_LEAD_SECONDS);
|
||||
return;
|
||||
}
|
||||
transportStarted = true;
|
||||
startMain(
|
||||
mainBuffer,
|
||||
context.currentTime + SOURCE_LEAD_SECONDS,
|
||||
mainPhraseOffsetSeconds(latestState.entryPhrase),
|
||||
START_FADE_SECONDS,
|
||||
);
|
||||
};
|
||||
|
||||
const requestIntroExit = (): void => {
|
||||
if (!introStarted || mainCommitted || exitTimer !== undefined || closed) return;
|
||||
exitTimer = window.setTimeout(() => {
|
||||
exitTimer = undefined;
|
||||
if (closed || latestState.atStart || mainCommitted) return;
|
||||
const introBuffer = loadedIntroBuffer;
|
||||
const pendingMain = mainBufferPromise;
|
||||
if (introBuffer === null || pendingMain === null) return;
|
||||
void pendingMain.then((mainBuffer) => {
|
||||
if (!closed && !latestState.atStart && !mainCommitted) {
|
||||
scheduleIntroExit(introBuffer, mainBuffer);
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
}, EXIT_COALESCE_MS);
|
||||
};
|
||||
|
||||
const onStateChange = (): void => {
|
||||
const state = context.state as AudioContextLifecycleState;
|
||||
for (const listener of stateListeners) listener(state);
|
||||
};
|
||||
context.addEventListener('statechange', onStateChange);
|
||||
applyMix(initialState);
|
||||
|
||||
return {
|
||||
async resume() {
|
||||
if (closed) throw new DOMException('Audio graph is closed.', 'InvalidStateError');
|
||||
await context.resume();
|
||||
if (transportStarted) return;
|
||||
loadPromise ??= loadAndStart();
|
||||
await loadPromise;
|
||||
},
|
||||
async suspend() {
|
||||
if (!closed && context.state !== 'closed') await context.suspend();
|
||||
},
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (exitTimer !== undefined) window.clearTimeout(exitTimer);
|
||||
loadController?.abort();
|
||||
for (const source of sourceNodes) stopSource(source);
|
||||
sourceNodes.clear();
|
||||
context.removeEventListener('statechange', onStateChange);
|
||||
stateListeners.clear();
|
||||
profileGain.disconnect();
|
||||
tone.disconnect();
|
||||
outputGain.disconnect();
|
||||
if (context.state !== 'closed') await context.close();
|
||||
},
|
||||
connectOutput() {
|
||||
if (connected || closed) return;
|
||||
outputGain.connect(context.destination);
|
||||
connected = true;
|
||||
},
|
||||
disconnectOutput() {
|
||||
if (!connected) return;
|
||||
outputGain.disconnect();
|
||||
connected = false;
|
||||
},
|
||||
setOutputGain(value) {
|
||||
ramp(outputGain.gain, clamp(value), 0.08);
|
||||
},
|
||||
setJourneyState(state) {
|
||||
if (closed) return;
|
||||
latestState = state;
|
||||
applyMix(state);
|
||||
if (!state.atStart) requestIntroExit();
|
||||
else if (exitTimer !== undefined) {
|
||||
window.clearTimeout(exitTimer);
|
||||
exitTimer = undefined;
|
||||
}
|
||||
},
|
||||
cancelPending() {
|
||||
loadController?.abort();
|
||||
if (exitTimer !== undefined) {
|
||||
window.clearTimeout(exitTimer);
|
||||
exitTimer = undefined;
|
||||
}
|
||||
},
|
||||
onContextStateChange(listener) {
|
||||
stateListeners.add(listener);
|
||||
return () => stateListeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAudioBuffer(
|
||||
context: BaseAudioContext,
|
||||
variants: readonly SoundtrackSources['intro'][number][],
|
||||
signal: AbortSignal,
|
||||
): Promise<AudioBuffer> {
|
||||
const ordered = orderSupportedVariants(variants);
|
||||
let lastError: unknown;
|
||||
for (const variant of ordered) {
|
||||
if (signal.aborted) throw abortError();
|
||||
try {
|
||||
const response = await fetch(variant.url, { signal, credentials: 'same-origin' });
|
||||
if (!response.ok) throw new Error(`Soundtrack request failed (${response.status}).`);
|
||||
const encoded = await response.arrayBuffer();
|
||||
if (signal.aborted) throw abortError();
|
||||
return await context.decodeAudioData(encoded);
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw abortError();
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error('No supported soundtrack source loaded.');
|
||||
}
|
||||
|
||||
function orderSupportedVariants<T extends SoundtrackSources['intro'][number]>(
|
||||
variants: readonly T[],
|
||||
): readonly T[] {
|
||||
const probe = document.createElement('audio');
|
||||
return [...variants].sort((left, right) => (
|
||||
Number(probe.canPlayType(right.mediaType) !== '') - Number(probe.canPlayType(left.mediaType) !== '')
|
||||
));
|
||||
}
|
||||
|
||||
function holdParameter(parameter: AudioParam, atTime: number): void {
|
||||
if (typeof parameter.cancelAndHoldAtTime === 'function') {
|
||||
parameter.cancelAndHoldAtTime(atTime);
|
||||
return;
|
||||
}
|
||||
parameter.cancelScheduledValues(atTime);
|
||||
parameter.setValueAtTime(parameter.value, atTime);
|
||||
}
|
||||
|
||||
function stopSource(source: AudioBufferSourceNode): void {
|
||||
try {
|
||||
source.stop();
|
||||
} catch {
|
||||
// An already-ended or not-yet-started source is safe to ignore during teardown.
|
||||
}
|
||||
source.disconnect();
|
||||
}
|
||||
|
||||
function stopSourceAt(source: AudioBufferSourceNode, when: number): void {
|
||||
try {
|
||||
source.stop(when);
|
||||
} catch {
|
||||
// A source that already ended needs no scheduled cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException('Soundtrack loading was cancelled.', 'AbortError');
|
||||
}
|
||||
|
||||
function clamp(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
53
src/audio/soundtrack-transport.ts
Normal file
53
src/audio/soundtrack-transport.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { AudioJourneyState } from '@/audio/types';
|
||||
|
||||
export const SOUNDTRACK_SAMPLE_RATE = 48_000;
|
||||
export const MUSIC_BPM = 86;
|
||||
export const MUSIC_BAR_SECONDS = 4 * 60 / MUSIC_BPM;
|
||||
export const MAIN_PHRASE_BARS = 8;
|
||||
|
||||
export const INTRO_IN_END_SAMPLE = 267_907;
|
||||
export const INTRO_FIRST_PASS_END_SAMPLE = 1_339_535;
|
||||
export const INTRO_LOOP_START_SAMPLE = INTRO_FIRST_PASS_END_SAMPLE;
|
||||
export const INTRO_LOOP_END_SAMPLE = 2_411_163;
|
||||
export const INTRO_OUT_START_SAMPLE = INTRO_LOOP_END_SAMPLE;
|
||||
export const INTRO_OUT_END_SAMPLE = 2_679_070;
|
||||
export const INTRO_RUNTIME_END_SAMPLE = 2_691_070;
|
||||
|
||||
export const INTRO_IN_END_SECONDS = INTRO_IN_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||
export const INTRO_FIRST_PASS_END_SECONDS = INTRO_FIRST_PASS_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||
export const INTRO_LOOP_START_SECONDS = INTRO_LOOP_START_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||
export const INTRO_LOOP_END_SECONDS = INTRO_LOOP_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||
export const INTRO_OUT_START_SECONDS = INTRO_OUT_START_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||
export const INTRO_OUT_END_SECONDS = INTRO_OUT_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||
export const INTRO_RUNTIME_END_SECONDS = INTRO_RUNTIME_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||
|
||||
export type InitialPlayback =
|
||||
| { readonly kind: 'intro' }
|
||||
| { readonly kind: 'main'; readonly offsetSeconds: number };
|
||||
|
||||
export function resolveInitialPlayback(state: AudioJourneyState): InitialPlayback {
|
||||
return state.atStart
|
||||
? { kind: 'intro' }
|
||||
: { kind: 'main', offsetSeconds: mainPhraseOffsetSeconds(state.entryPhrase) };
|
||||
}
|
||||
|
||||
export function mainPhraseOffsetSeconds(entryPhrase: number): number {
|
||||
const finitePhrase = Number.isFinite(entryPhrase) ? Math.trunc(entryPhrase) : 0;
|
||||
const phrase = Math.min(7, Math.max(0, finitePhrase));
|
||||
const targetSamples = Math.round(
|
||||
phrase * MAIN_PHRASE_BARS * MUSIC_BAR_SECONDS * SOUNDTRACK_SAMPLE_RATE,
|
||||
);
|
||||
return targetSamples / SOUNDTRACK_SAMPLE_RATE;
|
||||
}
|
||||
|
||||
export function nextSafeBarTime(
|
||||
now: number,
|
||||
anchorTime: number,
|
||||
minimumLeadSeconds: number,
|
||||
): number {
|
||||
const earliest = Math.max(anchorTime, now + Math.max(0, minimumLeadSeconds));
|
||||
const elapsedBars = (earliest - anchorTime) / MUSIC_BAR_SECONDS;
|
||||
const oneSampleInBars = 1 / SOUNDTRACK_SAMPLE_RATE / MUSIC_BAR_SECONDS;
|
||||
const nextBar = Math.ceil(elapsedBars - oneSampleInBars);
|
||||
return anchorTime + nextBar * MUSIC_BAR_SECONDS;
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import type { AudioContextLifecycleState, AudioGraph } from '@/audio/types';
|
||||
import { createMusicSequencer } from '@/audio/music-sequencer';
|
||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||
|
||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||
|
||||
type AudioContextFactory = () => AudioContext;
|
||||
|
||||
export function createBrowserSynthEngine(): AudioGraph {
|
||||
if (TEST_BUILD && isTestFault('F10-audio-start-rejection')) {
|
||||
recordTestFaultEvent('audio-start-rejected');
|
||||
throw new DOMException('Injected audio start rejection', 'NotAllowedError');
|
||||
}
|
||||
const AudioContextConstructor = window.AudioContext
|
||||
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextConstructor) throw new Error('Web Audio is unavailable');
|
||||
const graph = createSynthEngine(() => new AudioContextConstructor());
|
||||
if (!TEST_BUILD) return graph;
|
||||
let resumeCount = 0;
|
||||
return {
|
||||
...graph,
|
||||
async resume() {
|
||||
resumeCount += 1;
|
||||
if (isTestFault('F11-audio-resume-rejection') && resumeCount > 1) {
|
||||
recordTestFaultEvent('audio-resume-rejected');
|
||||
throw new DOMException('Injected audio resume rejection', 'NotAllowedError');
|
||||
}
|
||||
if (isTestFault('F12-audio-graph-invalid') && resumeCount > 1) {
|
||||
recordTestFaultEvent('audio-graph-invalid');
|
||||
throw new DOMException('Injected invalid audio graph', 'InvalidStateError');
|
||||
}
|
||||
await graph.resume();
|
||||
},
|
||||
async suspend() {
|
||||
if (isTestFault('F13-audio-suspend-rejection')) {
|
||||
recordTestFaultEvent('audio-suspend-rejected');
|
||||
throw new DOMException('Injected audio suspend rejection', 'InvalidStateError');
|
||||
}
|
||||
await graph.suspend();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSynthEngine(createContext: AudioContextFactory): AudioGraph {
|
||||
const context = createContext();
|
||||
const master = context.createGain();
|
||||
const dynamics = context.createDynamicsCompressor();
|
||||
master.gain.setValueAtTime(0, context.currentTime);
|
||||
dynamics.connect(master);
|
||||
|
||||
const musicFilter = context.createBiquadFilter();
|
||||
musicFilter.type = 'lowpass';
|
||||
musicFilter.frequency.setValueAtTime(900, context.currentTime);
|
||||
musicFilter.Q.setValueAtTime(0.72, context.currentTime);
|
||||
const musicGain = context.createGain();
|
||||
musicGain.gain.setValueAtTime(0.78, context.currentTime);
|
||||
musicFilter.connect(musicGain);
|
||||
musicGain.connect(dynamics);
|
||||
|
||||
const engineFilter = context.createBiquadFilter();
|
||||
engineFilter.type = 'lowpass';
|
||||
engineFilter.frequency.setValueAtTime(260, context.currentTime);
|
||||
engineFilter.Q.setValueAtTime(1.25, context.currentTime);
|
||||
|
||||
const engineGain = context.createGain();
|
||||
engineGain.gain.setValueAtTime(0.09, context.currentTime);
|
||||
engineFilter.connect(engineGain);
|
||||
engineGain.connect(dynamics);
|
||||
|
||||
const fundamental = createOscillator(context, 'sawtooth', 42, 0.16, engineFilter);
|
||||
const harmonic = createOscillator(context, 'sine', 84, 0.055, engineFilter);
|
||||
|
||||
const roadFilter = context.createBiquadFilter();
|
||||
roadFilter.type = 'bandpass';
|
||||
roadFilter.frequency.setValueAtTime(410, context.currentTime);
|
||||
roadFilter.Q.setValueAtTime(0.55, context.currentTime);
|
||||
const roadGain = context.createGain();
|
||||
roadGain.gain.setValueAtTime(0.018, context.currentTime);
|
||||
roadFilter.connect(roadGain);
|
||||
roadGain.connect(dynamics);
|
||||
|
||||
const noise = context.createBufferSource();
|
||||
noise.buffer = createNoiseBuffer(context, 2);
|
||||
noise.loop = true;
|
||||
noise.connect(roadFilter);
|
||||
|
||||
const ignition = context.createOscillator();
|
||||
ignition.type = 'triangle';
|
||||
ignition.frequency.setValueAtTime(68, context.currentTime);
|
||||
const ignitionGain = context.createGain();
|
||||
ignitionGain.gain.setValueAtTime(0.0001, context.currentTime);
|
||||
ignitionGain.gain.exponentialRampToValueAtTime(0.025, context.currentTime + 0.12);
|
||||
ignitionGain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 1.15);
|
||||
ignition.connect(ignitionGain);
|
||||
ignitionGain.connect(engineFilter);
|
||||
|
||||
const scheduledNodes: Array<AudioScheduledSourceNode> = [fundamental.source, harmonic.source, noise, ignition];
|
||||
for (const node of scheduledNodes) node.start();
|
||||
const music = createMusicSequencer(context, musicFilter);
|
||||
music.start(context.currentTime);
|
||||
|
||||
let outputConnected = false;
|
||||
let closed = false;
|
||||
|
||||
return {
|
||||
resume: () => context.resume(),
|
||||
suspend: () => context.suspend(),
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
music.stop();
|
||||
for (const node of scheduledNodes) {
|
||||
try { node.stop(); } catch { /* A source may already have ended. */ }
|
||||
node.disconnect();
|
||||
}
|
||||
if (outputConnected) {
|
||||
master.disconnect(context.destination);
|
||||
outputConnected = false;
|
||||
}
|
||||
fundamental.gain.disconnect();
|
||||
harmonic.gain.disconnect();
|
||||
ignitionGain.disconnect();
|
||||
musicFilter.disconnect();
|
||||
musicGain.disconnect();
|
||||
engineFilter.disconnect();
|
||||
engineGain.disconnect();
|
||||
roadFilter.disconnect();
|
||||
roadGain.disconnect();
|
||||
dynamics.disconnect();
|
||||
master.disconnect();
|
||||
await context.close();
|
||||
},
|
||||
connectOutput() {
|
||||
if (closed || outputConnected) return;
|
||||
master.connect(context.destination);
|
||||
outputConnected = true;
|
||||
},
|
||||
disconnectOutput() {
|
||||
if (!outputConnected) return;
|
||||
master.disconnect(context.destination);
|
||||
outputConnected = false;
|
||||
},
|
||||
setOutputGain(value) {
|
||||
const next = clamp(value);
|
||||
master.gain.cancelScheduledValues(context.currentTime);
|
||||
master.gain.setValueAtTime(next, context.currentTime);
|
||||
},
|
||||
setIntensity(value) {
|
||||
const next = clamp(value);
|
||||
const time = context.currentTime;
|
||||
fundamental.source.frequency.setTargetAtTime(42 + next * 28, time, 0.32);
|
||||
harmonic.source.frequency.setTargetAtTime(84 + next * 56, time, 0.32);
|
||||
engineFilter.frequency.setTargetAtTime(260 + next * 670, time, 0.38);
|
||||
roadFilter.frequency.setTargetAtTime(410 + next * 520, time, 0.42);
|
||||
musicFilter.frequency.setTargetAtTime(900 + next * 2_400, time, 0.42);
|
||||
engineGain.gain.setTargetAtTime(0.09 + next * 0.04, time, 0.4);
|
||||
roadGain.gain.setTargetAtTime(0.018 + next * 0.02, time, 0.45);
|
||||
music.setIntensity(next, time);
|
||||
},
|
||||
onContextStateChange(listener) {
|
||||
const onStateChange = (): void => listener(context.state as AudioContextLifecycleState);
|
||||
context.addEventListener('statechange', onStateChange);
|
||||
return () => context.removeEventListener('statechange', onStateChange);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOscillator(
|
||||
context: AudioContext,
|
||||
type: OscillatorType,
|
||||
frequency: number,
|
||||
level: number,
|
||||
destination: AudioNode,
|
||||
): { source: OscillatorNode; gain: GainNode } {
|
||||
const source = context.createOscillator();
|
||||
source.type = type;
|
||||
source.frequency.setValueAtTime(frequency, context.currentTime);
|
||||
const gain = context.createGain();
|
||||
gain.gain.setValueAtTime(level, context.currentTime);
|
||||
source.connect(gain);
|
||||
gain.connect(destination);
|
||||
return { source, gain };
|
||||
}
|
||||
|
||||
function createNoiseBuffer(context: AudioContext, durationSeconds: number): AudioBuffer {
|
||||
const length = Math.max(1, Math.floor(context.sampleRate * durationSeconds));
|
||||
const buffer = context.createBuffer(1, length, context.sampleRate);
|
||||
const samples = buffer.getChannelData(0);
|
||||
let previous = 0;
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
previous = previous * 0.93 + white * 0.07;
|
||||
samples[index] = previous;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function clamp(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
@@ -8,6 +8,25 @@ export type AudioUiState =
|
||||
|
||||
export type AudioContextLifecycleState = AudioContextState | 'interrupted';
|
||||
|
||||
export type MusicChapterBias = 'open' | 'shadow' | 'lift' | 'resolve';
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
export interface SoundtrackSources {
|
||||
readonly intro: readonly AudioSourceVariant[];
|
||||
readonly main: readonly AudioSourceVariant[];
|
||||
}
|
||||
|
||||
export interface AudioGraph {
|
||||
resume(): Promise<void>;
|
||||
suspend(): Promise<void>;
|
||||
@@ -15,23 +34,18 @@ export interface AudioGraph {
|
||||
connectOutput(): void;
|
||||
disconnectOutput(): void;
|
||||
setOutputGain(value: number): void;
|
||||
setIntensity(value: number): void;
|
||||
setJourneyState(state: AudioJourneyState): void;
|
||||
cancelPending(): void;
|
||||
onContextStateChange(listener: (state: AudioContextLifecycleState) => void): () => void;
|
||||
}
|
||||
|
||||
export interface MusicSequencer {
|
||||
start(atTime: number): void;
|
||||
scheduleThrough(horizonTime: number): void;
|
||||
setIntensity(value: number, atTime: number): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export type AudioGraphFactory = () => AudioGraph;
|
||||
export type AudioGraphFactory = (initialState: AudioJourneyState) => AudioGraph;
|
||||
|
||||
export interface AudioControllerOptions {
|
||||
createGraph: AudioGraphFactory;
|
||||
initialVolume?: number;
|
||||
initiallyVisible?: boolean;
|
||||
initialJourneyState?: AudioJourneyState;
|
||||
}
|
||||
|
||||
export interface AudioController {
|
||||
@@ -39,7 +53,7 @@ export interface AudioController {
|
||||
resumeFromGesture(): Promise<void>;
|
||||
setMuted(muted: boolean): void;
|
||||
setVolume(volume: number): void;
|
||||
setIntensity(intensity: number): void;
|
||||
setJourneyState(state: AudioJourneyState): void;
|
||||
setVisible(visible: boolean): Promise<void>;
|
||||
noteInterruption(): Promise<void>;
|
||||
pageHide(persisted: boolean): Promise<void>;
|
||||
|
||||
@@ -17,14 +17,19 @@ import { useAudioEngine } from '@/composables/useAudioEngine';
|
||||
import { useJourneySections } from '@/composables/useJourneySections';
|
||||
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
||||
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
||||
import type { PortfolioContent, SectionId, SectionLayout } from '@/content/types';
|
||||
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';
|
||||
|
||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||
|
||||
const props = defineProps<{ content: Readonly<PortfolioContent> }>();
|
||||
const props = defineProps<{
|
||||
content: Readonly<PortfolioContent>;
|
||||
manifest: Readonly<PublicAssetManifest>;
|
||||
}>();
|
||||
|
||||
const canonicalSectionIds = [
|
||||
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||
@@ -59,8 +64,44 @@ const journey = useJourneySections(
|
||||
navigation.state,
|
||||
computed(() => motion.policy.value.allowSceneRequest),
|
||||
);
|
||||
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 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' },
|
||||
],
|
||||
};
|
||||
});
|
||||
const audio = useAudioEngine({
|
||||
activeSectionId: audioSectionId,
|
||||
intensity: computed(() => journey.value.acceleration),
|
||||
soundtrack: soundtrackSources,
|
||||
volume: motion.volume,
|
||||
setVolume: motion.setVolume,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
readonly,
|
||||
@@ -7,14 +8,17 @@ import {
|
||||
type Ref,
|
||||
} from 'vue';
|
||||
import { createAudioController } from '@/audio/create-audio-controller';
|
||||
import { createBrowserSynthEngine } from '@/audio/synth-engine';
|
||||
import type { AudioController, AudioUiState } from '@/audio/types';
|
||||
import { createBrowserSoundtrackEngine } from '@/audio/soundtrack-engine';
|
||||
import { resolveJourneyMusicState } from '@/audio/journey-music-state';
|
||||
import type { AudioController, AudioUiState, 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>>;
|
||||
setVolume(value: number): void;
|
||||
}
|
||||
@@ -34,20 +38,28 @@ 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;
|
||||
controller = createAudioController({
|
||||
createGraph: createBrowserSynthEngine,
|
||||
createGraph: (initialState) => {
|
||||
const soundtrack = sources.soundtrack.value;
|
||||
if (soundtrack === null) throw new Error('Soundtrack assets are unavailable.');
|
||||
return createBrowserSoundtrackEngine(soundtrack, initialState);
|
||||
},
|
||||
initialVolume: sources.volume.value,
|
||||
initiallyVisible: document.visibilityState !== 'hidden',
|
||||
initialJourneyState: journeyState.value,
|
||||
});
|
||||
removeSubscription = controller.subscribe((next) => { state.value = next; });
|
||||
controller.setIntensity(sources.intensity.value);
|
||||
return controller;
|
||||
};
|
||||
|
||||
watch(sources.intensity, (value) => controller?.setIntensity(value));
|
||||
watch(journeyState, (state) => controller?.setJourneyState(state), { flush: 'sync' });
|
||||
watch(sources.volume, (value) => controller?.setVolume(value));
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -59,6 +59,11 @@ const navigationItemSchema = z.object({
|
||||
label: visibleString,
|
||||
}).strict();
|
||||
|
||||
const soundtrackAssetSourcesSchema = z.object({
|
||||
opusAssetId: opaqueId,
|
||||
mp3AssetId: opaqueId,
|
||||
}).strict();
|
||||
|
||||
const mediaPresentationBase = {
|
||||
assetId: opaqueId,
|
||||
alt: visibleString,
|
||||
@@ -194,6 +199,10 @@ const portfolioContentSchema = z.object({
|
||||
description: visibleString,
|
||||
socialPreviewAssetId: opaqueId.optional(),
|
||||
}).strict(),
|
||||
soundtrack: z.object({
|
||||
intro: soundtrackAssetSourcesSchema,
|
||||
main: soundtrackAssetSourcesSchema,
|
||||
}).strict().optional(),
|
||||
sceneDescription: visibleString,
|
||||
}).strict().superRefine((content, context) => {
|
||||
const navigationIds = content.navigation.map((item) => item.id);
|
||||
@@ -220,7 +229,11 @@ const portfolioContentSchema = z.object({
|
||||
}
|
||||
});
|
||||
|
||||
const mediaTypes = ['image/avif', 'image/jpeg', 'image/png', 'image/svg+xml', 'image/webp', 'video/mp4', 'video/webm'] as const;
|
||||
const mediaTypes = [
|
||||
'image/avif', 'image/jpeg', 'image/png', 'image/svg+xml', 'image/webp',
|
||||
'video/mp4', 'video/webm',
|
||||
'audio/ogg', 'audio/mpeg',
|
||||
] as const;
|
||||
const fileExtensionByMediaType: Record<(typeof mediaTypes)[number], string> = {
|
||||
'image/avif': 'avif',
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -229,10 +242,12 @@ const fileExtensionByMediaType: Record<(typeof mediaTypes)[number], string> = {
|
||||
'image/webp': 'webp',
|
||||
'video/mp4': 'mp4',
|
||||
'video/webm': 'webm',
|
||||
'audio/ogg': 'opus',
|
||||
'audio/mpeg': 'mp3',
|
||||
};
|
||||
const publicAssetSchema = z.object({
|
||||
id: opaqueId,
|
||||
fileName: z.string().regex(/^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm)$/, 'Asset filenames must be generated safe filenames.'),
|
||||
fileName: z.string().regex(/^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm|opus|mp3)$/, 'Asset filenames must be generated safe filenames.'),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/, 'Asset sha256 must be lowercase hexadecimal.'),
|
||||
mediaType: z.enum(mediaTypes),
|
||||
width: z.number().int().positive().nullable(),
|
||||
@@ -246,12 +261,17 @@ const publicAssetSchema = z.object({
|
||||
context.addIssue({ code: 'custom', message: 'Asset filename extension must match media type.', path: ['fileName'] });
|
||||
}
|
||||
const image = asset.mediaType.startsWith('image/');
|
||||
const video = asset.mediaType.startsWith('video/');
|
||||
const audio = asset.mediaType.startsWith('audio/');
|
||||
if (image && (asset.width === null || asset.height === null || asset.durationMs !== null)) {
|
||||
context.addIssue({ code: 'custom', message: 'Image assets require dimensions and no duration.', path: ['width'] });
|
||||
}
|
||||
if (!image && (asset.width === null || asset.height === null || asset.durationMs === null)) {
|
||||
if (video && (asset.width === null || asset.height === null || asset.durationMs === null)) {
|
||||
context.addIssue({ code: 'custom', message: 'Video assets require dimensions and duration.', path: ['durationMs'] });
|
||||
}
|
||||
if (audio && (asset.width !== null || asset.height !== null || asset.durationMs === null)) {
|
||||
context.addIssue({ code: 'custom', message: 'Audio assets require null dimensions and a positive duration.', path: ['durationMs'] });
|
||||
}
|
||||
});
|
||||
const publicAssetManifestSchema = z.object({ assets: z.array(publicAssetSchema) }).strict().superRefine((manifest, context) => {
|
||||
const ids = manifest.assets.map((asset) => asset.id);
|
||||
@@ -296,6 +316,12 @@ export const parseProductionInputSnapshot = (input: unknown): ProductionInputs =
|
||||
...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) =>
|
||||
technology.assetId === undefined ? [] : [technology.assetId])),
|
||||
...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]),
|
||||
...(content.soundtrack === undefined ? [] : [
|
||||
content.soundtrack.intro.opusAssetId,
|
||||
content.soundtrack.intro.mp3AssetId,
|
||||
content.soundtrack.main.opusAssetId,
|
||||
content.soundtrack.main.mp3AssetId,
|
||||
]),
|
||||
];
|
||||
const seenReferences = new Set<string>();
|
||||
for (const assetId of referencedAssetIds) {
|
||||
|
||||
@@ -133,6 +133,16 @@ export interface MetadataContent {
|
||||
socialPreviewAssetId?: string;
|
||||
}
|
||||
|
||||
export interface SoundtrackAssetSources {
|
||||
opusAssetId: string;
|
||||
mp3AssetId: string;
|
||||
}
|
||||
|
||||
export interface SoundtrackContent {
|
||||
intro: SoundtrackAssetSources;
|
||||
main: SoundtrackAssetSources;
|
||||
}
|
||||
|
||||
export interface PortfolioContent {
|
||||
identity: {
|
||||
name: 'Luca Pîslaru';
|
||||
@@ -148,6 +158,7 @@ export interface PortfolioContent {
|
||||
toolkit: readonly ToolkitGroup[];
|
||||
contact: ContactContent;
|
||||
metadata: MetadataContent;
|
||||
soundtrack?: SoundtrackContent;
|
||||
sceneDescription: string;
|
||||
}
|
||||
|
||||
@@ -158,7 +169,9 @@ export type PublicAssetMediaType =
|
||||
| 'image/svg+xml'
|
||||
| 'image/webp'
|
||||
| 'video/mp4'
|
||||
| 'video/webm';
|
||||
| 'video/webm'
|
||||
| 'audio/ogg'
|
||||
| 'audio/mpeg';
|
||||
|
||||
export interface PublicAsset {
|
||||
id: string;
|
||||
|
||||
@@ -31,7 +31,7 @@ export const buildContentSecurityPolicy = (jsonLdCspHash: string): string => {
|
||||
"img-src 'self' data:",
|
||||
"media-src 'self'",
|
||||
"font-src 'self'",
|
||||
"connect-src 'none'",
|
||||
"connect-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
|
||||
Reference in New Issue
Block a user