From 5250a3174c638510ae34e2287578268d05afd75d Mon Sep 17 00:00:00 2001 From: LucaP Date: Fri, 4 Sep 2026 15:34:32 +0300 Subject: [PATCH] feat: coordinate adaptive audio intent --- src/audio/audio-intent-coordinator.ts | 275 ++++++++++++++++++ src/audio/journey-music-state.ts | 59 +++- src/audio/types.ts | 24 +- src/composables/useAudioIntent.ts | 105 +++++++ tests/unit/audio-intent-coordinator.test.ts | 298 ++++++++++++++++++++ tests/unit/use-audio-intent.test.ts | 66 +++++ 6 files changed, 812 insertions(+), 15 deletions(-) create mode 100644 src/audio/audio-intent-coordinator.ts create mode 100644 src/composables/useAudioIntent.ts create mode 100644 tests/unit/audio-intent-coordinator.test.ts create mode 100644 tests/unit/use-audio-intent.test.ts diff --git a/src/audio/audio-intent-coordinator.ts b/src/audio/audio-intent-coordinator.ts new file mode 100644 index 0000000..84f7c50 --- /dev/null +++ b/src/audio/audio-intent-coordinator.ts @@ -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 }; + }, + }; +} diff --git a/src/audio/journey-music-state.ts b/src/audio/journey-music-state.ts index 95e4c94..1ab1acc 100644 --- a/src/audio/journey-music-state.ts +++ b/src/audio/journey-music-state.ts @@ -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>); + +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, }); } diff --git a/src/audio/types.ts b/src/audio/types.ts index 9309c35..50cd0d1 100644 --- a/src/audio/types.ts +++ b/src/audio/types.ts @@ -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; diff --git a/src/composables/useAudioIntent.ts b/src/composables/useAudioIntent.ts new file mode 100644 index 0000000..42d59dc --- /dev/null +++ b/src/composables/useAudioIntent.ts @@ -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>; +} + +export interface AudioIntentBinding { + readonly chapter: Readonly>; + readonly drive: Readonly>; +} + +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(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(); }; +} diff --git a/tests/unit/audio-intent-coordinator.test.ts b/tests/unit/audio-intent-coordinator.test.ts new file mode 100644 index 0000000..ecdc0a6 --- /dev/null +++ b/tests/unit/audio-intent-coordinator.test.ts @@ -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 => ({ + 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(); + }); +}); diff --git a/tests/unit/use-audio-intent.test.ts b/tests/unit/use-audio-intent.test.ts new file mode 100644 index 0000000..e34fbc5 --- /dev/null +++ b/tests/unit/use-audio-intent.test.ts @@ -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({ + 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({ + 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(); + }); +});