feat: coordinate adaptive audio intent

This commit is contained in:
2026-09-04 15:34:32 +03:00
parent 7632c8b9c0
commit 5250a3174c
6 changed files with 812 additions and 15 deletions

View File

@@ -0,0 +1,275 @@
import {
DEFAULT_DRIVE_ENERGY,
resolveChapterIntent,
} from '@/audio/journey-music-state';
import type {
AudioInitialState,
ChapterIntent,
DriveEnergySample,
} from '@/audio/types';
import type { SectionId } from '@/content/types';
import type {
NavigationPhase,
NavigationState,
} from '@/journey/navigation-controller';
import type { SectionActivationSnapshot } from '@/journey/types';
const PASSIVE_HYSTERESIS_VH = 0.06;
const PASSIVE_DWELL_MS = 400;
const DRIVE_DEAD_ZONE = 0.08;
const NAVIGATION_DRIVE_CAP = 0.35;
const DRIVE_EMIT_INTERVAL_MS = 33;
const DRIVE_EMIT_DELTA = 0.02;
const DRIVE_LIFT_EXIT = 0.32;
const DRIVE_LIFT_ENTRY = 0.62;
export interface AudioIntentSample {
readonly now: number;
readonly visible: boolean;
readonly scrollY: number;
readonly activeSectionId: SectionId;
readonly acceleration: number;
readonly sectionActivation: SectionActivationSnapshot;
readonly navigation: NavigationState;
}
export interface AudioIntentUpdate {
readonly chapter: ChapterIntent | null;
readonly drive: DriveEnergySample | null;
readonly needsFrame: boolean;
}
export interface AudioIntentCoordinator {
current(): AudioInitialState;
sample(input: AudioIntentSample): AudioIntentUpdate;
reset(input: Pick<
AudioIntentSample,
'navigation' | 'scrollY' | 'sectionActivation'
>): AudioIntentUpdate;
}
export function createAudioIntentCoordinator(
canonicalIds: readonly SectionId[],
initialSectionId: SectionId,
initialLayoutRevision: number,
initialScrollY: number,
): AudioIntentCoordinator {
const indexById = new Map(canonicalIds.map((id, index) => [id, index]));
if (!indexById.has(initialSectionId)) {
throw new Error(`Unknown initial audio section: ${initialSectionId}`);
}
let committedId = initialSectionId;
let generation = 0;
let pendingId: SectionId | null = null;
let pendingSince = 0;
let layoutRevision = initialLayoutRevision;
let scrollYAtLayout = initialScrollY;
let movedSinceLayout = false;
let navigationEpoch = -1;
let navigationPhase: NavigationPhase = 'idle';
let suppressedSettledEpoch = -1;
let deferredSettlement: {
readonly epoch: number;
readonly destinationId: SectionId;
readonly layoutRevision: number;
} | null = null;
let drive: DriveEnergySample = DEFAULT_DRIVE_ENERGY;
let driveEmittedAt = Number.NEGATIVE_INFINITY;
const clearCandidate = (): void => {
pendingId = null;
pendingSince = 0;
};
const commit = (id: SectionId): ChapterIntent | null => {
if (id === committedId) return null;
committedId = id;
generation += 1;
clearCandidate();
return resolveChapterIntent(id, generation);
};
const current = (): AudioInitialState => ({
chapter: resolveChapterIntent(committedId, generation),
drive,
});
const classifyDrive = (input: AudioIntentSample): DriveEnergySample => {
const acceleration = Math.min(1, Math.max(0, input.acceleration));
if (!input.visible) return DEFAULT_DRIVE_ENERGY;
if (input.navigation.phase === 'travelling') {
return acceleration === 0
? DEFAULT_DRIVE_ENERGY
: { energy: Math.min(NAVIGATION_DRIVE_CAP, acceleration), source: 'navigation' };
}
if (input.navigation.phase !== 'idle' || acceleration <= DRIVE_DEAD_ZONE) {
return DEFAULT_DRIVE_ENERGY;
}
return {
energy: (acceleration - DRIVE_DEAD_ZONE) / (1 - DRIVE_DEAD_ZONE),
source: 'physical',
};
};
const crossed = (previous: number, next: number, threshold: number): boolean =>
(previous < threshold && next >= threshold) ||
(previous >= threshold && next < threshold);
const emitDrive = (
next: DriveEnergySample,
now: number,
): DriveEnergySample | null => {
const urgent = next.source !== drive.source ||
(next.energy === 0) !== (drive.energy === 0) ||
crossed(drive.energy, next.energy, DRIVE_LIFT_EXIT) ||
crossed(drive.energy, next.energy, DRIVE_LIFT_ENTRY);
const due = now - driveEmittedAt >= DRIVE_EMIT_INTERVAL_MS &&
Math.abs(next.energy - drive.energy) >= DRIVE_EMIT_DELTA;
if (!urgent && !due) return null;
drive = Object.freeze(next);
driveEmittedAt = now;
return drive;
};
const suppressCurrentPosition = (input: Pick<
AudioIntentSample,
'navigation' | 'scrollY' | 'sectionActivation'
>): void => {
clearCandidate();
deferredSettlement = null;
layoutRevision = input.sectionActivation.layoutRevision;
scrollYAtLayout = input.scrollY;
movedSinceLayout = false;
navigationEpoch = input.navigation.epoch;
navigationPhase = input.navigation.phase;
suppressedSettledEpoch = input.navigation.epoch;
};
const sample = (input: AudioIntentSample): AudioIntentUpdate => {
let chapter: ChapterIntent | null = null;
const nextDrive = emitDrive(classifyDrive(input), input.now);
if (!input.visible) {
suppressCurrentPosition(input);
return { chapter: null, drive: nextDrive, needsFrame: false };
}
const layoutChanged = input.sectionActivation.layoutRevision !== layoutRevision;
if (layoutChanged) {
layoutRevision = input.sectionActivation.layoutRevision;
scrollYAtLayout = input.scrollY;
movedSinceLayout = false;
clearCandidate();
deferredSettlement = null;
}
if (Math.abs(input.scrollY - scrollYAtLayout) >= 1) movedSinceLayout = true;
const navigationChanged = input.navigation.epoch !== navigationEpoch;
const settledEdge = input.navigation.phase === 'settled' &&
(navigationChanged || navigationPhase !== 'settled');
if (navigationChanged) {
navigationEpoch = input.navigation.epoch;
clearCandidate();
deferredSettlement = null;
}
navigationPhase = input.navigation.phase;
if (input.navigation.phase === 'travelling') {
return { chapter: null, drive: nextDrive, needsFrame: false };
}
const destination = input.navigation.destinationId;
if (layoutChanged) {
if (
settledEdge &&
input.navigation.epoch !== suppressedSettledEpoch &&
destination !== null
) {
deferredSettlement = {
epoch: input.navigation.epoch,
destinationId: destination,
layoutRevision,
};
return { chapter: null, drive: nextDrive, needsFrame: true };
}
return { chapter: null, drive: nextDrive, needsFrame: false };
}
const deferred = deferredSettlement;
if (deferred !== null) {
deferredSettlement = null;
const stillValid = input.navigation.phase === 'settled' &&
input.navigation.epoch === deferred.epoch &&
input.navigation.epoch !== suppressedSettledEpoch &&
input.navigation.destinationId === deferred.destinationId &&
input.sectionActivation.layoutRevision === deferred.layoutRevision &&
input.activeSectionId === deferred.destinationId;
chapter = stillValid ? commit(deferred.destinationId) : null;
return { chapter, drive: nextDrive, needsFrame: false };
}
if (input.navigation.phase === 'settled') {
if (
settledEdge &&
input.navigation.epoch !== suppressedSettledEpoch &&
destination !== null &&
destination === input.activeSectionId
) {
chapter = commit(destination);
} else if (
settledEdge &&
input.navigation.epoch !== suppressedSettledEpoch &&
destination !== null
) {
deferredSettlement = {
epoch: input.navigation.epoch,
destinationId: destination,
layoutRevision,
};
return { chapter: null, drive: nextDrive, needsFrame: true };
}
return { chapter, drive: nextDrive, needsFrame: false };
}
if (!movedSinceLayout || input.activeSectionId === committedId) {
clearCandidate();
return { chapter: null, drive: nextDrive, needsFrame: false };
}
const committedIndex = indexById.get(committedId);
const candidateIndex = indexById.get(input.activeSectionId);
if (committedIndex === undefined || candidateIndex === undefined) {
throw new Error('Audio intent received a non-canonical section.');
}
const eligible = candidateIndex > committedIndex
? input.sectionActivation.activeHeadingOffsetVh <= -PASSIVE_HYSTERESIS_VH
: input.sectionActivation.nextHeadingOffsetVh !== null &&
input.sectionActivation.nextHeadingOffsetVh >= PASSIVE_HYSTERESIS_VH;
if (!eligible) {
clearCandidate();
return { chapter: null, drive: nextDrive, needsFrame: false };
}
if (pendingId !== input.activeSectionId) {
pendingId = input.activeSectionId;
pendingSince = input.now;
return { chapter: null, drive: nextDrive, needsFrame: true };
}
if (input.now - pendingSince < PASSIVE_DWELL_MS) {
return { chapter: null, drive: nextDrive, needsFrame: true };
}
chapter = commit(input.activeSectionId);
return { chapter, drive: nextDrive, needsFrame: false };
};
return {
current,
sample,
reset(input) {
suppressCurrentPosition(input);
const nextDrive = drive.energy === 0 && drive.source === 'idle'
? null
: DEFAULT_DRIVE_ENERGY;
drive = DEFAULT_DRIVE_ENERGY;
driveEmittedAt = Number.NEGATIVE_INFINITY;
return { chapter: null, drive: nextDrive, needsFrame: false };
},
};
}

