From 32c00cdd780d879e0e5d1fe0377403939abbf128 Mon Sep 17 00:00:00 2001 From: gionnibgud Date: Thu, 23 Jul 2026 11:26:32 +0200 Subject: [PATCH] fix(count-in): follow the song's meter and its pickup measure (#1029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The count-in always clicked exactly four beats, so a 3/4 song was counted in 4/4, and a song opening with a pickup (anacrusis) had the pickup enter where the downbeat belonged — putting the player a beat ahead all song. Bar length now comes from the song_timeline beats already on the highway (measure >= 0 marks downbeats), so no new plumbing: the time_signatures map is streamed to plugins rather than stored in the frontend. A first bar shorter than that meter shortens the count by its length — a 1-beat pickup in 4/4 counts "1 2 3" and the music enters on 4. Bar length is the mode of the downbeat gaps, not the first gap, so a pickup's own short gap can't be read as the meter; the beats trailing the last downbeat count as a candidate too, or a song of pickup + one bar offers only the pickup's gap. Pickup shortening is scoped to the song's first bar — a short bar elsewhere is a meter change, and is counted by its own length instead. Songs without beats (pre-chart, minigames, synthetic highways) still get four. Applies to both count-in paths: loop wrap / section practice, and the start-of-song 'Countdown before song' setting. Signed-off-by: gionnibgud Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 11 ++ static/js/count-in.js | 83 +++++++++++++- tests/js/count_in_beats.test.js | 189 ++++++++++++++++++++++++++++++++ tests/js/loop_restart.test.js | 4 + 4 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 tests/js/count_in_beats.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6294fd6..2b60f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -299,6 +299,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry). ### Fixed +- **Count-in follows the song's meter and its pickup measure.** The count-in + (loop wrap, section practice, and the "Countdown before song" setting) always + clicked exactly four beats, so a 3/4 song was counted in 4/4, and a song + opening with a pickup (anacrusis) had the pickup enter where the downbeat + belonged — putting the player a beat ahead for the whole song. The bar length + now comes from the `song_timeline` beats already on the highway + (`measure >= 0` marks downbeats; no new plumbing, since the `time_signatures` + map is streamed to plugins rather than stored in the frontend), and a first + bar shorter than that meter shortens the count by its length: a 1-beat pickup + in 4/4 counts "1 2 3" and the music enters on 4. Songs without beats — pre-chart, + minigames, synthetic highways — still get four. - **GP8 asset resolution honours the directory the registry named.** `` is matched on filename stem so a format variant of the same recording can win (an `.ogg` beside the declared `.mp3` is copied out diff --git a/static/js/count-in.js b/static/js/count-in.js index b78aafa..6459753 100644 --- a/static/js/count-in.js +++ b/static/js/count-in.js @@ -1,4 +1,4 @@ -// Count-in — the 1-2-3-4 click before playback, plus the song-credits overlay that +// Count-in — the one-bar click before playback, plus the song-credits overlay that // shares its lifecycle and timers. // // The third slice out of app.js's strongly-connected core, and the first that had to @@ -40,6 +40,75 @@ export function playClick(high = false) { osc.stop(_audioCtx.currentTime + 0.08); } +// ── How many clicks lead into `startT` ────────────────────────────────── +// One bar, derived from the song_timeline beats: `window.highway.getBeats()` +// is the only meter data the frontend holds (the `time_signatures` map is +// streamed to plugins, not stored here). Beats carry `measure >= 0` on +// downbeats, so the gap between consecutive downbeats IS the bar length — +// which is why a 3/4 song no longer gets four clicks. +// +// A first bar shorter than that is a pickup (anacrusis), and the count is +// shortened by its length so the music enters on its real beat: a 1-beat +// pickup in 4/4 counts "1 2 3" and the pickup lands on 4. Counting a full +// four there puts the pickup where the downbeat belongs, and the player comes +// in a beat late for the whole song. +export function countInBeats(startT) { + const DEFAULT = 4; // pre-chart, synthetic highway (minigames), or no beats + let beats = null; + try { + if (window.highway && typeof window.highway.getBeats === 'function') { + beats = window.highway.getBeats(); + } + } catch (_) { /* fall through to the default */ } + if (!Array.isArray(beats) || beats.length < 2) return DEFAULT; + + const downbeats = []; + for (let i = 0; i < beats.length; i++) { + if (beats[i] && beats[i].measure >= 0) downbeats.push(i); + } + if (downbeats.length < 2) return DEFAULT; + + // Bar length = the most common gap between downbeats. The mode rather than + // the first gap: it ignores a short pickup bar and a short final bar, and + // survives an isolated meter change mid-song. The beats trailing the last + // downbeat count as a candidate too — otherwise a song of pickup + one bar + // offers only the pickup's own gap and the count collapses to it. + const gapCounts = new Map(); + const addGap = (gap) => gapCounts.set(gap, (gapCounts.get(gap) || 0) + 1); + for (let k = 1; k < downbeats.length; k++) { + addGap(downbeats[k] - downbeats[k - 1]); + } + addGap(beats.length - downbeats[downbeats.length - 1]); + let barLen = DEFAULT; + let bestCount = 0; + for (const [gap, n] of gapCounts) { + // Tie → the longer bar: a pickup's short gap must not outvote the + // real meter when the song is too short to repeat it. + if (n > bestCount || (n === bestCount && gap > barLen)) { + barLen = gap; + bestCount = n; + } + } + + // The beat playback resumes on. The 50 ms tolerance matches the seek + // precision the loop-wrap path already assumes. + const startIdx = beats.findIndex(b => b && b.time >= startT - 0.05); + if (startIdx === -1) return barLen; // past the last beat + if (!(beats[startIdx].measure >= 0)) return barLen; // resuming mid-bar + + const nextDownbeat = downbeats.find(d => d > startIdx); + if (nextDownbeat === undefined) return barLen; // the last downbeat + const thisBar = nextDownbeat - startIdx; + if (thisBar <= 0) return barLen; + + // Only the song's FIRST bar can be a pickup. A short bar anywhere else is + // a meter change (or a truncated final bar), and counting it as a pickup + // would leave almost no count-in at all — so elsewhere we simply count + // that bar's own length, which is also what a mid-song meter change wants. + if (startIdx === downbeats[0] && thisBar < barLen) return barLen - thisBar; + return thisBar; +} + let _countingIn = false; let _countOverlay = null; // Generation token so teardown can cancel an in-progress count-in. Each @@ -273,12 +342,15 @@ export async function startCountIn(opts = {}) { function beginCount() { const bpm = window.highway.getBPM(loopA); const beatInterval = 60 / bpm; + // One bar of the meter at loop A (a short bar there is counted short, + // same as the song-start pickup). + const clicks = countInBeats(loopA); let count = 0; function tick() { if (gen !== _countInGen) return; // teardown mid-count count++; - if (count > 4) { + if (count > clicks) { hideCountOverlay(); _countingIn = false; if (window._juceMode) { @@ -320,7 +392,7 @@ export async function startCountIn(opts = {}) { } } -// Start-of-song count-in: a 4-beat click before playback begins, gated by the +// Start-of-song count-in: a one-bar click before playback begins, gated by the // "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's // overlay + click + gen-token cancellation, but counts from the song's current // position (0 at song start) with no loop A/B rewind. startCountIn() is loop- @@ -340,14 +412,15 @@ export async function startSongCountIn() { if (gen !== _countInGen) return; // teardown during pause const startT = S.lastAudioTime || 0; let bpm = window.highway.getBPM(startT); - // Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each). + // Pre-chart / malformed-tempo fallback: 120 BPM (500 ms per beat). if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120; const beatInterval = 60 / bpm; + const clicks = countInBeats(startT); let count = 0; function tick() { if (gen !== _countInGen) return; // teardown mid-count count++; - if (count > 4) { + if (count > clicks) { hideCountOverlay(); _countingIn = false; // Hand off to the normal play path — togglePlay() flips isPlaying, diff --git a/tests/js/count_in_beats.test.js b/tests/js/count_in_beats.test.js new file mode 100644 index 0000000..73a5012 --- /dev/null +++ b/tests/js/count_in_beats.test.js @@ -0,0 +1,189 @@ +// Verify `countInBeats(startT)` in static/js/count-in.js sizes the count-in +// to the song's own bar rather than a hardcoded four clicks. +// +// Two behaviours are under test: +// 1. Meter — a 3/4 song gets three clicks, not four. +// 2. Pickup (anacrusis) — a first bar shorter than the meter shortens the +// count so the pickup enters on its real beat (1-beat pickup in 4/4 → +// "1 2 3", music on 4). A full four there puts the pickup where the +// downbeat belongs and the player comes in a beat late all song. +// +// The meter is read from the song_timeline beats (`window.highway.getBeats()`, +// `measure >= 0` on downbeats) because that is the only meter data the +// frontend holds — the `time_signatures` map is streamed to plugins, not +// stored here. +// +// Same extraction approach as loop_restart.test.js: pull the function source +// out of the module and evaluate it in a vm sandbox with a stubbed highway, +// rather than loading the ESM module and its DOM-coupled imports. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const COUNT_IN_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js'); + +// Brace-match the function body out of the source. Brittle by design: +// a rename fails loudly here rather than silently skipping coverage. +function extractFunction(src, signature) { + const start = src.indexOf(signature); + if (start === -1) throw new Error(`extractFunction: '${signature}' not found`); + const openBrace = src.indexOf('{', start + signature.length); + let depth = 1; + let i = openBrace + 1; + while (i < src.length && depth > 0) { + const ch = src[i]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`); + return src.slice(start, i); +} + +const src = fs.readFileSync(COUNT_IN_JS, 'utf8'); +// Drop the `export` keyword so the body evaluates as a plain declaration. +const fnSrc = extractFunction(src, 'export function countInBeats') + .replace(/^export\s+/, ''); + +// `beats` is the song_timeline shape: {time, measure}, measure >= 0 only on +// downbeats. `getBeats` may also be absent entirely (pre-chart / minigame). +function load(beats) { + const sandbox = { + window: beats === undefined + ? { highway: {} } + : { highway: { getBeats: () => beats } }, + }; + vm.createContext(sandbox); + vm.runInContext(`${fnSrc}; globalThis.__fn = countInBeats;`, sandbox); + return sandbox.__fn; +} + +// Build a beats array: `bars` full bars of `beatsPerBar`, optionally preceded +// by a pickup of `pickup` beats. One beat per 0.5 s throughout. +function makeBeats({ beatsPerBar = 4, bars = 4, pickup = 0 } = {}) { + const out = []; + let t = 0; + let measure = 0; + if (pickup > 0) { + for (let i = 0; i < pickup; i++) { + out.push({ time: t, measure: i === 0 ? measure : -1 }); + t += 0.5; + } + measure++; + } + for (let b = 0; b < bars; b++) { + for (let i = 0; i < beatsPerBar; i++) { + out.push({ time: t, measure: i === 0 ? measure : -1 }); + t += 0.5; + } + measure++; + } + return out; +} + +// ── Meter ──────────────────────────────────────────────────────────────── + +test('countInBeats counts a full bar in 4/4', () => { + const countInBeats = load(makeBeats({ beatsPerBar: 4 })); + assert.equal(countInBeats(0), 4); +}); + +test('countInBeats counts three in 3/4 (was hardcoded four)', () => { + const countInBeats = load(makeBeats({ beatsPerBar: 3 })); + assert.equal(countInBeats(0), 3); +}); + +test('countInBeats counts six in 6/8', () => { + const countInBeats = load(makeBeats({ beatsPerBar: 6 })); + assert.equal(countInBeats(0), 6); +}); + +// ── Pickup (anacrusis) ─────────────────────────────────────────────────── + +test('countInBeats shortens the count by a 1-beat pickup in 4/4', () => { + const countInBeats = load(makeBeats({ beatsPerBar: 4, pickup: 1 })); + assert.equal(countInBeats(0), 3, 'counts 1-2-3 so the pickup lands on 4'); +}); + +test('countInBeats shortens the count by a 2-beat pickup in 4/4', () => { + const countInBeats = load(makeBeats({ beatsPerBar: 4, pickup: 2 })); + assert.equal(countInBeats(0), 2); +}); + +test('countInBeats handles a pickup in 3/4', () => { + const countInBeats = load(makeBeats({ beatsPerBar: 3, pickup: 1 })); + assert.equal(countInBeats(0), 2); +}); + +test('countInBeats finds the meter when the song is only a pickup plus one bar', () => { + // Gap counts tie (one 1-beat gap, one 4-beat gap) — the longer bar is the + // meter, so this must be 3 rather than 0. + const countInBeats = load(makeBeats({ beatsPerBar: 4, bars: 1, pickup: 1 })); + assert.equal(countInBeats(0), 3); +}); + +// ── Resuming somewhere other than the song top ─────────────────────────── + +test('countInBeats counts a full bar at a mid-song downbeat, pickup notwithstanding', () => { + const beats = makeBeats({ beatsPerBar: 4, pickup: 1 }); + const countInBeats = load(beats); + // Index 5 is the downbeat of the second full bar (1 pickup + 4 beats). + assert.equal(beats[5].measure >= 0, true, 'fixture sanity: index 5 is a downbeat'); + assert.equal(countInBeats(beats[5].time), 4); +}); + +test('countInBeats counts a mid-song meter change by that bar, not as a pickup', () => { + // 4/4 throughout, except one 3-beat bar at index 8. Treating a short bar + // anywhere but the song's first as a pickup would count a single click. + const beats = []; + let t = 0; + const push = (n, measure) => { + for (let i = 0; i < n; i++) { beats.push({ time: t, measure: i === 0 ? measure : -1 }); t += 0.5; } + }; + push(4, 0); push(4, 1); push(3, 2); push(4, 3); push(4, 4); + const countInBeats = load(beats); + assert.equal(beats[8].measure, 2, 'fixture sanity: index 8 opens the 3-beat bar'); + assert.equal(countInBeats(beats[8].time), 3, 'counts the short bar itself'); + assert.equal(countInBeats(0), 4, 'the 4/4 opening is unaffected'); +}); + +test('countInBeats counts a full bar when resuming mid-bar', () => { + const beats = makeBeats({ beatsPerBar: 4 }); + const countInBeats = load(beats); + assert.equal(countInBeats(beats[2].time), 4); // third beat of bar 1 +}); + +test('countInBeats tolerates a start time slightly past the beat (seek slop)', () => { + const countInBeats = load(makeBeats({ beatsPerBar: 4, pickup: 1 })); + assert.equal(countInBeats(0.02), 3); +}); + +// ── Fallbacks ──────────────────────────────────────────────────────────── + +test('countInBeats falls back to four without a beats array', () => { + assert.equal(load(undefined)(0), 4, 'no getBeats (pre-chart / minigame)'); + assert.equal(load([])(0), 4, 'empty beats'); + assert.equal(load(null)(0), 4, 'null beats'); +}); + +test('countInBeats falls back to four when beats carry no downbeat labels', () => { + const beats = [0, 0.5, 1.0, 1.5, 2.0].map(time => ({ time, measure: -1 })); + assert.equal(load(beats)(0), 4); +}); + +test('countInBeats falls back to four with only one downbeat', () => { + const beats = [ + { time: 0, measure: 0 }, + { time: 0.5, measure: -1 }, + { time: 1.0, measure: -1 }, + ]; + assert.equal(load(beats)(0), 4); +}); + +test('countInBeats counts a full bar past the last beat', () => { + const beats = makeBeats({ beatsPerBar: 3 }); + assert.equal(load(beats)(9999), 3); +}); diff --git a/tests/js/loop_restart.test.js b/tests/js/loop_restart.test.js index 6d8b4d8..63980c3 100644 --- a/tests/js/loop_restart.test.js +++ b/tests/js/loop_restart.test.js @@ -88,6 +88,10 @@ function buildSandbox() { playClick: () => {}, showCountOverlay: () => {}, hideCountOverlay: () => {}, + // beginCount sizes the count to the bar at loop A; the wrap-path + // assertions below don't depend on how many clicks it decides on. + // Covered directly in count_in_beats.test.js. + countInBeats: () => 4, // Stubbed DOM access. Anything querying for a button just gets a // permissive object that ignores writes.