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 }; }, }; }