View File

@@ -1,25 +1,56 @@
import type { AudioJourneyState } from '@/audio/types';
import type { SectionId } from '@/content/types';
import type {
AudioJourneyState,
ChapterIntent,
DriveEnergySample,
MusicMood,
} 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);
start: { mood: 'open', entryPhrase: 0 },
profile: { mood: 'open', entryPhrase: 1 },
experience: { mood: 'shadow', entryPhrase: 2 },
infrastructure: { mood: 'shadow', entryPhrase: 3 },
'selected-builds': { mood: 'lift', entryPhrase: 4 },
'additional-work': { mood: 'lift', entryPhrase: 5 },
toolkit: { mood: 'resolve', entryPhrase: 6 },
contact: { mood: 'resolve', entryPhrase: 7 },
} as const satisfies Readonly<Record<
SectionId,
{ readonly mood: MusicMood; readonly entryPhrase: number }
>>);
export function resolveChapterIntent(
sectionId: SectionId,
generation = 0,
): ChapterIntent {
const chapter = JOURNEY_MUSIC[sectionId];
return Object.freeze({
sectionId,
mood: chapter.mood,
atStart: sectionId === 'start',
entryPhrase: chapter.entryPhrase,
generation: Math.max(0, Math.trunc(generation)),
});
}
export const DEFAULT_CHAPTER_INTENT = resolveChapterIntent('start');
export const DEFAULT_DRIVE_ENERGY = Object.freeze({
energy: 0,
source: 'idle',
} as const satisfies DriveEnergySample);
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 canonicalId = Object.hasOwn(JOURNEY_MUSIC, sectionId)
? sectionId as SectionId
: 'start';
const chapter = resolveChapterIntent(canonicalId);
const finiteIntensity = Number.isFinite(intensity) ? intensity : 0;
return Object.freeze({
...chapter,
atStart: sectionId === 'start',
intensity: Math.min(1, Math.max(0, finiteIntensity)),
bias: chapter.mood,
entryPhrase: chapter.entryPhrase,
});
}

