123 lines
4.8 KiB
TypeScript
123 lines
4.8 KiB
TypeScript
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,
|
|
};
|
|
}
|