import type { ChapterIntent } from '@/audio/types'; export const SOUNDTRACK_SAMPLE_RATE = 48_000; export const MUSIC_BPM = 86; export const MUSIC_BEAT_SECONDS = 60 / MUSIC_BPM; export const MUSIC_BEAT_SAMPLES = Math.round( SOUNDTRACK_SAMPLE_RATE * MUSIC_BEAT_SECONDS, ); export const MUSIC_BAR_SECONDS = 4 * 60 / MUSIC_BPM; export const MUSIC_BAR_SAMPLES = 133_953; export const MAIN_PHRASE_BARS = 8; export const MAIN_DURATION_SAMPLES = 8_573_023; export const MAIN_DURATION_SECONDS = MAIN_DURATION_SAMPLES / SOUNDTRACK_SAMPLE_RATE; export const AFTERGLOW_TRANSITION_SAMPLES = MUSIC_BAR_SAMPLES; export const AFTERGLOW_TRANSITION_SECONDS = AFTERGLOW_TRANSITION_SAMPLES / SOUNDTRACK_SAMPLE_RATE; export const AFTERGLOW_LOOP_START_SAMPLE = AFTERGLOW_TRANSITION_SAMPLES; export const AFTERGLOW_LOOP_SAMPLES = 1_071_628; export const AFTERGLOW_LOOP_END_SAMPLE = 1_205_581; export const AFTERGLOW_LOOP_START_SECONDS = AFTERGLOW_LOOP_START_SAMPLE / SOUNDTRACK_SAMPLE_RATE; export const AFTERGLOW_LOOP_END_SECONDS = AFTERGLOW_LOOP_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE; export const DRIVE_LIFT_LOOP_SAMPLES = 535_814; export const DRIVE_LIFT_LOOP_SECONDS = DRIVE_LIFT_LOOP_SAMPLES / SOUNDTRACK_SAMPLE_RATE; export const INTRO_IN_END_SAMPLE = 267_907; export const INTRO_FIRST_PASS_END_SAMPLE = 1_339_535; export const INTRO_LOOP_START_SAMPLE = INTRO_FIRST_PASS_END_SAMPLE; export const INTRO_LOOP_END_SAMPLE = 2_411_163; export const INTRO_OUT_START_SAMPLE = INTRO_LOOP_END_SAMPLE; export const INTRO_OUT_END_SAMPLE = 2_679_070; export const INTRO_RUNTIME_END_SAMPLE = 2_691_070; export const INTRO_IN_END_SECONDS = INTRO_IN_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE; export const INTRO_FIRST_PASS_END_SECONDS = INTRO_FIRST_PASS_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE; export const INTRO_LOOP_START_SECONDS = INTRO_LOOP_START_SAMPLE / SOUNDTRACK_SAMPLE_RATE; export const INTRO_LOOP_END_SECONDS = INTRO_LOOP_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE; export const INTRO_OUT_START_SECONDS = INTRO_OUT_START_SAMPLE / SOUNDTRACK_SAMPLE_RATE; export const INTRO_OUT_END_SECONDS = INTRO_OUT_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE; export const INTRO_RUNTIME_END_SECONDS = INTRO_RUNTIME_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE; export type InitialPlayback = | { readonly kind: 'intro' } | { readonly kind: 'main'; readonly offsetSeconds: number }; export function resolveInitialPlayback(state: ChapterIntent): InitialPlayback { return state.atStart ? { kind: 'intro' } : { kind: 'main', offsetSeconds: mainPhraseOffsetSeconds(state.entryPhrase) }; } export function mainPhraseOffsetSeconds(entryPhrase: number): number { const finitePhrase = Number.isFinite(entryPhrase) ? Math.trunc(entryPhrase) : 0; const phrase = Math.min(7, Math.max(0, finitePhrase)); const targetSamples = Math.round( phrase * MAIN_PHRASE_BARS * MUSIC_BAR_SECONDS * SOUNDTRACK_SAMPLE_RATE, ); return targetSamples / SOUNDTRACK_SAMPLE_RATE; } export function nextSafeBarTime( now: number, anchorTime: number, minimumLeadSeconds: number, ): number { const earliest = Math.max(anchorTime, now + Math.max(0, minimumLeadSeconds)); const elapsedBars = (earliest - anchorTime) / MUSIC_BAR_SECONDS; const oneSampleInBars = 1 / SOUNDTRACK_SAMPLE_RATE / MUSIC_BAR_SECONDS; const nextBar = Math.ceil(elapsedBars - oneSampleInBars); return anchorTime + nextBar * MUSIC_BAR_SECONDS; } export function nextBeatTime(earliest: number, anchorTime: number): number { const elapsedBeats = (Math.max(anchorTime, earliest) - anchorTime) / MUSIC_BEAT_SECONDS; const oneSampleInBeats = 1 / SOUNDTRACK_SAMPLE_RATE / MUSIC_BEAT_SECONDS; return anchorTime + Math.ceil(elapsedBeats - oneSampleInBeats) * MUSIC_BEAT_SECONDS; } export function phaseAlignedLoopOffset( when: number, anchorTime: number, loopDuration: number, ): number { if (!Number.isFinite(loopDuration) || loopDuration <= 0) return 0; const elapsed = when - anchorTime; return ((elapsed % loopDuration) + loopDuration) % loopDuration; } export interface MainScheduleInput { readonly startTime: number; readonly offsetSeconds: number; readonly bufferDuration: number; } export interface MainSchedule { readonly sourceStartTime: number; readonly sourceOffsetSeconds: number; readonly musicalAnchorTime: number; readonly sourceEndTime: number; readonly afterglowStartTime: number; } export function resolveMainSchedule(input: MainScheduleInput): MainSchedule { const sourceOffsetSeconds = Math.min( Math.max(0, input.offsetSeconds), Math.max(0, input.bufferDuration - 0.05), ); const sourceEndTime = input.startTime + input.bufferDuration - sourceOffsetSeconds; return { sourceStartTime: input.startTime, sourceOffsetSeconds, musicalAnchorTime: input.startTime - sourceOffsetSeconds, sourceEndTime, afterglowStartTime: sourceEndTime - AFTERGLOW_TRANSITION_SECONDS, }; }