A game could start and stop a song, but not steer one while it plays. The battle theme could not take over on the next bar, a song could not jump to a marker, and a track could not fall away when the player hides. This is stage 3 of 5 in #338. - Quantize: now, next beat, next bar (the default) or next marker. The spot is the next one as the song is heard, and the change is planned on the audio clock to the sample, round the loop as the ruler counts. - Set song track turns one track in dB on top of the song's level, over a fade, on the bus input before its FX and sends. - Switch song plays another song, or the same one from a marker, in place of the playing one. It keeps the playback number, so Stop song, Set song track and On beat go on reaching it. - A playing now has a key for its buses apart from its public number. During a switch the old and the new song each have buses of their own. - Play song has a "live tracks" switch. With it off a baked song plays its bake, and Set song track on it writes a line to the log. - A Switch song to a marker plays the notes, as a bake only plays from its start. Found on the way: - A cut on a bar line faded over seconds in Chrome. cancelAndHoldAtTime holds nothing where nothing is planned later, so the ramp ran from the bus's last change. The turn now sets its start value on the spot. It had been measured at 0.52 of full level 0.2 s past the bar line. Tests: - Unit: 23 new cases in beats, song-play, sound-library and the node catalog. Counter-checks: 17 bends, all compile, all red. - e2e 65 (atx): ALL PASSED, 73 checks. 65k3 is new. The switch landed three bars after the start under the same number, and the track cut on the next bar line with nothing in between. With the old ramp 65k3 is red (0.53 and 0.51 between). - Gate (atx): CHECK EXIT 0, 334 files, 5144 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
7.3 KiB
TypeScript
183 lines
7.3 KiB
TypeScript
/**
|
||
* Where a playing is heard, and the beats, bars and markers it passed (#338): a frame looks now and
|
||
* then, so what passed since is told late – and too late, or more than the last, not at all.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
heardPosition,
|
||
heldQuantize,
|
||
lateLimit,
|
||
newestPassed,
|
||
nextSpot,
|
||
passedBar,
|
||
passedBeat,
|
||
passedMarkers,
|
||
quantizeSpots,
|
||
type Timeline,
|
||
} from '../../src/core/music/beats';
|
||
import { COMMON_TIME } from '../../src/core/music/time';
|
||
|
||
const Q = 480;
|
||
const BAR = 4 * Q;
|
||
/** 120 BPM in 4/4: a beat is half a second, a bar two. Intro bar 1, then round bar 2. */
|
||
const LOOPED: Timeline = {
|
||
tempo: 120,
|
||
meter: COMMON_TIME,
|
||
from: 0,
|
||
loop: { start: BAR, end: 2 * BAR },
|
||
length: 2 * BAR,
|
||
};
|
||
const ONCE: Timeline = { ...LOOPED, loop: null };
|
||
|
||
const beatAt = (t: Timeline, before: number | null, now: number) => {
|
||
const beat = passedBeat(t, before, now);
|
||
return beat && [beat.bar, beat.beat, +beat.late.toFixed(6)];
|
||
};
|
||
|
||
describe('where a playing is heard', () => {
|
||
it('counts bar and beat from 1, the fraction into the beat, the seconds into the song', () => {
|
||
expect(heardPosition(LOOPED, 0)).toMatchObject({
|
||
bar: 1,
|
||
beat: 1,
|
||
fraction: 0,
|
||
seconds: 0,
|
||
playing: true,
|
||
});
|
||
expect(heardPosition(LOOPED, 1.25)).toMatchObject({ bar: 1, beat: 3, fraction: 0.5, seconds: 1.25 });
|
||
});
|
||
|
||
it('goes back round the loop with the playhead', () => {
|
||
// 5.25 s in: the intro took 2, the loop round 2 more, so a quarter second into the second round.
|
||
expect(heardPosition(LOOPED, 5.25)).toMatchObject({ bar: 2, beat: 3, fraction: 0.5, seconds: 3.25 });
|
||
});
|
||
|
||
it('stands where it starts before its first note, at its end after it, playing neither time', () => {
|
||
expect(heardPosition(LOOPED, null)).toMatchObject({ bar: 1, beat: 1, playing: false });
|
||
expect(heardPosition(LOOPED, -0.03)).toMatchObject({ bar: 1, beat: 1, playing: false });
|
||
expect(heardPosition(ONCE, 4.5)).toMatchObject({ bar: 3, beat: 1, seconds: 4, playing: false });
|
||
});
|
||
|
||
it('takes the beat from the line of the meter: an eighth in 6/8', () => {
|
||
const six: Timeline = { ...ONCE, meter: { beats: 6, unit: 8 } };
|
||
// An eighth is a quarter second at 120 quarters a minute; a bar of six is 1.5 s.
|
||
expect(heardPosition(six, 1.625)).toMatchObject({ bar: 2, beat: 1, fraction: 0.5 });
|
||
expect(lateLimit(six)).toBe(0.125);
|
||
expect(lateLimit(LOOPED)).toBe(0.15);
|
||
});
|
||
});
|
||
|
||
describe('the beats it passed', () => {
|
||
it('tells a beat passed since the last frame, with how late', () => {
|
||
expect(beatAt(LOOPED, 0.49, 0.51)).toEqual([1, 2, 0.01]);
|
||
expect(beatAt(LOOPED, 0.51, 0.53)).toBeNull();
|
||
});
|
||
|
||
it('tells the very first beat to a playing seen for the first time', () => {
|
||
expect(beatAt(LOOPED, null, 0.02)).toEqual([1, 1, 0.02]);
|
||
expect(beatAt(LOOPED, -0.01, 0.02)).toEqual([1, 1, 0.02]);
|
||
});
|
||
|
||
it('still tells a beat a slipped frame passed, but not one more than the limit ago', () => {
|
||
expect(beatAt(LOOPED, 0.4, 0.6)).toEqual([1, 2, 0.1]);
|
||
expect(beatAt(LOOPED, 0.4, 0.7)).toBeNull();
|
||
});
|
||
|
||
it('tells only the last of several passed in one frame, and nothing after a long stall', () => {
|
||
expect(beatAt(LOOPED, 0, 1.6)).toEqual([1, 4, 0.1]);
|
||
expect(beatAt(LOOPED, 0, 10.3)).toBeNull();
|
||
});
|
||
|
||
it('tells the loop’s start each time round', () => {
|
||
// 4 s in, the intro and one round are over: the head is back at bar 2.
|
||
expect(beatAt(LOOPED, 3.99, 4.01)).toEqual([2, 1, 0.01]);
|
||
expect(passedBar(LOOPED, 5.99, 6.02)).toMatchObject({ bar: 2, beat: 1 });
|
||
});
|
||
|
||
it('counts beats on the ruler’s grid where the loop starts between them', () => {
|
||
const offBeat: Timeline = { ...LOOPED, loop: { start: BAR + Q / 2, end: 2 * BAR } };
|
||
// The second round starts 4 s in at a quarter past bar 2's first beat: its next beat is 0.25 s later.
|
||
expect(beatAt(offBeat, 3.99, 4.01)).toBeNull();
|
||
expect(beatAt(offBeat, 4.2, 4.3)).toEqual([2, 2, 0.05]);
|
||
});
|
||
|
||
it('tells nothing at the end of one that plays once, nor after it', () => {
|
||
expect(beatAt(ONCE, 3.95, 4.05)).toBeNull();
|
||
expect(passedBar(ONCE, 3.95, 4.05)).toBeNull();
|
||
expect(beatAt(ONCE, 3.45, 3.55)).toEqual([2, 4, 0.05]);
|
||
});
|
||
|
||
it('tells a bar only on its first beat', () => {
|
||
expect(passedBar(LOOPED, 0.49, 0.51)).toBeNull();
|
||
expect(passedBar(LOOPED, 1.99, 2.01)).toMatchObject({ bar: 2, beat: 1 });
|
||
});
|
||
});
|
||
|
||
describe('the markers it passed', () => {
|
||
const markers = [
|
||
{ id: 'a', name: 'Verse', tick: BAR },
|
||
{ id: 'b', name: 'Also verse', tick: BAR },
|
||
{ id: 'c', name: 'Turn', tick: 2 * BAR - 10 },
|
||
];
|
||
|
||
it('tells every marker on the tick passed, each time round the loop', () => {
|
||
const passed = passedMarkers(LOOPED, 1.99, 2.01, markers);
|
||
expect(passed.map((m) => [m.id, m.name, +m.late.toFixed(6)])).toEqual([
|
||
['a', 'Verse', 0.01],
|
||
['b', 'Also verse', 0.01],
|
||
]);
|
||
expect(passedMarkers(LOOPED, 3.99, 4.01, markers).map((m) => m.id)).toEqual(['a', 'b']);
|
||
});
|
||
|
||
it('of markers passed across the loop’s end, keeps the last spot only', () => {
|
||
// "Turn" is 10 ticks before the end, heard 3.9896 s in; the loop's start follows at 4 s.
|
||
const passed = passedMarkers(LOOPED, 3.98, 4.02, markers);
|
||
expect(passed.map((m) => m.id)).toEqual(['a', 'b', 'c']);
|
||
expect(newestPassed(passed).map((m) => m.id)).toEqual(['a', 'b']);
|
||
expect(newestPassed(passed.filter((m) => m.id === 'c')).map((m) => m.id)).toEqual(['c']);
|
||
});
|
||
});
|
||
|
||
describe('where a change to the music lands (#338)', () => {
|
||
const markers = [
|
||
{ id: 'a', name: 'Verse', tick: BAR + Q },
|
||
{ id: 'b', name: 'Late', tick: 2 * BAR - Q },
|
||
];
|
||
const at = (t: Timeline, elapsed: number, quantize: 'beat' | 'bar' | 'marker', list = markers) =>
|
||
nextSpot(t, elapsed, quantizeSpots(quantize, t.meter, list));
|
||
|
||
it('on the next beat or bar, one it stands on being the one', () => {
|
||
expect(at(LOOPED, 0.2, 'beat')).toBeCloseTo(0.5, 9);
|
||
expect(at(LOOPED, 0.5, 'beat')).toBeCloseTo(0.5, 9);
|
||
expect(at(LOOPED, 0.2, 'bar')).toBeCloseTo(2, 9);
|
||
});
|
||
|
||
it('round the loop: past its last bar the next one is its start come round again', () => {
|
||
// Played time 3.9 s is the end of bar 2; the loop's start follows at 4 s.
|
||
expect(at(LOOPED, 3.9, 'bar')).toBeCloseTo(4, 9);
|
||
expect(at(LOOPED, 5.9, 'bar')).toBeCloseTo(6, 9);
|
||
});
|
||
|
||
it('on the next marker, round the loop too, and on the next bar in a song without markers', () => {
|
||
expect(at(LOOPED, 0, 'marker')).toBeCloseTo(2.5, 9);
|
||
expect(at(LOOPED, 2.6, 'marker')).toBeCloseTo(3.5, 9);
|
||
expect(at(LOOPED, 3.6, 'marker')).toBeCloseTo(4.5, 9);
|
||
expect(at(LOOPED, 0.2, 'marker', [])).toBeCloseTo(2, 9);
|
||
});
|
||
|
||
it('from where a playing started, a marker its start lies past is not come to again', () => {
|
||
expect(at({ ...LOOPED, from: BAR + 2 * Q }, 0, 'marker')).toBeCloseTo(0.5, 9);
|
||
expect(at({ ...ONCE, from: BAR + 2 * Q }, 0, 'marker', [markers[0]!])).toBeNull();
|
||
});
|
||
|
||
it('nowhere at or past the end of one that ends: the change then lands at once', () => {
|
||
expect(at(ONCE, 3.2, 'bar')).toBeNull();
|
||
expect(at(ONCE, 3.2, 'beat')).toBeCloseTo(3.5, 9);
|
||
});
|
||
|
||
it('takes what it does not know for the next bar', () => {
|
||
expect(heldQuantize('now')).toBe('now');
|
||
expect(heldQuantize('soon')).toBe('bar');
|
||
expect(heldQuantize(undefined)).toBe('bar');
|
||
});
|
||
});
|