feat: coordinate adaptive audio intent
This commit is contained in:
275
src/audio/audio-intent-coordinator.ts
Normal file
275
src/audio/audio-intent-coordinator.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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({
|
const JOURNEY_MUSIC = Object.freeze({
|
||||||
start: { bias: 'open', entryPhrase: 0 },
|
start: { mood: 'open', entryPhrase: 0 },
|
||||||
profile: { bias: 'open', entryPhrase: 1 },
|
profile: { mood: 'open', entryPhrase: 1 },
|
||||||
experience: { bias: 'shadow', entryPhrase: 2 },
|
experience: { mood: 'shadow', entryPhrase: 2 },
|
||||||
infrastructure: { bias: 'shadow', entryPhrase: 3 },
|
infrastructure: { mood: 'shadow', entryPhrase: 3 },
|
||||||
'selected-builds': { bias: 'lift', entryPhrase: 4 },
|
'selected-builds': { mood: 'lift', entryPhrase: 4 },
|
||||||
'additional-work': { bias: 'lift', entryPhrase: 5 },
|
'additional-work': { mood: 'lift', entryPhrase: 5 },
|
||||||
toolkit: { bias: 'resolve', entryPhrase: 6 },
|
toolkit: { mood: 'resolve', entryPhrase: 6 },
|
||||||
contact: { bias: 'resolve', entryPhrase: 7 },
|
contact: { mood: 'resolve', entryPhrase: 7 },
|
||||||
} as const);
|
} 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 {
|
export function resolveJourneyMusicState(sectionId: string, intensity = 0): AudioJourneyState {
|
||||||
const chapter = Object.hasOwn(JOURNEY_MUSIC, sectionId)
|
const canonicalId = Object.hasOwn(JOURNEY_MUSIC, sectionId)
|
||||||
? JOURNEY_MUSIC[sectionId as keyof typeof JOURNEY_MUSIC]
|
? sectionId as SectionId
|
||||||
: JOURNEY_MUSIC.start;
|
: 'start';
|
||||||
|
const chapter = resolveChapterIntent(canonicalId);
|
||||||
const finiteIntensity = Number.isFinite(intensity) ? intensity : 0;
|
const finiteIntensity = Number.isFinite(intensity) ? intensity : 0;
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...chapter,
|
|
||||||
atStart: sectionId === 'start',
|
atStart: sectionId === 'start',
|
||||||
intensity: Math.min(1, Math.max(0, finiteIntensity)),
|
intensity: Math.min(1, Math.max(0, finiteIntensity)),
|
||||||
|
bias: chapter.mood,
|
||||||
|
entryPhrase: chapter.entryPhrase,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { SectionId } from '@/content/types';
|
||||||
|
|
||||||
export type AudioUiState =
|
export type AudioUiState =
|
||||||
| { kind: 'idle' }
|
| { kind: 'idle' }
|
||||||
| { kind: 'starting' }
|
| { kind: 'starting' }
|
||||||
@@ -8,7 +10,27 @@ export type AudioUiState =
|
|||||||
|
|
||||||
export type AudioContextLifecycleState = AudioContextState | 'interrupted';
|
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 {
|
export interface AudioJourneyState {
|
||||||
readonly atStart: boolean;
|
readonly atStart: boolean;
|
||||||
|
|||||||
105
src/composables/useAudioIntent.ts
Normal file
105
src/composables/useAudioIntent.ts
Normal 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(); };
|
||||||
|
}
|
||||||
298
tests/unit/audio-intent-coordinator.test.ts
Normal file
298
tests/unit/audio-intent-coordinator.test.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
createAudioIntentCoordinator,
|
||||||
|
type AudioIntentSample,
|
||||||
|
} from '@/audio/audio-intent-coordinator';
|
||||||
|
import type { SectionId } from '@/content/types';
|
||||||
|
|
||||||
|
const sectionIds = [
|
||||||
|
'start', 'profile', 'experience', 'infrastructure',
|
||||||
|
'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||||
|
] as const satisfies readonly SectionId[];
|
||||||
|
|
||||||
|
const baseSample = (
|
||||||
|
overrides: Partial<AudioIntentSample> = {},
|
||||||
|
): AudioIntentSample => ({
|
||||||
|
now: 0,
|
||||||
|
visible: true,
|
||||||
|
scrollY: 0,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
acceleration: 0,
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 1,
|
||||||
|
activeHeadingOffsetVh: -0.1,
|
||||||
|
nextHeadingOffsetVh: 0.8,
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
epoch: 1,
|
||||||
|
source: 'passive-scroll',
|
||||||
|
phase: 'idle',
|
||||||
|
destinationId: null,
|
||||||
|
signDestinationId: null,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('audio intent coordinator', () => {
|
||||||
|
it('commits once only after 6vh hysteresis and 400 ms dwell', () => {
|
||||||
|
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 10,
|
||||||
|
scrollY: 10,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 1,
|
||||||
|
activeHeadingOffsetVh: -0.059,
|
||||||
|
nextHeadingOffsetVh: 0.8,
|
||||||
|
},
|
||||||
|
}))).toMatchObject({ chapter: null, needsFrame: false });
|
||||||
|
|
||||||
|
expect(coordinator.sample(baseSample({ now: 100, scrollY: 10, activeSectionId: 'profile' })))
|
||||||
|
.toMatchObject({ chapter: null, needsFrame: true });
|
||||||
|
expect(coordinator.sample(baseSample({ now: 499, scrollY: 10, activeSectionId: 'profile' })))
|
||||||
|
.toMatchObject({ chapter: null });
|
||||||
|
expect(coordinator.sample(baseSample({ now: 500, scrollY: 10, activeSectionId: 'profile' })).chapter)
|
||||||
|
.toMatchObject({ sectionId: 'profile', mood: 'open', generation: 1 });
|
||||||
|
expect(coordinator.sample(baseSample({ now: 900, scrollY: 10, activeSectionId: 'profile' })).chapter)
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a passive candidate on reversal or structural remeasurement', () => {
|
||||||
|
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||||
|
coordinator.sample(baseSample({ now: 0, scrollY: 10, activeSectionId: 'profile' }));
|
||||||
|
expect(coordinator.sample(baseSample({ now: 250, scrollY: 9, activeSectionId: 'start' })).needsFrame)
|
||||||
|
.toBe(false);
|
||||||
|
coordinator.sample(baseSample({ now: 300, scrollY: 12, activeSectionId: 'profile' }));
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 800,
|
||||||
|
scrollY: 12,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 2,
|
||||||
|
activeHeadingOffsetVh: -0.2,
|
||||||
|
nextHeadingOffsetVh: 0.7,
|
||||||
|
},
|
||||||
|
}))).toMatchObject({ chapter: null, needsFrame: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits only the latest settled explicit destination', () => {
|
||||||
|
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||||
|
coordinator.sample(baseSample({
|
||||||
|
navigation: {
|
||||||
|
epoch: 2,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
phase: 'travelling',
|
||||||
|
destinationId: 'contact',
|
||||||
|
signDestinationId: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
coordinator.sample(baseSample({
|
||||||
|
activeSectionId: 'toolkit',
|
||||||
|
navigation: {
|
||||||
|
epoch: 3,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
phase: 'travelling',
|
||||||
|
destinationId: 'toolkit',
|
||||||
|
signDestinationId: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 600,
|
||||||
|
activeSectionId: 'toolkit',
|
||||||
|
navigation: {
|
||||||
|
epoch: 3,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
phase: 'settled',
|
||||||
|
destinationId: 'toolkit',
|
||||||
|
signDestinationId: null,
|
||||||
|
},
|
||||||
|
}))).toMatchObject({
|
||||||
|
chapter: { sectionId: 'toolkit', mood: 'resolve', generation: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dead-bands tiny input and caps programmatic travel below lift entry', () => {
|
||||||
|
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||||
|
expect(coordinator.sample(baseSample({ now: 0, acceleration: 0.07 })).drive).toBeNull();
|
||||||
|
expect(coordinator.sample(baseSample({ now: 40, acceleration: 0.7 })).drive)
|
||||||
|
.toEqual({ energy: expect.closeTo((0.7 - 0.08) / 0.92), source: 'physical' });
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 80,
|
||||||
|
acceleration: 1,
|
||||||
|
navigation: {
|
||||||
|
epoch: 2,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
phase: 'travelling',
|
||||||
|
destinationId: 'contact',
|
||||||
|
signDestinationId: null,
|
||||||
|
},
|
||||||
|
})).drive).toEqual({ energy: 0.35, source: 'navigation' });
|
||||||
|
expect(coordinator.sample(baseSample({ now: 120, acceleration: 0 })).drive)
|
||||||
|
.toEqual({ energy: 0, source: 'idle' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rate-limits small drive changes but emits exact threshold, source, and zero crossings', () => {
|
||||||
|
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||||
|
const physicalAcceleration = (energy: number): number => 0.08 + energy * 0.92;
|
||||||
|
|
||||||
|
expect(coordinator.sample(baseSample({ now: 0, acceleration: physicalAcceleration(0.3) })).drive)
|
||||||
|
.toEqual({ energy: expect.closeTo(0.3), source: 'physical' });
|
||||||
|
expect(coordinator.sample(baseSample({ now: 10, acceleration: physicalAcceleration(0.31) })).drive)
|
||||||
|
.toBeNull();
|
||||||
|
expect(coordinator.sample(baseSample({ now: 11, acceleration: physicalAcceleration(0.32) })).drive)
|
||||||
|
.toEqual({ energy: expect.closeTo(0.32), source: 'physical' });
|
||||||
|
expect(coordinator.sample(baseSample({ now: 12, acceleration: physicalAcceleration(0.62) })).drive)
|
||||||
|
.toEqual({ energy: expect.closeTo(0.62), source: 'physical' });
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 13,
|
||||||
|
acceleration: 0.2,
|
||||||
|
navigation: {
|
||||||
|
epoch: 2,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
phase: 'travelling',
|
||||||
|
destinationId: 'contact',
|
||||||
|
signDestinationId: null,
|
||||||
|
},
|
||||||
|
})).drive).toEqual({ energy: 0.2, source: 'navigation' });
|
||||||
|
expect(coordinator.sample(baseSample({ now: 14, acceleration: 0 })).drive)
|
||||||
|
.toEqual({ energy: 0, source: 'idle' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot turn a layout-only active-section shift into a passive commit', () => {
|
||||||
|
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 100,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 2,
|
||||||
|
activeHeadingOffsetVh: -0.2,
|
||||||
|
nextHeadingOffsetVh: 0.7,
|
||||||
|
},
|
||||||
|
}))).toMatchObject({ chapter: null, needsFrame: false });
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 600,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 2,
|
||||||
|
activeHeadingOffsetVh: -0.2,
|
||||||
|
nextHeadingOffsetVh: 0.7,
|
||||||
|
},
|
||||||
|
})).chapter).toBeNull();
|
||||||
|
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 650,
|
||||||
|
scrollY: 2,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 2,
|
||||||
|
activeHeadingOffsetVh: -0.2,
|
||||||
|
nextHeadingOffsetVh: 0.7,
|
||||||
|
},
|
||||||
|
})).needsFrame).toBe(true);
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 1_050,
|
||||||
|
scrollY: 2,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 2,
|
||||||
|
activeHeadingOffsetVh: -0.2,
|
||||||
|
nextHeadingOffsetVh: 0.7,
|
||||||
|
},
|
||||||
|
})).chapter).toMatchObject({ sectionId: 'profile' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers a genuine settlement across remeasurement but ignores old settled state', () => {
|
||||||
|
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||||
|
coordinator.sample(baseSample({
|
||||||
|
navigation: {
|
||||||
|
epoch: 2,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
phase: 'travelling',
|
||||||
|
destinationId: 'contact',
|
||||||
|
signDestinationId: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 100,
|
||||||
|
activeSectionId: 'contact',
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 2,
|
||||||
|
activeHeadingOffsetVh: -0.2,
|
||||||
|
nextHeadingOffsetVh: null,
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
epoch: 2,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
phase: 'settled',
|
||||||
|
destinationId: 'contact',
|
||||||
|
signDestinationId: null,
|
||||||
|
},
|
||||||
|
}))).toMatchObject({ chapter: null, needsFrame: true });
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 116,
|
||||||
|
activeSectionId: 'contact',
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 2,
|
||||||
|
activeHeadingOffsetVh: -0.2,
|
||||||
|
nextHeadingOffsetVh: null,
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
epoch: 2,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
phase: 'settled',
|
||||||
|
destinationId: 'contact',
|
||||||
|
signDestinationId: null,
|
||||||
|
},
|
||||||
|
})).chapter).toMatchObject({ sectionId: 'contact' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts one final dwell after wheel events advance passive epochs', () => {
|
||||||
|
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||||
|
const passive = (epoch: number) => ({
|
||||||
|
epoch,
|
||||||
|
source: 'passive-scroll' as const,
|
||||||
|
phase: 'idle' as const,
|
||||||
|
destinationId: null,
|
||||||
|
signDestinationId: null,
|
||||||
|
});
|
||||||
|
coordinator.sample(baseSample({
|
||||||
|
now: 100,
|
||||||
|
scrollY: 24,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
navigation: passive(2),
|
||||||
|
}));
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 180,
|
||||||
|
scrollY: 48,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
navigation: passive(3),
|
||||||
|
})).needsFrame).toBe(true);
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 580,
|
||||||
|
scrollY: 48,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
navigation: passive(3),
|
||||||
|
})).chapter).toMatchObject({ sectionId: 'profile' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses a navigation that settled while hidden', () => {
|
||||||
|
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
|
||||||
|
const settled = {
|
||||||
|
epoch: 2,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
phase: 'settled',
|
||||||
|
destinationId: 'contact',
|
||||||
|
signDestinationId: null,
|
||||||
|
} as const;
|
||||||
|
coordinator.reset({
|
||||||
|
navigation: settled,
|
||||||
|
scrollY: 0,
|
||||||
|
sectionActivation: baseSample().sectionActivation,
|
||||||
|
});
|
||||||
|
expect(coordinator.sample(baseSample({
|
||||||
|
now: 1_000,
|
||||||
|
activeSectionId: 'contact',
|
||||||
|
navigation: settled,
|
||||||
|
})).chapter).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
66
tests/unit/use-audio-intent.test.ts
Normal file
66
tests/unit/use-audio-intent.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { defineComponent, h, readonly, shallowRef } from 'vue';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { resolveChapterIntent } from '@/audio/journey-music-state';
|
||||||
|
import {
|
||||||
|
useAudioIntent,
|
||||||
|
type AudioIntentBinding,
|
||||||
|
} from '@/composables/useAudioIntent';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const sectionIds = [
|
||||||
|
'start', 'profile', 'experience', 'infrastructure',
|
||||||
|
'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||||
|
] as const satisfies readonly SectionId[];
|
||||||
|
|
||||||
|
describe('useAudioIntent', () => {
|
||||||
|
it('seeds the first measured deep-link section before audio activation', () => {
|
||||||
|
const snapshot = shallowRef<JourneySnapshot>({
|
||||||
|
scrollY: 7_200,
|
||||||
|
routeProgress: 1,
|
||||||
|
direction: 0,
|
||||||
|
acceleration: 0,
|
||||||
|
driveSpeed: 0.18,
|
||||||
|
activeSectionId: 'contact',
|
||||||
|
chapter: 'destination',
|
||||||
|
sign: null,
|
||||||
|
arrived: true,
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 4,
|
||||||
|
activeHeadingOffsetVh: -0.2,
|
||||||
|
nextHeadingOffsetVh: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const navigation = shallowRef<NavigationState>({
|
||||||
|
epoch: 1,
|
||||||
|
source: 'initial',
|
||||||
|
phase: 'settled',
|
||||||
|
destinationId: 'contact',
|
||||||
|
signDestinationId: null,
|
||||||
|
});
|
||||||
|
let frame: ((now: number, value: JourneySnapshot) => boolean) | null = null;
|
||||||
|
const journey: JourneySectionsBinding = {
|
||||||
|
snapshot: readonly(snapshot),
|
||||||
|
requestFrame: vi.fn(),
|
||||||
|
subscribeFrame(listener) {
|
||||||
|
frame = listener;
|
||||||
|
return () => { frame = null; };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let binding: AudioIntentBinding | null = null;
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
setup() {
|
||||||
|
binding = useAudioIntent({ canonicalIds: sectionIds, journey, navigation });
|
||||||
|
return () => h('div');
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(frame).not.toBeNull();
|
||||||
|
frame!(16, snapshot.value);
|
||||||
|
expect(binding!.chapter.value).toEqual(resolveChapterIntent('contact'));
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user