107 lines
3.6 KiB
TypeScript
107 lines
3.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
AFTERGLOW_LOOP_END_SAMPLE,
|
|
AFTERGLOW_LOOP_START_SAMPLE,
|
|
AFTERGLOW_TRANSITION_SECONDS,
|
|
DRIVE_LIFT_LOOP_SAMPLES,
|
|
DRIVE_LIFT_LOOP_SECONDS,
|
|
MAIN_DURATION_SAMPLES,
|
|
MAIN_DURATION_SECONDS,
|
|
MAIN_PHRASE_BARS,
|
|
MUSIC_BAR_SAMPLES,
|
|
MUSIC_BAR_SECONDS,
|
|
MUSIC_BEAT_SAMPLES,
|
|
MUSIC_BEAT_SECONDS,
|
|
INTRO_IN_END_SECONDS,
|
|
SOUNDTRACK_SAMPLE_RATE,
|
|
mainPhraseOffsetSeconds,
|
|
nextBeatTime,
|
|
nextSafeBarTime,
|
|
phaseAlignedLoopOffset,
|
|
resolveMainSchedule,
|
|
resolveInitialPlayback,
|
|
} from '@/audio/soundtrack-transport';
|
|
|
|
describe('soundtrack transport decisions', () => {
|
|
it('starts at the intro only on Start and otherwise chooses the matching main phrase', () => {
|
|
expect(resolveInitialPlayback({
|
|
sectionId: 'start',
|
|
mood: 'open',
|
|
atStart: true,
|
|
entryPhrase: 0,
|
|
generation: 0,
|
|
})).toEqual({ kind: 'intro' });
|
|
|
|
expect(resolveInitialPlayback({
|
|
sectionId: 'infrastructure',
|
|
mood: 'shadow',
|
|
atStart: false,
|
|
entryPhrase: 3,
|
|
generation: 1,
|
|
})).toEqual({ kind: 'main', offsetSeconds: mainPhraseOffsetSeconds(3) });
|
|
|
|
expect(mainPhraseOffsetSeconds(Number.NaN)).toBe(0);
|
|
const finalPhraseSample = Math.round(
|
|
7 * MAIN_PHRASE_BARS * MUSIC_BAR_SECONDS * SOUNDTRACK_SAMPLE_RATE,
|
|
);
|
|
expect(mainPhraseOffsetSeconds(99)).toBe(finalPhraseSample / SOUNDTRACK_SAMPLE_RATE);
|
|
});
|
|
|
|
it('coalesces an exit onto the next bar with enough time for a clean crossfade', () => {
|
|
const anchorTime = 1;
|
|
const now = anchorTime + MUSIC_BAR_SECONDS * 2 + 0.1;
|
|
const transitionTime = nextSafeBarTime(now, anchorTime, 0.25);
|
|
|
|
expect(transitionTime).toBeCloseTo(anchorTime + MUSIC_BAR_SECONDS * 3, 8);
|
|
expect(transitionTime).toBeGreaterThanOrEqual(now + 0.25);
|
|
expect(nextSafeBarTime(anchorTime, anchorTime, 0)).toBe(anchorTime);
|
|
|
|
const earliestIntroExit = nextSafeBarTime(
|
|
anchorTime + INTRO_IN_END_SECONDS - 0.35,
|
|
anchorTime,
|
|
0.35,
|
|
);
|
|
expect(earliestIntroExit).toBeCloseTo(anchorTime + MUSIC_BAR_SECONDS * 2, 8);
|
|
});
|
|
|
|
it('pins the short adaptive regions to whole samples', () => {
|
|
expect(MUSIC_BEAT_SAMPLES).toBe(33_488);
|
|
expect(MUSIC_BAR_SAMPLES).toBe(133_953);
|
|
expect(DRIVE_LIFT_LOOP_SAMPLES).toBe(535_814);
|
|
expect(AFTERGLOW_LOOP_START_SAMPLE).toBe(133_953);
|
|
expect(AFTERGLOW_LOOP_END_SAMPLE).toBe(1_205_581);
|
|
expect(MAIN_DURATION_SAMPLES).toBe(8_573_023);
|
|
});
|
|
|
|
it('derives one immutable main anchor and the final-bar afterglow handoff', () => {
|
|
expect(resolveMainSchedule({
|
|
startTime: 10,
|
|
offsetSeconds: mainPhraseOffsetSeconds(6),
|
|
bufferDuration: MAIN_DURATION_SECONDS,
|
|
})).toEqual({
|
|
sourceStartTime: 10,
|
|
sourceOffsetSeconds: mainPhraseOffsetSeconds(6),
|
|
musicalAnchorTime: 10 - mainPhraseOffsetSeconds(6),
|
|
sourceEndTime: 10 + MAIN_DURATION_SECONDS - mainPhraseOffsetSeconds(6),
|
|
afterglowStartTime: 10 + MAIN_DURATION_SECONDS - mainPhraseOffsetSeconds(6) - AFTERGLOW_TRANSITION_SECONDS,
|
|
});
|
|
});
|
|
|
|
it('aligns a late loop source and sustained entry to the master beat', () => {
|
|
expect(phaseAlignedLoopOffset(13, 10, DRIVE_LIFT_LOOP_SECONDS))
|
|
.toBeCloseTo(3, 8);
|
|
expect(nextBeatTime(11.45, 10)).toBeCloseTo(
|
|
10 + 3 * MUSIC_BEAT_SECONDS,
|
|
8,
|
|
);
|
|
});
|
|
|
|
it('keeps one-sample-rounded beat and bar boundaries on the current boundary', () => {
|
|
const oneSample = 1 / SOUNDTRACK_SAMPLE_RATE;
|
|
expect(nextBeatTime(MUSIC_BEAT_SECONDS + oneSample, 0))
|
|
.toBeCloseTo(MUSIC_BEAT_SECONDS, 8);
|
|
expect(nextSafeBarTime(MUSIC_BAR_SECONDS + oneSample, 0, 0))
|
|
.toBeCloseTo(MUSIC_BAR_SECONDS, 8);
|
|
});
|
|
});
|