View File

@@ -1,3 +1,5 @@
import type { SectionId } from '@/content/types';
export type AudioUiState =
| { kind: 'idle' }
| { kind: 'starting' }
@@ -8,7 +10,27 @@ export type AudioUiState =
export type AudioContextLifecycleState = AudioContextState | 'interrupted';
export type MusicChapterBias = 'open' | 'shadow' | 'lift' | 'resolve';
export type MusicMood = 'open' | 'shadow' | 'lift' | 'resolve';
export type MusicChapterBias = MusicMood;
export type DriveEnergySource = 'physical' | 'navigation' | 'idle';
export interface ChapterIntent {
readonly sectionId: SectionId;
readonly mood: MusicMood;
readonly atStart: boolean;
readonly entryPhrase: number;
readonly generation: number;
}
export interface DriveEnergySample {
readonly energy: number;
readonly source: DriveEnergySource;
}
export interface AudioInitialState {
readonly chapter: ChapterIntent;
readonly drive: DriveEnergySample;
}
export interface AudioJourneyState {
readonly atStart: boolean;

View File

@@ -0,0 +1,105 @@
import {
onMounted,
onUnmounted,
readonly,
shallowRef,
watch,
type Ref,
} from 'vue';
import {
createAudioIntentCoordinator,
type AudioIntentCoordinator,
type AudioIntentUpdate,
} from '@/audio/audio-intent-coordinator';
import {
DEFAULT_DRIVE_ENERGY,
resolveChapterIntent,
} from '@/audio/journey-music-state';
import type { ChapterIntent, DriveEnergySample } from '@/audio/types';
import type { JourneySectionsBinding } from '@/composables/useJourneySections';
import type { SectionId } from '@/content/types';
import type { NavigationState } from '@/journey/navigation-controller';
import type { JourneySnapshot } from '@/journey/types';
export interface AudioIntentSources {
readonly canonicalIds: readonly SectionId[];
readonly journey: JourneySectionsBinding;
readonly navigation: Readonly<Ref<NavigationState>>;
}
export interface AudioIntentBinding {
readonly chapter: Readonly<Ref<ChapterIntent>>;
readonly drive: Readonly<Ref<DriveEnergySample>>;
}
export function useAudioIntent(sources: AudioIntentSources): AudioIntentBinding {
const firstId = sources.canonicalIds[0];
if (firstId === undefined) throw new Error('Audio intent requires one section.');
const chapter = shallowRef(resolveChapterIntent(firstId));
const drive = shallowRef<DriveEnergySample>(DEFAULT_DRIVE_ENERGY);
let coordinator: AudioIntentCoordinator | null = null;
let removeFrame = (): void => {};
const publish = (update: AudioIntentUpdate): boolean => {
if (update.chapter !== null) chapter.value = update.chapter;
if (update.drive !== null) drive.value = update.drive;
return update.needsFrame;
};
const sample = (now: number, snapshot: JourneySnapshot): boolean => {
if (coordinator === null) {
coordinator = createAudioIntentCoordinator(
sources.canonicalIds,
snapshot.activeSectionId,
snapshot.sectionActivation.layoutRevision,
snapshot.scrollY,
);
const seeded = coordinator.current();
chapter.value = seeded.chapter;
drive.value = seeded.drive;
}
return publish(coordinator.sample({
now,
visible: document.visibilityState !== 'hidden',
scrollY: snapshot.scrollY,
activeSectionId: snapshot.activeSectionId,
acceleration: snapshot.acceleration,
sectionActivation: snapshot.sectionActivation,
navigation: sources.navigation.value,
}));
};
const stopNavigationWatch = watch(
sources.navigation,
() => sources.journey.requestFrame(),
{ flush: 'sync' },
);
onMounted(() => {
removeFrame = sources.journey.subscribeFrame(sample);
const resetFromCurrentSnapshot = (): void => {
if (coordinator === null) return;
const snapshot = sources.journey.snapshot.value;
publish(coordinator.reset({
navigation: sources.navigation.value,
scrollY: snapshot.scrollY,
sectionActivation: snapshot.sectionActivation,
}));
};
const onVisibility = (): void => {
resetFromCurrentSnapshot();
if (document.visibilityState !== 'hidden') sources.journey.requestFrame();
};
document.addEventListener('visibilitychange', onVisibility);
removeFrame = combineCleanup(removeFrame, () =>
document.removeEventListener('visibilitychange', onVisibility));
sources.journey.requestFrame();
});
onUnmounted(() => {
stopNavigationWatch();
removeFrame();
});
return { chapter: readonly(chapter), drive: readonly(drive) };
}
function combineCleanup(left: () => void, right: () => void): () => void {
return () => { left(); right(); };
}