/** * 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, lateLimit, newestPassed, passedBar, passedBeat, passedMarkers, 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']); }); });