Files
Portfolio/src/audio/soundtrack-transport.ts

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