276 lines
9.2 KiB
TypeScript
276 lines
9.2 KiB
TypeScript
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 };
|
|
},
|
|
};
|
|
}
|