mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 11:49:28 +00:00
fix(count-in): follow the song's meter and its pickup measure
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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: gionnibgud <gionnibgud@gmail.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0e3522ccc3
commit
6ece31b020
@@ -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).
|
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
||||||
|
|
||||||
### Fixed
|
### 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.**
|
- **GP8 asset resolution honours the directory the registry named.**
|
||||||
`<EmbeddedFilePath>` is matched on filename stem so a format variant of the
|
`<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
|
same recording can win (an `.ogg` beside the declared `.mp3` is copied out
|
||||||
|
|||||||
+78
-5
@@ -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.
|
// shares its lifecycle and timers.
|
||||||
//
|
//
|
||||||
// The third slice out of app.js's strongly-connected core, and the first that had to
|
// 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);
|
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 _countingIn = false;
|
||||||
let _countOverlay = null;
|
let _countOverlay = null;
|
||||||
// Generation token so teardown can cancel an in-progress count-in. Each
|
// Generation token so teardown can cancel an in-progress count-in. Each
|
||||||
@@ -273,12 +342,15 @@ export async function startCountIn(opts = {}) {
|
|||||||
function beginCount() {
|
function beginCount() {
|
||||||
const bpm = window.highway.getBPM(loopA);
|
const bpm = window.highway.getBPM(loopA);
|
||||||
const beatInterval = 60 / bpm;
|
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;
|
let count = 0;
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
if (gen !== _countInGen) return; // teardown mid-count
|
if (gen !== _countInGen) return; // teardown mid-count
|
||||||
count++;
|
count++;
|
||||||
if (count > 4) {
|
if (count > clicks) {
|
||||||
hideCountOverlay();
|
hideCountOverlay();
|
||||||
_countingIn = false;
|
_countingIn = false;
|
||||||
if (window._juceMode) {
|
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
|
// "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's
|
||||||
// overlay + click + gen-token cancellation, but counts from the song's current
|
// 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-
|
// 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
|
if (gen !== _countInGen) return; // teardown during pause
|
||||||
const startT = S.lastAudioTime || 0;
|
const startT = S.lastAudioTime || 0;
|
||||||
let bpm = window.highway.getBPM(startT);
|
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;
|
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
|
||||||
const beatInterval = 60 / bpm;
|
const beatInterval = 60 / bpm;
|
||||||
|
const clicks = countInBeats(startT);
|
||||||
let count = 0;
|
let count = 0;
|
||||||
function tick() {
|
function tick() {
|
||||||
if (gen !== _countInGen) return; // teardown mid-count
|
if (gen !== _countInGen) return; // teardown mid-count
|
||||||
count++;
|
count++;
|
||||||
if (count > 4) {
|
if (count > clicks) {
|
||||||
hideCountOverlay();
|
hideCountOverlay();
|
||||||
_countingIn = false;
|
_countingIn = false;
|
||||||
// Hand off to the normal play path — togglePlay() flips isPlaying,
|
// Hand off to the normal play path — togglePlay() flips isPlaying,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -88,6 +88,10 @@ function buildSandbox() {
|
|||||||
playClick: () => {},
|
playClick: () => {},
|
||||||
showCountOverlay: () => {},
|
showCountOverlay: () => {},
|
||||||
hideCountOverlay: () => {},
|
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
|
// Stubbed DOM access. Anything querying for a button just gets a
|
||||||
// permissive object that ignores writes.
|
// permissive object that ignores writes.
|
||||||
|
|||||||
Reference in New Issue
Block a user