Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,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.**
`<EmbeddedFilePath>` is matched on filename stem so a format variant of the
same recording can win (an `.ogg` beside the declared `.mp3` is copied out
Expand Down
83 changes: 78 additions & 5 deletions static/js/count-in.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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-
Expand All @@ -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,
Expand Down
189 changes: 189 additions & 0 deletions tests/js/count_in_beats.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
4 changes: 4 additions & 0 deletions tests/js/loop_restart.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading