From 19b0083a3ace386aec40571c4bf6d65ff1ea7e60 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Mon, 7 Sep 2026 15:14:45 +0100
Subject: [PATCH 1/4] feat(click): group the bar so odd and compound meters
sound like themselves
The click marked exactly one beat per bar, on the same rule in both
implementations, with only two voices. Correct for 2/4, 3/4 and 4/4. Wrong
for every meter that carries internal stresses: 7/8 came out as seven even
clicks, which gives a player nothing to lock onto, and 6/8 rendered as six
straight eighths when it is felt in two dotted-quarter groups.
Adds a grouping to the bar. 7 is played 3+2+2, 6 and 9 and 12 in threes,
5 as 3+2, with an accent at each group start. A meter with no conventional
reading, 11 for instance, stays flat rather than being guessed at.
Grouping needs a third voice. With only an accent and a plain beat, a
3+2+2 bar has three identical accents and the downbeat stops being
findable, so a group start now sounds between the two: 1225 Hz at 0.85,
the geometric middle of the existing 1000 and 1500 because the ear hears
pitch ratios. Both renderers gained it and both count-ins use it, so
counting a player into 7/8 gives them the pulse they are about to play.
2, 3 and 4 are deliberately unchanged. 4/4 does carry a real secondary
stress on beat 3, but turning that on by default would change the
commonest meter in the app for every existing user. That is their choice
to make, and the grouping box makes it available.
6/8 does change, and that is the point of the issue: it was shipping a
reading of the meter that no player would count.
The user can override the grouping for an explicit meter, "3+2+2" or
"2+2+3". One that does not sum to the bar is refused wholesale rather than
repaired, on both sides, because a half-understood grouping puts accents on
beats nobody asked for. The export mirrors that rather than returning 422:
a file is better served with the default grouping than refused.
Under Auto no single grouping can apply, since the bar length can change
from bar to bar, so each detected bar is grouped by its own length. That is
what makes a detected 6/8 passage group in threes with nothing configured,
and it needed a bar-position lookup on the live side, where only a
downbeat yes/no had existed.
The grouping enters the render cache key only when one is in force, so
every export made before this keeps hitting the entry it already had.
Playback and export are held to the same numbers in tests/js/count-in.test.mjs
and tests/test_click_render.py, the existing parity discipline for this pair.
Closes #595
---
app/api/stems.py | 32 +++++-
app/pipeline/click_render.py | 197 ++++++++++++++++++++++++++++-----
static/css/daw.css | 13 +++
static/index.html | 1 +
static/js/beatgrid.js | 17 +++
static/js/i18n.js | 20 ++++
static/js/metronome.js | 130 ++++++++++++++++++++--
static/js/player.js | 4 +-
static/js/state.js | 9 ++
static/js/transport.js | 59 +++++++++-
tests/e2e/click-track.spec.mjs | 31 ++++++
tests/js/count-in.test.mjs | 57 +++++++++-
tests/test_click_render.py | 189 ++++++++++++++++++++++++++++++-
13 files changed, 710 insertions(+), 49 deletions(-)
diff --git a/app/api/stems.py b/app/api/stems.py
index fb178b7e..c5743557 100644
--- a/app/api/stems.py
+++ b/app/api/stems.py
@@ -216,6 +216,26 @@ class _ClickLane(NamedTuple):
count_in: bool
+def _parse_groups(raw: str, accent_mode: int) -> list[int] | None:
+ """Parse a "3+2+2" grouping, or None to use the default.
+
+ Rejected wholesale rather than repaired, and never a 422: a grouping that
+ does not fit the bar would put accents on beats the user never asked for,
+ and an export is better served with the default grouping than refused. The
+ same rule the browser applies in transport.js::_applyGrouping, so a URL a
+ user cannot produce from the UI degrades to what the UI would have sent.
+
+ Only meaningful with an explicit meter -- under Auto each detected bar is
+ grouped by its own length, so one grouping cannot be assumed to fit.
+ """
+ if not raw or accent_mode <= 0:
+ return None
+ parts = [int(p) for p in re.findall(r"\d+", raw)[:32]]
+ if not parts or any(p < 1 for p in parts) or sum(parts) != accent_mode:
+ return None
+ return parts
+
+
def _click_lane(
job_id: str,
enabled: bool,
@@ -225,6 +245,7 @@ def _click_lane(
count_in_bars: int = 0,
start: float | None = None,
end: float | None = None,
+ groups: list[int] | None = None,
) -> _ClickLane | None:
"""Render (or reuse) the click / count-in track for this job as an extra
ffmpeg input, or None when both are off or the job has no beat grid.
@@ -260,6 +281,7 @@ def _click_lane(
include_click=enabled,
start=start,
end=end,
+ groups=groups,
)
path = _CLICK_CACHE_DIR / f"{key}.wav"
@@ -267,7 +289,7 @@ def _click_lane(
# lead_in is a pure function of the grid; recompute it even on a cache
# hit so the caller can delay the stems without re-reading the WAV.
lead_in, _ = count_in_beats(
- beats, bars, count_in_bars, multiplier, accent_mode, start=start or 0.0
+ beats, bars, count_in_bars, multiplier, accent_mode, start=start or 0.0, groups=groups
)
if not path.is_file():
try:
@@ -283,6 +305,7 @@ def _click_lane(
include_click=enabled,
start=start or 0.0,
end=end,
+ groups=groups,
)
except Exception:
logger.exception("count-in render failed for %s", job_id)
@@ -305,6 +328,7 @@ def _click_lane(
sample_rate=sample_rate,
multiplier=multiplier,
accent_mode=accent_mode,
+ groups=groups,
)
except Exception:
logger.exception("click render failed for %s", job_id)
@@ -804,6 +828,11 @@ async def get_mixdown(
click_mult: float = Query(default=1.0, description="Click rate: 0.5, 1 or 2"),
click_accent: int = Query(default=-1, ge=-1, le=32, description="-1 auto, 0 off, N per bar"),
click_gain: float = Query(default=0.6, ge=0, le=4, description="Click level"),
+ click_groups: str = Query(
+ default="",
+ max_length=64,
+ description='Beat grouping for an explicit meter, e.g. "3+2+2". Blank uses the default.',
+ ),
count_in: int = Query(
# Ceiling matches MAX_COUNT_IN_BARS in static/js/state.js, the longest
# count-in the UI can ask for (#587). The lead-in it prepends is
@@ -858,6 +887,7 @@ async def get_mixdown(
count_in_bars=count_in,
start=start,
end=end,
+ groups=_parse_groups(click_groups, click_accent),
)
cache_key = _mixdown_cache_key(job_id, ext, names, parsed_gains, start, end, click_lane)
cache_path = _MIXDOWN_CACHE_DIR / f"{cache_key}.{ext}"
diff --git a/app/pipeline/click_render.py b/app/pipeline/click_render.py
index e94709a2..08119a69 100644
--- a/app/pipeline/click_render.py
+++ b/app/pipeline/click_render.py
@@ -24,11 +24,23 @@
# Mirrors the constants at the top of static/js/metronome.js. Changing either
# side without the other makes exports diverge from playback.
CLICK_FREQ = 1000.0
+# Group starts sit between the downbeat and a plain beat in both pitch and
+# level, so a grouped bar reads as "strong, medium, weak" rather than as three
+# identical accents (#595). Geometric middle of the two existing voices: the ear
+# hears pitch ratios, so 1225 is the midpoint of 1000 and 1500, not 1250.
+GROUP_FREQ = 1225.0
ACCENT_FREQ = 1500.0
CLICK_DECAY = 0.035
CLICK_ATTACK = 0.001
CLICK_PEAK = 0.7
+GROUP_PEAK = 0.85
ACCENT_PEAK = 1.0
+
+# Click strength at one beat. The renderer and static/js/metronome.js must agree
+# on these three, or a monitored click and an exported one differ.
+LEVEL_WEAK = 0
+LEVEL_GROUP = 1
+LEVEL_DOWNBEAT = 2
# exponentialRampToValueAtTime cannot start from zero, so the scheduler ramps
# from this floor; matching it keeps the attack shape identical.
RAMP_FLOOR = 0.0001
@@ -40,6 +52,62 @@
_VALID_MULTIPLIERS = (0.5, 1.0, 2.0)
+def default_grouping(beats_per_bar: int) -> list[int]:
+ """How a bar of `beats_per_bar` divides, when the user has not said.
+
+ Odd and compound meters carry internal stresses that a flat bar does not
+ express: 7 is played 3+2+2, 6 is two dotted-quarter groups, 5 is 3+2. The
+ click has to mark those or it gives a player nothing to lock onto (#595).
+
+ 2, 3 and 4 deliberately return a single group, leaving them exactly as they
+ sounded before. 4/4 does carry a real secondary stress on beat 3, but
+ turning that on by default would change the most common meter in the app for
+ every existing user, which is a decision for them and not a default.
+
+ Mirrored by defaultGrouping() in static/js/metronome.js -- keep both in step.
+ """
+ if beats_per_bar < 1:
+ return []
+ if beats_per_bar in (5, 7):
+ # 3 first: the long group leads in both, which is the commoner reading
+ # (Take Five is 3+2, and 7/8 is more often 3+2+2 than 2+2+3).
+ return [3] + [2] * ((beats_per_bar - 3) // 2)
+ if beats_per_bar >= 6 and beats_per_bar % 3 == 0:
+ # Compound: 6, 9 and 12 are felt in dotted-quarter groups of three.
+ return [3] * (beats_per_bar // 3)
+ return [beats_per_bar]
+
+
+def normalise_grouping(groups: list[int] | None, beats_per_bar: int) -> list[int]:
+ """A grouping that is safe to index a bar with, or the default.
+
+ Anything that is not a list of positive ints summing to the bar length is
+ rejected wholesale rather than repaired: a half-understood grouping would
+ put accents on beats the user never asked for, and silently playing the
+ default is the honest failure.
+ """
+ if beats_per_bar < 1:
+ return []
+ if not groups:
+ return default_grouping(beats_per_bar)
+ if not all(isinstance(g, int) and g >= 1 for g in groups):
+ return default_grouping(beats_per_bar)
+ if sum(groups) != beats_per_bar:
+ return default_grouping(beats_per_bar)
+ return list(groups)
+
+
+def group_offsets(groups: list[int]) -> set[int]:
+ """Beat offsets inside a bar that start a group, excluding the downbeat
+ (which is already the stronger LEVEL_DOWNBEAT)."""
+ offsets: set[int] = set()
+ at = 0
+ for g in groups[:-1]:
+ at += g
+ offsets.add(at)
+ return offsets
+
+
def rescale_beats(beats: list[float], multiplier: float) -> list[float]:
"""Apply the playback rate multiplier. Mirrors `_rescale` in metronome.js:
doubling inserts midpoints, halving takes every other beat, and both derive
@@ -72,12 +140,16 @@ def source_index(i: int, multiplier: float) -> int | None:
return i
-def is_downbeat(index: int | None, bars: list[dict], accent_mode: int) -> bool:
- """Whether the beat at `index` (original grid) carries an accent."""
- if index is None or accent_mode == ACCENT_OFF:
- return False
+def _bar_position(index: int, bars: list[dict], accent_mode: int) -> tuple[int, int] | None:
+ """(offset into the bar, bar length) for a beat, or None when no bar applies.
+
+ Split out of is_downbeat so grouping can ask *where* in the bar a beat falls,
+ not merely whether it is the first one (#595).
+ """
+ if accent_mode == ACCENT_OFF:
+ return None
if accent_mode > 0:
- return index % accent_mode == 0
+ return index % accent_mode, accent_mode
# Auto: follow the last bar mark at or before this beat.
mark = None
for b in bars:
@@ -87,11 +159,45 @@ def is_downbeat(index: int | None, bars: list[dict], accent_mode: int) -> bool:
else:
break
if mark is None:
- return False
+ return None
per_bar = mark.get("beats_per_bar")
if not isinstance(per_bar, int) or per_bar < 1:
+ return None
+ return (index - mark["beat"]) % per_bar, per_bar
+
+
+def is_downbeat(index: int | None, bars: list[dict], accent_mode: int) -> bool:
+ """Whether the beat at `index` (original grid) carries an accent."""
+ if index is None:
return False
- return (index - mark["beat"]) % per_bar == 0
+ pos = _bar_position(index, bars, accent_mode)
+ return pos is not None and pos[0] == 0
+
+
+def beat_level(
+ index: int | None, bars: list[dict], accent_mode: int, groups: list[int] | None = None
+) -> int:
+ """How strongly the beat at `index` is clicked: downbeat, group start, weak.
+
+ `groups` applies only to an explicit meter. Under Auto the bar length can
+ change from bar to bar, so a single user-supplied grouping cannot be assumed
+ to fit every bar; each bar falls back to the default for its own length,
+ which is what makes a detected 6/8 passage group in threes without the user
+ configuring anything.
+ """
+ if index is None:
+ return LEVEL_WEAK
+ pos = _bar_position(index, bars, accent_mode)
+ if pos is None:
+ return LEVEL_WEAK
+ offset, per_bar = pos
+ if offset == 0:
+ return LEVEL_DOWNBEAT
+ if accent_mode > 0:
+ bar_groups = normalise_grouping(groups, per_bar)
+ else:
+ bar_groups = default_grouping(per_bar)
+ return LEVEL_GROUP if offset in group_offsets(bar_groups) else LEVEL_WEAK
def count_in_beats_per_bar(bars: list[dict], accent_mode: int, start_index: int = 0) -> int:
@@ -150,15 +256,20 @@ def count_in_beats(
multiplier: float = 1.0,
accent_mode: int = ACCENT_AUTO,
start: float = 0.0,
-) -> tuple[float, list[tuple[float, bool]]]:
+ groups: list[int] | None = None,
+) -> tuple[float, list[tuple[float, int]]]:
"""Compute the count-in that leads into playback at `start`.
Returns `(lead_in, clicks)` where `lead_in` is the seconds of pre-roll to
- prepend and `clicks` is `[(offset, accent), ...]` with each offset in
+ prepend and `clicks` is `[(offset, level), ...]` with each offset in
`[0, lead_in)`. One bar of the detected meter counts in by default:
`PI po po po` on 4/4, the final click landing one beat before the audio so
the song enters on the next downbeat.
+ The count-in is grouped exactly as the running click is (#595), so counting
+ a player into 7/8 gives them the 3+2+2 pulse they are about to play rather
+ than seven flat clicks.
+
Pure and side-effect free so playback (metronome.js) and export
(render_click_wav) can share one definition -- pinned by
tests/test_click_render.py, exactly like rescale/source_index/is_downbeat.
@@ -180,7 +291,21 @@ def count_in_beats(
return 0.0, []
n = count_bars * bpb
lead_in = n * interval
- clicks = [(j * interval, (j % bpb) == 0) for j in range(n)]
+ # The count-in is its own run of bars, so its grouping comes from bpb
+ # directly rather than from the bar marks: there is no detected bar to
+ # consult in front of the audio.
+ bar_groups = normalise_grouping(groups, bpb) if accent_mode > 0 else default_grouping(bpb)
+ starts = group_offsets(bar_groups)
+ clicks = []
+ for j in range(n):
+ offset = j % bpb
+ if offset == 0:
+ level = LEVEL_DOWNBEAT
+ elif offset in starts:
+ level = LEVEL_GROUP
+ else:
+ level = LEVEL_WEAK
+ clicks.append((j * interval, level))
return lead_in, clicks
@@ -212,6 +337,7 @@ def cache_key(
include_click: bool = True,
start: float | None = None,
end: float | None = None,
+ groups: list[int] | None = None,
) -> str:
"""Every input to the render is in the key. Beats are included by digest
rather than by job id alone: an edited grid must not hit a cache entry
@@ -228,6 +354,11 @@ def cache_key(
).hexdigest()
bar_sig = ",".join(f"{b.get('beat')}:{b.get('beats_per_bar')}" for b in bars)
raw = f"{job_id}|{grid}|{bar_sig}|{duration:.3f}|{sample_rate}|{multiplier}|{accent_mode}"
+ # Appended only when a grouping is actually in force, so every export that
+ # predates grouping keeps the exact key it had and the cache survives the
+ # change. Without this a re-grouped render would serve the old flat file.
+ if groups:
+ raw += f"|g{'+'.join(str(int(g)) for g in groups)}"
if count_in_bars > 0:
seg = f"{'' if start is None else f'{start:.3f}'}:{'' if end is None else f'{end:.3f}'}"
raw += f"|ci{count_in_bars}|clk{int(include_click)}|{seg}"
@@ -235,23 +366,28 @@ def cache_key(
def _song_click_events(
- beats: list[float], bars: list[dict], multiplier: float, accent_mode: int
-) -> list[tuple[float, bool]]:
- """The (time, accent) pair for every beat of the running click track, in
+ beats: list[float],
+ bars: list[dict],
+ multiplier: float,
+ accent_mode: int,
+ groups: list[int] | None = None,
+) -> list[tuple[float, int]]:
+ """The (time, level) pair for every beat of the running click track, in
source time. Shared by the plain click render and the count-in render so the
click sounds identical whether or not a count-in precedes it."""
grid = rescale_beats([float(b) for b in beats], multiplier)
return [
- (t, is_downbeat(source_index(i, multiplier), bars, accent_mode)) for i, t in enumerate(grid)
+ (t, beat_level(source_index(i, multiplier), bars, accent_mode, groups))
+ for i, t in enumerate(grid)
]
def _render_events(
- dest: Path, events: list[tuple[float, bool]], duration: float, sample_rate: int
+ dest: Path, events: list[tuple[float, int]], duration: float, sample_rate: int
) -> Path | None:
- """Stamp a list of (time, accent) clicks into a mono WAV of `duration`
+ """Stamp a list of (time, level) clicks into a mono WAV of `duration`
seconds. The single place clicks become audio, so playback parity only has
- to be maintained against the two voices, not against two render paths."""
+ to be maintained against the three voices, not against two render paths."""
total = int(round(duration * sample_rate))
if not events or total <= 0:
return None
@@ -263,15 +399,18 @@ def _render_events(
# again in the int16 conversion below. The output is 16-bit PCM, so the
# extra mantissa was never audible (#512).
buf = np.zeros(total, dtype=np.float32)
- # Only two distinct voices, so render each once and stamp it in.
- plain = _voice(CLICK_PEAK, CLICK_FREQ, sample_rate)
- accented = _voice(ACCENT_PEAK, ACCENT_FREQ, sample_rate)
-
- for t, accent in events:
+ # Only three distinct voices, so render each once and stamp it in.
+ voices = {
+ LEVEL_WEAK: _voice(CLICK_PEAK, CLICK_FREQ, sample_rate),
+ LEVEL_GROUP: _voice(GROUP_PEAK, GROUP_FREQ, sample_rate),
+ LEVEL_DOWNBEAT: _voice(ACCENT_PEAK, ACCENT_FREQ, sample_rate),
+ }
+
+ for t, level in events:
start = int(round(t * sample_rate))
if start >= total or start < 0:
continue
- voice = accented if accent else plain
+ voice = voices.get(int(level), voices[LEVEL_WEAK])
n = min(len(voice), total - start)
# Clicks can overlap at very fast tempos; summing matches the graph,
# where every click is its own node into the same gain.
@@ -299,6 +438,7 @@ def render_click_wav(
sample_rate: int = 44100,
multiplier: float = 1.0,
accent_mode: int = ACCENT_AUTO,
+ groups: list[int] | None = None,
) -> Path | None:
"""Write a mono WAV of the click track spanning the whole track.
@@ -309,7 +449,7 @@ def render_click_wav(
"""
if multiplier not in _VALID_MULTIPLIERS:
multiplier = 1.0
- events = _song_click_events(beats, bars, multiplier, accent_mode)
+ events = _song_click_events(beats, bars, multiplier, accent_mode, groups)
out = _render_events(dest, events, duration, sample_rate)
if out is not None:
logger.info("click render: %d beats, %.1f s -> %s", len(events), duration, dest.name)
@@ -328,6 +468,7 @@ def render_count_in_wav(
include_click: bool = True,
start: float = 0.0,
end: float | None = None,
+ groups: list[int] | None = None,
) -> tuple[Path, float] | None:
"""Render the click WAV for a count-in export, in *output* coordinates.
@@ -345,13 +486,13 @@ def render_count_in_wav(
seg_len = max(0.0, seg_end - seg_start)
lead_in, count_clicks = count_in_beats(
- beats, bars, count_in_bars, multiplier, accent_mode, start=seg_start
+ beats, bars, count_in_bars, multiplier, accent_mode, start=seg_start, groups=groups
)
- events: list[tuple[float, bool]] = list(count_clicks)
+ events: list[tuple[float, int]] = list(count_clicks)
if include_click:
- for t, accent in _song_click_events(beats, bars, multiplier, accent_mode):
+ for t, level in _song_click_events(beats, bars, multiplier, accent_mode, groups):
if seg_start <= t < seg_end:
- events.append((t - seg_start + lead_in, accent))
+ events.append((t - seg_start + lead_in, level))
out = _render_events(dest, events, lead_in + seg_len, sample_rate)
if out is None:
diff --git a/static/css/daw.css b/static/css/daw.css
index 9dc03447..992dfdad 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -2847,6 +2847,19 @@ input, textarea { font-family: inherit; }
.metro-custom-beats:hover { background: var(--panel-3); }
.metro-custom-beats:focus { border-color: var(--accent); }
+/* Beat grouping for the current meter, e.g. 3+2+2 (#595). Shown only when the
+ meter has more than one sensible reading. */
+.metro-group {
+ width: 74px; height: 34px; padding: 0 8px;
+ background: var(--panel); border: 1px solid var(--border-strong);
+ border-radius: 8px; color: var(--fg);
+ font-family: inherit; font-size: 12px; font-weight: 500; outline: none;
+ font-variant-numeric: tabular-nums; flex-shrink: 0; text-align: center;
+}
+.metro-group:hover { background: var(--panel-3); }
+.metro-group:focus { border-color: var(--accent); }
+.metro-group::placeholder { color: var(--muted); }
+
/* Detection confidence / unavailability note: its own line under the
waveform, where it reads as a caption on the timeline it describes (design
1b). Hidden until it has text, clipped to one line with the full text as a
diff --git a/static/index.html b/static/index.html
index f45b9466..97964e38 100644
--- a/static/index.html
+++ b/static/index.html
@@ -778,6 +778,7 @@
+
diff --git a/static/js/beatgrid.js b/static/js/beatgrid.js
index 9cedd640..4d55d656 100644
--- a/static/js/beatgrid.js
+++ b/static/js/beatgrid.js
@@ -418,6 +418,23 @@ export function isDownbeatIndex(i) {
return _isDownbeat(i);
}
+/**
+ * Where a beat sits inside its bar, as `[offset, barLength]`, or null when no
+ * bar mark applies. The metronome needs the position rather than a downbeat
+ * yes/no so it can group an odd or compound bar (#595); `_bar_position` in
+ * app/pipeline/click_render.py is the same lookup on the export side.
+ */
+export function barPositionIndex(i) {
+ if (!_bars.length) return null;
+ let mark = null;
+ for (const b of _bars) {
+ if (b.beat <= i) mark = b;
+ else break;
+ }
+ if (!mark || !Number.isInteger(mark.beats_per_bar) || mark.beats_per_bar < 1) return null;
+ return [(i - mark.beat) % mark.beats_per_bar, mark.beats_per_bar];
+}
+
// ─── hit testing + pointer handling ─────────────────────────
function _nearestBeat(clientX) {
diff --git a/static/js/i18n.js b/static/js/i18n.js
index b1169ad2..f0bc1585 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -447,6 +447,8 @@ const en = {
"click.countIn4": "Count-in: 4 bars",
"click.barCustom": "Custom...",
"click.barCustomTitle": "Beats per bar",
+ "click.groupTitle": "How the bar divides, e.g. 3+2+2. Blank uses the usual grouping for the meter.",
+ "click.groupAria": "Beat grouping",
"click.barAccentAria": "Accent every N beats",
"click.auto": "Auto (detected)",
"click.autoNone": "Auto (none found)",
@@ -1053,6 +1055,8 @@ const pl = {
"click.countIn4": "Odliczanie: 4 takty",
"click.barCustom": "Własne...",
"click.barCustomTitle": "Uderzenia na takt",
+ "click.groupTitle": "Jak dzieli się takt, np. 3+2+2. Puste pole używa typowego podziału dla metrum.",
+ "click.groupAria": "Podział taktu",
"click.barAccentAria": "Akcentuj co N uderzeń",
"click.auto": "Auto (wykryto)",
"click.autoNone": "Auto (nie znaleziono)",
@@ -1650,6 +1654,8 @@ const ja = {
"click.countIn4": "カウントイン: 4小節",
"click.barCustom": "カスタム...",
"click.barCustomTitle": "1小節の拍数",
+ "click.groupTitle": "小節の区切り方 (例: 3+2+2)。空欄ならその拍子の一般的な区切りを使います。",
+ "click.groupAria": "拍のグループ分け",
"click.barAccentAria": "N拍ごとにアクセント",
"click.auto": "自動 (検出済み)",
"click.autoNone": "自動 (未検出)",
@@ -2223,6 +2229,8 @@ const zhHans = {
"click.countIn4": "预备拍: 4 小节",
"click.barCustom": "自定义...",
"click.barCustomTitle": "每小节拍数",
+ "click.groupTitle": "小节如何划分,例如 3+2+2。留空则使用该拍号的常用分组。",
+ "click.groupAria": "节拍分组",
"click.barAccentAria": "每 N 拍重音一次",
"click.auto": "自动(已检测)",
"click.autoNone": "自动(未检测到)",
@@ -2797,6 +2805,8 @@ const de = {
"click.countIn4": "Vorzähler: 4 Takte",
"click.barCustom": "Eigene...",
"click.barCustomTitle": "Schläge pro Takt",
+ "click.groupTitle": "Wie sich der Takt teilt, z. B. 3+2+2. Leer nutzt die übliche Gruppierung des Taktes.",
+ "click.groupAria": "Taktgruppierung",
"click.barAccentAria": "Jeden N. Beat betonen",
"click.auto": "Auto (erkannt)",
"click.autoNone": "Auto (nicht gefunden)",
@@ -3381,6 +3391,8 @@ const pt = {
"click.countIn4": "Contagem: 4 compassos",
"click.barCustom": "Personalizado...",
"click.barCustomTitle": "Batidas por compasso",
+ "click.groupTitle": "Como o compasso se divide, ex. 3+2+2. Em branco usa o agrupamento usual do compasso.",
+ "click.groupAria": "Agrupamento de batidas",
"click.barAccentAria": "Acentuar a cada N batidas",
"click.auto": "Auto (detectado)",
"click.autoNone": "Auto (nenhum encontrado)",
@@ -3966,6 +3978,8 @@ const id = {
"click.countIn4": "Hitungan awal: 4 birama",
"click.barCustom": "Khusus...",
"click.barCustomTitle": "Ketukan per birama",
+ "click.groupTitle": "Cara birama dibagi, mis. 3+2+2. Kosong memakai pengelompokan biasa untuk birama itu.",
+ "click.groupAria": "Pengelompokan ketukan",
"click.barAccentAria": "Aksen setiap N ketukan",
"click.auto": "Otomatis (terdeteksi)",
"click.autoNone": "Otomatis (tidak ditemukan)",
@@ -4540,6 +4554,8 @@ const fr = {
"click.countIn4": "Décompte : 4 mesures",
"click.barCustom": "Personnalisé...",
"click.barCustomTitle": "Temps par mesure",
+ "click.groupTitle": "Comment la mesure se divise, ex. 3+2+2. Vide utilise le groupement habituel de la mesure.",
+ "click.groupAria": "Groupement des temps",
"click.barAccentAria": "Accentuer tous les N temps",
"click.auto": "Auto (détecté)",
"click.autoNone": "Auto (aucun trouvé)",
@@ -5242,6 +5258,8 @@ const es = {
"click.countIn4": "Cuenta de entrada: 4 compases",
"click.barCustom": "Personalizado...",
"click.barCustomTitle": "Tiempos por compás",
+ "click.groupTitle": "Cómo se divide el compás, p. ej. 3+2+2. En blanco usa la agrupación habitual del compás.",
+ "click.groupAria": "Agrupación de tiempos",
"click.barAccentAria": "Acentuar cada N tiempos",
"click.auto": "Automático (detectado)",
"click.autoNone": "Automático (no se detectó)",
@@ -5848,6 +5866,8 @@ const ko = {
"click.countIn4": "카운트인: 4마디",
"click.barCustom": "사용자 지정...",
"click.barCustomTitle": "마디당 박자",
+ "click.groupTitle": "마디를 나누는 방식 (예: 3+2+2). 비워 두면 해당 박자의 일반적인 묶음을 사용합니다.",
+ "click.groupAria": "박자 묶음",
"click.barAccentAria": "N 비트마다 강세",
"click.auto": "자동 (감지됨)",
"click.autoNone": "자동 (찾지 못함)",
diff --git a/static/js/metronome.js b/static/js/metronome.js
index 18792b62..93a9ed1f 100644
--- a/static/js/metronome.js
+++ b/static/js/metronome.js
@@ -28,10 +28,77 @@ const TICK_MS = 50;
// file; the short exponential decay keeps the transient tight so it reads as
// percussive rather than tonal.
const CLICK_FREQ = 1000;
+// Group starts sit between the downbeat and a plain beat in pitch and level, so
+// a grouped bar reads as strong/medium/weak rather than as identical accents
+// (#595). Geometric middle of the two: the ear hears pitch ratios, so the
+// midpoint of 1000 and 1500 is 1225, not 1250. Mirrored in click_render.py.
+const GROUP_FREQ = 1225;
const ACCENT_FREQ = 1500;
const CLICK_DECAY = 0.035;
const CLICK_ATTACK = 0.001;
+// Click strength at one beat. click_render.py defines the same three.
+export const LEVEL_WEAK = 0;
+export const LEVEL_GROUP = 1;
+export const LEVEL_DOWNBEAT = 2;
+
+/**
+ * How a bar of `beatsPerBar` divides when the user has not said.
+ *
+ * Mirror of default_grouping() in app/pipeline/click_render.py -- keep both in
+ * step. 2, 3 and 4 deliberately return one group so they sound exactly as they
+ * did; 4/4's real secondary stress on beat 3 is a choice for the user, not a
+ * default that changes the commonest meter for everyone.
+ * @param {number} beatsPerBar
+ * @returns {number[]}
+ */
+export function defaultGrouping(beatsPerBar) {
+ if (!Number.isInteger(beatsPerBar) || beatsPerBar < 1) return [];
+ if (beatsPerBar === 5 || beatsPerBar === 7) {
+ // Long group first: Take Five is 3+2, and 7/8 is more often 3+2+2.
+ return [3, ...Array((beatsPerBar - 3) >> 1).fill(2)];
+ }
+ // Compound: 6, 9 and 12 are felt in dotted-quarter groups of three.
+ if (beatsPerBar >= 6 && beatsPerBar % 3 === 0) return Array(beatsPerBar / 3).fill(3);
+ return [beatsPerBar];
+}
+
+/**
+ * A grouping safe to index a bar with, or the default. Anything that is not a
+ * list of positive integers summing to the bar length is rejected wholesale
+ * rather than repaired: a half-understood grouping would accent beats the user
+ * never asked for, so falling back to the default is the honest failure.
+ * @param {number[]|null|undefined} groups
+ * @param {number} beatsPerBar
+ * @returns {number[]}
+ */
+export function normaliseGrouping(groups, beatsPerBar) {
+ if (!Number.isInteger(beatsPerBar) || beatsPerBar < 1) return [];
+ if (!Array.isArray(groups) || !groups.length) return defaultGrouping(beatsPerBar);
+ if (!groups.every((g) => Number.isInteger(g) && g >= 1)) return defaultGrouping(beatsPerBar);
+ if (groups.reduce((a, b) => a + b, 0) !== beatsPerBar) return defaultGrouping(beatsPerBar);
+ return groups.slice();
+}
+
+/** Beat offsets inside a bar that start a group, excluding the downbeat. */
+export function groupOffsets(groups) {
+ const out = new Set();
+ let at = 0;
+ for (let i = 0; i < groups.length - 1; i++) {
+ at += groups[i];
+ out.add(at);
+ }
+ return out;
+}
+
+/** The click level for a beat `offset` into a bar of `beatsPerBar`. */
+export function levelAt(offset, beatsPerBar, groups) {
+ if (offset === 0) return LEVEL_DOWNBEAT;
+ return groupOffsets(normaliseGrouping(groups, beatsPerBar)).has(offset)
+ ? LEVEL_GROUP
+ : LEVEL_WEAK;
+}
+
// ─── Count-in (issue #269) ───────────────────────────────────────────────
//
// A count-in is one bar of click *before* playback, leading into the start
@@ -84,13 +151,15 @@ function _intervalNear(grid, start, span) {
/**
* The count-in that leads into playback at `start`.
- * @returns {{leadIn:number, clicks:{offset:number, accent:boolean}[]}}
+ * @returns {{leadIn:number, clicks:{offset:number, level:number, accent:boolean}[]}}
* `leadIn` seconds of pre-roll, and clicks at offsets in `[0, leadIn)`.
+ * `level` is LEVEL_WEAK/GROUP/DOWNBEAT; `accent` stays as the downbeat-only
+ * boolean so callers that only care about "is this the 1" keep working.
*/
export function computeCountIn(
beats,
bars,
- { countBars = 1, multiplier = 1, accentMode = -1, start = 0 } = {},
+ { countBars = 1, multiplier = 1, accentMode = -1, start = 0, groups = null } = {},
) {
if (countBars < 1) return { leadIn: 0, clicks: [] };
const clean = Array.isArray(beats) ? beats.filter((b) => Number.isFinite(b)) : [];
@@ -104,8 +173,17 @@ export function computeCountIn(
const interval = _intervalNear(grid, start, bpb);
if (interval === null) return { leadIn: 0, clicks: [] };
const n = countBars * bpb;
+ // The count-in is its own run of bars, so the grouping comes from bpb rather
+ // than from the bar marks: there is no detected bar in front of the audio.
+ // A user grouping only applies to an explicit meter, matching count_in_beats.
+ const barGroups = accentMode > 0 ? normaliseGrouping(groups, bpb) : defaultGrouping(bpb);
+ const starts = groupOffsets(barGroups);
const clicks = [];
- for (let j = 0; j < n; j++) clicks.push({ offset: j * interval, accent: j % bpb === 0 });
+ for (let j = 0; j < n; j++) {
+ const offset = j % bpb;
+ const level = offset === 0 ? LEVEL_DOWNBEAT : starts.has(offset) ? LEVEL_GROUP : LEVEL_WEAK;
+ clicks.push({ offset: j * interval, level, accent: offset === 0 });
+ }
return { leadIn: n * interval, clicks };
}
@@ -144,6 +222,10 @@ export function createMetronome(engine, beats, { volume = 0.6, beatsPerBar = 0 }
// into the *current* grid, is it a downbeat? When null, accents fall back to
// a fixed beats-per-bar count from the start of the track.
let _isDownbeat = null;
+ // Bar-position lookup (offset, barLength) so bar marks can be grouped, not
+ // merely accented on the 1. Falls back to _isDownbeat when absent (#595).
+ let _barPosition = null;
+ let _groups = null;
// Map an index in the rescaled grid back to the original beat it came from.
// Bar marks are recorded against the *detected* beats, so accents have to be
@@ -225,17 +307,23 @@ export function createMetronome(engine, beats, { volume = 0.6, beatsPerBar = 0 }
// Schedule one click to sound at AudioContext time `when`. `sink` is the list
// it registers itself in so the right batch can be torn down independently
// (the running click's `queued`, or the count-in's `countInQueued`).
- function _scheduleClick(when, accent, sink = queued) {
+ function _scheduleClick(when, level, sink = queued) {
+ // Booleans still arrive from callers that only distinguish the downbeat.
+ const lv = level === true ? LEVEL_DOWNBEAT : level === false ? LEVEL_WEAK : Number(level) || 0;
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = "sine";
- osc.frequency.value = accent ? ACCENT_FREQ : CLICK_FREQ;
+ osc.frequency.value =
+ lv === LEVEL_DOWNBEAT ? ACCENT_FREQ : lv === LEVEL_GROUP ? GROUP_FREQ : CLICK_FREQ;
// Ramp from near-silence rather than 0: setValueAtTime(0) followed by an
// exponential ramp is a no-op in the spec (exponential ramps cannot start
// at zero), which would leave the click at full level and click twice.
env.gain.setValueAtTime(0.0001, when);
- env.gain.exponentialRampToValueAtTime(accent ? 1.0 : 0.7, when + CLICK_ATTACK);
+ env.gain.exponentialRampToValueAtTime(
+ lv === LEVEL_DOWNBEAT ? 1.0 : lv === LEVEL_GROUP ? 0.85 : 0.7,
+ when + CLICK_ATTACK,
+ );
env.gain.exponentialRampToValueAtTime(0.0001, when + CLICK_DECAY);
osc.connect(env);
@@ -291,10 +379,22 @@ export function createMetronome(engine, beats, { volume = 0.6, beatsPerBar = 0 }
// Bar marks from the editor win over the fixed beats-per-bar count:
// on a track whose meter changes, a fixed count is meaningless.
const src = _sourceIndex(cursor);
- const accent = _isDownbeat
- ? src !== null && !!_isDownbeat(src)
- : (_beatsPerBar > 0 && src !== null && src % _beatsPerBar === 0);
- _scheduleClick(when, accent);
+ let level = LEVEL_WEAK;
+ if (src !== null) {
+ const pos = _barPosition ? _barPosition(src) : null;
+ if (pos) {
+ // Bar marks: each bar is grouped by its own length, so a detected
+ // 6/8 passage groups in threes with nothing configured. A user
+ // grouping is not applied here -- under bar marks the length can
+ // change bar to bar, so one grouping cannot be assumed to fit.
+ level = levelAt(pos[0], pos[1], null);
+ } else if (_isDownbeat) {
+ level = _isDownbeat(src) ? LEVEL_DOWNBEAT : LEVEL_WEAK;
+ } else if (_beatsPerBar > 0) {
+ level = levelAt(src % _beatsPerBar, _beatsPerBar, _groups);
+ }
+ }
+ _scheduleClick(when, level);
}
cursor++;
}
@@ -339,13 +439,21 @@ export function createMetronome(engine, beats, { volume = 0.6, beatsPerBar = 0 }
_cancelCountIn();
for (const c of clicks) {
const when = engine.sourceTimeToCtxTime(c.time);
- if (when > ctx.currentTime) _scheduleClick(when, !!c.accent, countInQueued);
+ if (when > ctx.currentTime) _scheduleClick(when, c.level ?? (c.accent ? LEVEL_DOWNBEAT : LEVEL_WEAK), countInQueued);
}
},
/** Tear down a count-in already handed to the audio clock (pause/stop). */
cancelCountIn() {
if (!destroyed) _cancelCountIn();
},
+ /** Bar-position lookup used to group bar-marked bars; null to drop it. */
+ setBarPositionFn(fn) {
+ _barPosition = typeof fn === "function" ? fn : null;
+ },
+ /** Grouping for the explicit meter, e.g. [3,2,2]; null for the default. */
+ setGrouping(groups) {
+ _groups = Array.isArray(groups) && groups.length ? groups.slice() : null;
+ },
/** 0 disables accents; otherwise accent every Nth beat from the grid start. */
setBeatsPerBar(n) {
_beatsPerBar = Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
diff --git a/static/js/player.js b/static/js/player.js
index 86ea02e3..091581d6 100644
--- a/static/js/player.js
+++ b/static/js/player.js
@@ -24,7 +24,7 @@ import {
footerTitle, footerMeta, footerThumb,
setFooterWaveDrawFn, setOverviewRerenderFn, autoSectionsResetFn,
metronome, setMetronome, metronomeEnabled, metronomeVolume, metronomeBeatsPerBar,
- metronomeCountInBars,
+ metronomeCountInBars, metronomeGrouping,
exportClickEl, exportClickWrap, exportCountInEl, exportCountInWrap,
setMetronomeHasBars,
} from "./state.js";
@@ -1826,6 +1826,7 @@ function _clickParams(q) {
q.set("click_mult", String(metronome?.getMultiplier?.() ?? 1));
q.set("click_accent", String(metronomeBeatsPerBar));
q.set("click_gain", metronomeVolume.toFixed(3));
+ if (metronomeGrouping) q.set("click_groups", metronomeGrouping.join("+"));
}
// Count-in export param (issue #269). Independent of the running click track:
@@ -1845,6 +1846,7 @@ function _countInParam(q) {
q.set("click_mult", String(metronome?.getMultiplier?.() ?? 1));
q.set("click_accent", String(metronomeBeatsPerBar));
q.set("click_gain", metronomeVolume.toFixed(3));
+ if (metronomeGrouping) q.set("click_groups", metronomeGrouping.join("+"));
}
/** Whether this track can export a click / count-in at all (needs a beat grid). */
diff --git a/static/js/state.js b/static/js/state.js
index 606ca5a6..3bd1f856 100644
--- a/static/js/state.js
+++ b/static/js/state.js
@@ -65,6 +65,7 @@ export const metroOneBtn = $("t-metro-one");
export const metroDoubleBtn = $("t-metro-double");
export const metroCountInEl = $("t-metro-countin");
export const metroBarCustomEl = $("t-metro-bar-custom");
+export const metroGroupEl = $("t-metro-group");
export const metroEditBtn = $("t-metro-edit");
export const exportClickEl = $("t-export-click");
export const exportClickWrap = $("t-export-click-wrap");
@@ -242,6 +243,14 @@ export function setMetronomeVolume(v) { metronomeVolume = v; }
// every N beats from the top of the track.
export let metronomeBeatsPerBar = -1;
export function setMetronomeBeatsPerBar(v) { metronomeBeatsPerBar = v; }
+// How the bar subdivides, e.g. [3,2,2] for a 7 played 3+2+2 (issue #595). null
+// means "use the default for this bar length". Only meaningful with an explicit
+// meter: under Auto the bar length can change bar to bar, so each detected bar
+// is grouped by its own default instead.
+export let metronomeGrouping = null;
+export function setMetronomeGrouping(v) {
+ metronomeGrouping = Array.isArray(v) && v.length ? v.slice() : null;
+}
// Whether the current track's grid carries detected bar marks at all. Without
// them "Auto" has nothing to follow and behaves as no accent.
export let metronomeHasBars = false;
diff --git a/static/js/transport.js b/static/js/transport.js
index 05909ded..b832beea 100644
--- a/static/js/transport.js
+++ b/static/js/transport.js
@@ -15,18 +15,19 @@ import {
presenceRulerEl, presencePlayheadEl,
footerTimeElapsed, footerTimeTotal, footerWaveTicks, npScrubFill, footerWaveDrawFn,
loopStartInput, loopEndInput,
- metroBtn, metroPanel, metroVolEl, metroVolLabel, metroBarEl, metroBarCustomEl, metroNoteEl,
+ metroBtn, metroPanel, metroVolEl, metroVolLabel, metroBarEl, metroBarCustomEl, metroGroupEl, metroNoteEl,
metroHalfBtn, metroOneBtn, metroDoubleBtn, metroCountInEl,
metronome, metronomeEnabled, metronomeVolume, metronomeBeatsPerBar, metronomeHasBars,
metronomeCountInBars, setMetronomeCountInBars,
+ metronomeGrouping, setMetronomeGrouping,
setMetronomeHasBars,
setMetronomeEnabled, setMetronomeVolume, setMetronomeBeatsPerBar,
setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume, setPlaybackSpeed,
waveZoom, setWaveZoom, overviewRerenderFn,
} from "./state.js";
import { applyMix, nudgeAllLanePitches, resetAllLanePitches } from "./mixer.js";
-import { isDownbeatIndex, getBeats as getGridBeats, getBars as getGridBars } from "./beatgrid.js";
-import { computeCountIn } from "./metronome.js";
+import { isDownbeatIndex, barPositionIndex, getBeats as getGridBeats, getBars as getGridBars } from "./beatgrid.js";
+import { computeCountIn, defaultGrouping, normaliseGrouping } from "./metronome.js";
import { t } from "./i18n.js";
import { pitchBlockedKey } from "./pitchBus.js";
@@ -494,6 +495,7 @@ function _armCountIn(eng, startPos) {
countBars: metronomeCountInBars,
multiplier: metronome.getMultiplier?.() ?? 1,
accentMode: metronomeBeatsPerBar,
+ groups: metronomeGrouping,
start: startPos,
});
if (leadIn <= 0 || !clicks.length) return false;
@@ -1065,6 +1067,7 @@ function _saveMetroPrefs() {
enabled: metronomeEnabled,
volume: metronomeVolume,
beatsPerBar: metronomeBeatsPerBar,
+ grouping: metronomeGrouping,
countInBars: metronomeCountInBars,
}).catch((e) => console.warn("[transport] failed to save metronome prefs:", e));
}
@@ -1079,10 +1082,16 @@ function _saveMetroPrefs() {
*/
export function applyMetronomeAccent() {
if (!metronome) return;
+ // Grouping is only applied to an explicit meter; under bar marks each bar is
+ // grouped by its own length instead (#595), which is why the position lookup
+ // goes across whether or not the user has set a grouping.
+ metronome.setGrouping?.(metronomeGrouping);
if (metronomeBeatsPerBar < 0 && metronomeHasBars) {
metronome.setDownbeatFn?.(isDownbeatIndex);
+ metronome.setBarPositionFn?.(barPositionIndex);
} else {
metronome.setDownbeatFn?.(null);
+ metronome.setBarPositionFn?.(null);
metronome.setBeatsPerBar?.(Math.max(0, metronomeBeatsPerBar));
}
}
@@ -1113,6 +1122,7 @@ function _renderMetroBar() {
metroBarCustomEl.classList.remove("hidden");
}
}
+ _renderMetroGrouping();
}
// Clamp a typed meter into the range the backend already validates
// (beats_per_bar is ge=1, le=32 in app/api/jobs.py) and apply it. Anything
@@ -1124,6 +1134,43 @@ function _applyCustomBeatsPerBar() {
const n = Number.isFinite(raw) ? Math.max(1, Math.min(32, raw)) : 4;
metroBarCustomEl.value = String(n);
setMetronomeBeatsPerBar(n);
+ setMetronomeGrouping(null);
+ _renderMetroGrouping();
+ applyMetronomeAccent();
+ _renderMetroNote(_lastGrid);
+ _saveMetroPrefs();
+}
+
+// The grouping box is only meaningful for an explicit meter that actually has a
+// choice to make: Auto groups each detected bar by its own length, Off has no
+// bars, and 2, 3 and 4 have one sensible reading. Shows the grouping in force,
+// so a user can see that 7 is being played 3+2+2 before deciding to change it.
+function _renderMetroGrouping() {
+ if (!metroGroupEl) return;
+ const n = metronomeBeatsPerBar;
+ if (!(n >= 5)) {
+ metroGroupEl.classList.add("hidden");
+ return;
+ }
+ metroGroupEl.classList.remove("hidden");
+ metroGroupEl.value = normaliseGrouping(metronomeGrouping, n).join("+");
+ metroGroupEl.placeholder = defaultGrouping(n).join("+");
+}
+
+// Parse "3+2+2" (or "3 2 2", or "3,2,2") into a grouping for the current meter.
+// A grouping that does not sum to the bar length is rejected rather than
+// repaired -- a half-understood one would accent beats the user never asked
+// for -- and the box snaps back to what is actually being played.
+function _applyGrouping() {
+ if (!metroGroupEl) return;
+ const n = metronomeBeatsPerBar;
+ const parts = metroGroupEl.value
+ .split(/[^0-9]+/)
+ .filter(Boolean)
+ .map((x) => parseInt(x, 10));
+ const sum = parts.reduce((a, b) => a + b, 0);
+ setMetronomeGrouping(parts.length && sum === n ? parts : null);
+ _renderMetroGrouping();
applyMetronomeAccent();
_renderMetroNote(_lastGrid);
_saveMetroPrefs();
@@ -1248,6 +1295,7 @@ function wireMetronomeControl() {
if (prefs && typeof prefs === "object") {
if (typeof prefs.volume === "number") setMetronomeVolume(Math.max(0, Math.min(1, prefs.volume)));
if (typeof prefs.beatsPerBar === "number") setMetronomeBeatsPerBar(prefs.beatsPerBar);
+ if (Array.isArray(prefs.grouping)) setMetronomeGrouping(prefs.grouping);
if (typeof prefs.enabled === "boolean") setMetronomeEnabled(prefs.enabled);
// countInBars superseded the countIn boolean (#587). Read the old key
// when the new one is absent so an upgrade keeps the count-in armed
@@ -1303,10 +1351,15 @@ function wireMetronomeControl() {
metroBarCustomEl?.classList.add("hidden");
const raw = parseInt(metroBarEl.value, 10);
setMetronomeBeatsPerBar(Number.isFinite(raw) ? raw : -1);
+ // The old grouping belonged to the old bar length; keeping it would either
+ // be refused on every beat or, worse, fit the new length by accident.
+ setMetronomeGrouping(null);
+ _renderMetroGrouping();
applyMetronomeAccent();
_renderMetroNote(_lastGrid);
_saveMetroPrefs();
});
metroBarCustomEl?.addEventListener("change", _applyCustomBeatsPerBar);
+ metroGroupEl?.addEventListener("change", _applyGrouping);
}
diff --git a/tests/e2e/click-track.spec.mjs b/tests/e2e/click-track.spec.mjs
index 1e813be3..b541ac24 100644
--- a/tests/e2e/click-track.spec.mjs
+++ b/tests/e2e/click-track.spec.mjs
@@ -96,6 +96,37 @@ test.describe("click track", () => {
await expect(ui.note).toContainText("accenting every 32 beats");
});
+ test("an odd meter exposes its grouping, and a bad one is refused", async ({ page }) => {
+ await openStudio(page, { tauri: true });
+ await waitForClickTrack(page);
+ const ui = metro(page);
+ const group = page.locator("#t-metro-group");
+
+ // Simple meters have one sensible reading, so there is nothing to show.
+ await ui.accent.selectOption("4");
+ await expect(group).toBeHidden();
+
+ // 7 is played 3+2+2, and the box says so before the user touches it (#595).
+ await ui.accent.selectOption("7");
+ await expect(group).toBeVisible();
+ await expect(group).toHaveValue("3+2+2");
+
+ // A grouping that fits the bar is taken.
+ await group.fill("2+2+3");
+ await group.blur();
+ await expect(group).toHaveValue("2+2+3");
+
+ // One that does not is refused rather than repaired, and the box snaps back
+ // to what is actually being played.
+ await group.fill("3+3");
+ await group.blur();
+ await expect(group).toHaveValue("3+2+2");
+
+ // Leaving the odd meter puts the control away again.
+ await ui.accent.selectOption("4");
+ await expect(group).toBeHidden();
+ });
+
test("the rate control reports the tempo it is actually clicking", async ({ page }) => {
await openStudio(page, { tauri: true });
await waitForClickTrack(page);
diff --git a/tests/js/count-in.test.mjs b/tests/js/count-in.test.mjs
index 73eee9be..45960aed 100644
--- a/tests/js/count-in.test.mjs
+++ b/tests/js/count-in.test.mjs
@@ -9,7 +9,15 @@
//
// Run: node tests/js/count-in.test.mjs
-import { computeCountIn } from "../../static/js/metronome.js";
+import {
+ computeCountIn,
+ defaultGrouping,
+ normaliseGrouping,
+ levelAt,
+ LEVEL_WEAK,
+ LEVEL_GROUP,
+ LEVEL_DOWNBEAT,
+} from "../../static/js/metronome.js";
let pass = 0,
fail = 0;
@@ -135,5 +143,52 @@ const STEADY = Array.from({ length: 16 }, (_, i) => 0.5 + i * 0.5);
);
}
+{
+ // #595 grouping. These expectations are the same spec Python is held to in
+ // tests/test_click_render.py -- default_grouping, beat_level and
+ // count_in_beats there must produce identical numbers, or a player monitors
+ // one thing and exports another.
+ const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+
+ check("simple meters keep one group", eq(
+ [1, 2, 3, 4].map(defaultGrouping), [[1], [2], [3], [4]]));
+ check("odd and compound meters split", eq(
+ [5, 6, 7, 9, 12].map(defaultGrouping),
+ [[3, 2], [3, 3], [3, 2, 2], [3, 3, 3], [3, 3, 3, 3]]));
+ check("a prime with no conventional reading stays flat", eq(defaultGrouping(11), [11]));
+
+ check("a grouping that does not fit the bar is refused", eq(normaliseGrouping([3, 3], 7), [3, 2, 2]));
+ check("a grouping that fits is kept", eq(normaliseGrouping([2, 2, 3], 7), [2, 2, 3]));
+
+ const W = LEVEL_WEAK, G = LEVEL_GROUP, D = LEVEL_DOWNBEAT;
+ check("7/8 is clicked 3+2+2, not flat",
+ eq([...Array(7).keys()].map((i) => levelAt(i, 7, null)), [D, W, W, G, W, G, W]));
+ check("a user grouping moves the group accents",
+ eq([...Array(7).keys()].map((i) => levelAt(i, 7, [2, 2, 3])), [D, W, G, W, G, W, W]));
+ check("4/4 is unchanged",
+ eq([...Array(4).keys()].map((i) => levelAt(i, 4, null)), [D, W, W, W]));
+ check("6/8 is felt in two groups of three",
+ eq([...Array(6).keys()].map((i) => levelAt(i, 6, null)), [D, W, W, G, W, W]));
+}
+
+{
+ // The count-in has to carry the same pulse the click is about to play.
+ const W = LEVEL_WEAK, G = LEVEL_GROUP, D = LEVEL_DOWNBEAT;
+ const { clicks } = computeCountIn(STEADY, [], { accentMode: 7 });
+ check("count-in into 7/8 is grouped",
+ JSON.stringify(clicks.map((c) => c.level)) === JSON.stringify([D, W, W, G, W, G, W]),
+ JSON.stringify(clicks.map((c) => c.level)));
+
+ const custom = computeCountIn(STEADY, [], { accentMode: 7, groups: [2, 2, 3] });
+ check("count-in follows a user grouping",
+ JSON.stringify(custom.clicks.map((c) => c.level)) === JSON.stringify([D, W, G, W, G, W, W]));
+
+ // The old boolean is still exposed, so anything that only asks "is this the
+ // 1" keeps working across the level change.
+ check("accent stays a downbeat-only boolean",
+ JSON.stringify(clicks.map((c) => c.accent)) ===
+ JSON.stringify([true, false, false, false, false, false, false]));
+}
+
console.log(`\n${pass}/${pass + fail} checks passed`);
process.exit(fail ? 1 : 0);
diff --git a/tests/test_click_render.py b/tests/test_click_render.py
index a2a2a876..afe6aad3 100644
--- a/tests/test_click_render.py
+++ b/tests/test_click_render.py
@@ -14,10 +14,16 @@
ACCENT_FREQ,
ACCENT_OFF,
CLICK_FREQ,
+ LEVEL_DOWNBEAT,
+ LEVEL_GROUP,
+ LEVEL_WEAK,
+ beat_level,
cache_key,
count_in_beats,
count_in_beats_per_bar,
+ default_grouping,
is_downbeat,
+ normalise_grouping,
render_click_wav,
render_count_in_wav,
rescale_beats,
@@ -145,14 +151,14 @@ def test_count_in_one_bar_of_four():
the audio so the song enters on the next downbeat."""
lead_in, clicks = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}])
assert lead_in == pytest.approx(2.0)
- assert [a for _, a in clicks] == [True, False, False, False]
+ assert [lv for _, lv in clicks] == [LEVEL_DOWNBEAT, LEVEL_WEAK, LEVEL_WEAK, LEVEL_WEAK]
assert [round(o, 3) for o, _ in clicks] == [0.0, 0.5, 1.0, 1.5]
def test_count_in_follows_detected_meter():
lead_in, clicks = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 3}])
assert lead_in == pytest.approx(1.5)
- assert [a for _, a in clicks] == [True, False, False]
+ assert [lv for _, lv in clicks] == [LEVEL_DOWNBEAT, LEVEL_WEAK, LEVEL_WEAK]
def test_count_in_explicit_accent_sets_bar_length():
@@ -168,14 +174,23 @@ def test_count_in_defaults_to_four_without_marks():
def test_count_in_two_bars_accents_each_downbeat():
lead_in, clicks = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}], count_bars=2)
assert lead_in == pytest.approx(4.0)
- assert [a for _, a in clicks] == [True, False, False, False, True, False, False, False]
+ assert [lv for _, lv in clicks] == [
+ LEVEL_DOWNBEAT,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ LEVEL_DOWNBEAT,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ ]
def test_count_in_still_marks_the_downbeat_when_click_accents_are_off():
"""A count-in without a '1' is useless, so it accents its downbeat even when
the running click has accents switched off."""
_, clicks = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}], accent_mode=ACCENT_OFF)
- assert clicks[0][1] is True
+ assert clicks[0][1] == LEVEL_DOWNBEAT
def test_count_in_follows_the_rate_multiplier():
@@ -570,3 +585,169 @@ def test_mixdown_cache_key_separates_count_in_from_plain_click(client, tmp_path)
a = stems_mod._mixdown_cache_key(JOB, "wav", ["drums"], [1.0], None, None, plain)
b = stems_mod._mixdown_cache_key(JOB, "wav", ["drums"], [1.0], None, None, counted)
assert a != b, "a count-in export must never reuse a plain click render"
+
+
+# ─── Meter grouping (#595) ───────────────────────────────────────────────
+
+
+def test_default_grouping_leaves_simple_meters_alone():
+ """2, 3 and 4 must sound exactly as they did. 4/4 carries a real secondary
+ stress on beat 3, but turning it on by default would change the commonest
+ meter in the app for every existing user."""
+ for n in (1, 2, 3, 4):
+ assert default_grouping(n) == [n]
+
+
+def test_default_grouping_splits_odd_and_compound_meters():
+ assert default_grouping(5) == [3, 2]
+ assert default_grouping(7) == [3, 2, 2]
+ assert default_grouping(6) == [3, 3]
+ assert default_grouping(9) == [3, 3, 3]
+ assert default_grouping(12) == [3, 3, 3, 3]
+ # A prime with no conventional reading stays flat rather than being guessed.
+ assert default_grouping(11) == [11]
+
+
+def test_a_grouping_that_does_not_fit_the_bar_is_refused_not_repaired():
+ """Half-understood grouping would accent beats the user never asked for."""
+ assert normalise_grouping([3, 3], 7) == [3, 2, 2]
+ assert normalise_grouping([0, 7], 7) == [3, 2, 2]
+ assert normalise_grouping([], 7) == [3, 2, 2]
+ assert normalise_grouping([2, 2, 3], 7) == [2, 2, 3]
+
+
+def test_seven_eight_is_clicked_in_groups_not_flat():
+ """The bug: seven even clicks give a player nothing to lock onto."""
+ levels = [beat_level(i, [], 7) for i in range(7)]
+ assert levels == [
+ LEVEL_DOWNBEAT,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ LEVEL_GROUP,
+ LEVEL_WEAK,
+ LEVEL_GROUP,
+ LEVEL_WEAK,
+ ]
+
+
+def test_a_user_grouping_moves_the_group_accents():
+ assert [beat_level(i, [], 7, [2, 2, 3]) for i in range(7)] == [
+ LEVEL_DOWNBEAT,
+ LEVEL_WEAK,
+ LEVEL_GROUP,
+ LEVEL_WEAK,
+ LEVEL_GROUP,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ ]
+
+
+def test_four_four_is_unchanged_by_grouping():
+ assert [beat_level(i, [], 4) for i in range(8)] == [
+ LEVEL_DOWNBEAT,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ LEVEL_DOWNBEAT,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ ]
+
+
+def test_detected_bars_group_by_their_own_length_under_auto():
+ """Auto has no single meter to group: a detected 6/8 passage must group in
+ threes without the user configuring anything, and a user grouping must not
+ be forced onto a bar length it does not fit."""
+ bars = [{"beat": 0, "beats_per_bar": 6}]
+ assert [beat_level(i, bars, ACCENT_AUTO) for i in range(6)] == [
+ LEVEL_DOWNBEAT,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ LEVEL_GROUP,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ ]
+ # A 7-beat grouping is ignored rather than applied to a 6-beat bar.
+ assert [beat_level(i, bars, ACCENT_AUTO, [2, 2, 3]) for i in range(6)] == [
+ LEVEL_DOWNBEAT,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ LEVEL_GROUP,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ ]
+
+
+def test_accents_off_stays_silent_of_accents_even_with_a_grouping():
+ assert [beat_level(i, [], ACCENT_OFF, [3, 2, 2]) for i in range(7)] == [LEVEL_WEAK] * 7
+
+
+def test_the_count_in_is_grouped_like_the_click_it_leads_into():
+ """Counting a player into 7/8 has to give them the 3+2+2 pulse they are
+ about to play, not seven flat clicks."""
+ _, clicks = count_in_beats(_STEADY, [], accent_mode=7)
+ assert [lv for _, lv in clicks] == [
+ LEVEL_DOWNBEAT,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ LEVEL_GROUP,
+ LEVEL_WEAK,
+ LEVEL_GROUP,
+ LEVEL_WEAK,
+ ]
+
+
+def test_the_count_in_follows_a_user_grouping():
+ _, clicks = count_in_beats(_STEADY, [], accent_mode=7, groups=[2, 2, 3])
+ assert [lv for _, lv in clicks] == [
+ LEVEL_DOWNBEAT,
+ LEVEL_WEAK,
+ LEVEL_GROUP,
+ LEVEL_WEAK,
+ LEVEL_GROUP,
+ LEVEL_WEAK,
+ LEVEL_WEAK,
+ ]
+
+
+def test_grouping_changes_the_cache_key_but_absence_keeps_the_old_one():
+ """A re-grouped render must not serve the flat file, and every export made
+ before grouping existed must keep hitting its existing cache entry."""
+ args = (JOB, [0.5, 1.0], [], 10.0, SR, 1.0, 7)
+ assert cache_key(*args) == cache_key(*args, groups=None)
+ assert cache_key(*args) != cache_key(*args, groups=[2, 2, 3])
+ assert cache_key(*args, groups=[3, 2, 2]) != cache_key(*args, groups=[2, 2, 3])
+
+
+def test_export_parses_a_grouping_the_same_way_the_browser_does():
+ from app.api.stems import _parse_groups
+
+ assert _parse_groups("3+2+2", 7) == [3, 2, 2]
+ assert _parse_groups("3,2,2", 7) == [3, 2, 2]
+ # Does not fit the bar, is not for an explicit meter, or is not a grouping
+ # at all: fall back to the default rather than 422 an otherwise fine export.
+ assert _parse_groups("3+3", 7) is None
+ assert _parse_groups("3+2+2", ACCENT_AUTO) is None
+ assert _parse_groups("nonsense", 7) is None
+
+
+def test_a_grouped_export_is_accepted_end_to_end(client, tmp_path):
+ _setup_job(tmp_path)
+ (tmp_path / JOB / "stems" / "drums.wav").write_bytes(b"RIFF")
+ r = client.get(
+ f"/api/jobs/{JOB}/mixdown.wav?stems=drums&gains=1.0&click=1"
+ "&click_accent=7&click_groups=3%2B2%2B2"
+ )
+ assert r.status_code != 422
+
+
+def test_a_nonsense_grouping_does_not_fail_the_export(client, tmp_path):
+ """An export is better served with the default grouping than refused."""
+ _setup_job(tmp_path)
+ (tmp_path / JOB / "stems" / "drums.wav").write_bytes(b"RIFF")
+ r = client.get(
+ f"/api/jobs/{JOB}/mixdown.wav?stems=drums&gains=1.0&click=1"
+ "&click_accent=7&click_groups=99%2B99"
+ )
+ assert r.status_code != 422
From 39ee733462fb8bb4ba91748801d5b274302797c1 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:20:18 +0100
Subject: [PATCH 2/4] fix(click): say what the grouping does, instead of
showing a bare 3+2
Two problems, one root cause: the grouping was visible but never explained.
The note under the waveform was wrong. With an explicit meter it said
"accenting every 5 beats", which was true before grouping and is not true
now: a 5 grouped 3+2 also stresses beat 4. The panel was describing a click
it was no longer playing.
It now names the grouping and the beats it stresses -- "counting 5 in 3+2
(stress on 1, 4)" -- which also makes the note the place that explains what
the grouping box means. A meter with a single group keeps the old wording,
so 4/4 and an ungroupable 11 read exactly as they did.
The box itself was an unlabelled text field containing something like
"3+2". Next to a number input showing 5, that is not decodable: it could be
beats, bars, a ratio. It now carries a visible "Groups" label, shown and
hidden with the box.
Found by looking at the panel rather than the tests, which is why the tests
did not catch it -- they asserted the grouping was applied, not that anyone
could tell what it meant. The e2e test now pins both the label and the
note's wording, and updating it surfaced that the 5-beat case had silently
changed its note text.
Refs #595
---
static/css/daw.css | 4 ++++
static/index.html | 1 +
static/js/i18n.js | 30 ++++++++++++++++++++++++++++++
static/js/transport.js | 23 ++++++++++++++++++++++-
tests/e2e/click-track.spec.mjs | 16 +++++++++++++---
5 files changed, 70 insertions(+), 4 deletions(-)
diff --git a/static/css/daw.css b/static/css/daw.css
index 992dfdad..40d42f95 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -2859,6 +2859,10 @@ input, textarea { font-family: inherit; }
.metro-group:hover { background: var(--panel-3); }
.metro-group:focus { border-color: var(--accent); }
.metro-group::placeholder { color: var(--muted); }
+.metro-group-label {
+ color: var(--muted); font-size: 11px; font-weight: 500;
+ letter-spacing: .02em; flex-shrink: 0; align-self: center;
+}
/* Detection confidence / unavailability note: its own line under the
waveform, where it reads as a caption on the timeline it describes (design
diff --git a/static/index.html b/static/index.html
index 97964e38..7aca1b3d 100644
--- a/static/index.html
+++ b/static/index.html
@@ -778,6 +778,7 @@
+ Groups
diff --git a/static/js/i18n.js b/static/js/i18n.js
index f0bc1585..2c9c864c 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -449,6 +449,7 @@ const en = {
"click.barCustomTitle": "Beats per bar",
"click.groupTitle": "How the bar divides, e.g. 3+2+2. Blank uses the usual grouping for the meter.",
"click.groupAria": "Beat grouping",
+ "click.groupLabel": "Groups",
"click.barAccentAria": "Accent every N beats",
"click.auto": "Auto (detected)",
"click.autoNone": "Auto (none found)",
@@ -775,6 +776,8 @@ const en = {
// Built as ONE full-sentence template per variant (not concatenated
// clauses), so translations can reorder freely.
"metro.note.full": "Clicking {bpm} BPM — {conf}% of beats sit on a drum hit, accenting every {accent} beats. Use /2 or x2 if the click feels half or double speed.",
+ "metro.note.grouped": "Clicking {bpm} BPM — {conf}% of beats sit on a drum hit, counting {accent} in {groups} ({stress}). Use /2 or x2 if the click feels half or double speed.",
+ "metro.note.stress": "stress on {beats}",
"metro.note.noAccent": "Clicking {bpm} BPM — {conf}% of beats sit on a drum hit. Use /2 or x2 if the click feels half or double speed.",
"metro.note.detectedSingleBar": "Clicking {bpm} BPM — {conf}% of beats sit on a drum hit, accenting {beats}/4 from the detected downbeat. Use /2 or x2 if the click feels half or double speed.",
"metro.note.detectedMultiBar": "Clicking {bpm} BPM — {conf}% of beats sit on a drum hit, accenting {count} detected meter regions. Use /2 or x2 if the click feels half or double speed.",
@@ -1057,6 +1060,7 @@ const pl = {
"click.barCustomTitle": "Uderzenia na takt",
"click.groupTitle": "Jak dzieli się takt, np. 3+2+2. Puste pole używa typowego podziału dla metrum.",
"click.groupAria": "Podział taktu",
+ "click.groupLabel": "Podział",
"click.barAccentAria": "Akcentuj co N uderzeń",
"click.auto": "Auto (wykryto)",
"click.autoNone": "Auto (nie znaleziono)",
@@ -1378,6 +1382,8 @@ const pl = {
"player.playbackDisabled": "Odtwarzanie jest wyłączone dla tego utworu.",
"metro.note.full": "Metronom {bpm} BPM — {conf}% uderzeń trafia w perkusję, akcentując co {accent} uderzeń. Użyj /2 lub x2, jeśli tempo wydaje się o połowę wolniejsze lub szybsze.",
+ "metro.note.grouped": "Metronom {bpm} BPM — {conf}% uderzeń trafia w perkusję, licząc {accent} w podziale {groups} ({stress}). Użyj /2 lub x2, jeśli tempo wydaje się o połowę wolniejsze lub szybsze.",
+ "metro.note.stress": "akcent na {beats}",
"metro.note.noAccent": "Metronom {bpm} BPM — {conf}% uderzeń trafia w perkusję. Użyj /2 lub x2, jeśli tempo wydaje się o połowę wolniejsze lub szybsze.",
"metro.note.detectedSingleBar": "Metronom {bpm} BPM — {conf}% uderzeń trafia w perkusję, akcentując {beats}/4 od wykrytej mocnej części taktu. Użyj /2 lub x2, jeśli tempo wydaje się o połowę wolniejsze lub szybsze.",
"metro.note.detectedMultiBar": "Metronom {bpm} BPM — {conf}% uderzeń trafia w perkusję, akcentując {count} wykrytych obszarów metrum. Użyj /2 lub x2, jeśli tempo wydaje się o połowę wolniejsze lub szybsze.",
@@ -1656,6 +1662,7 @@ const ja = {
"click.barCustomTitle": "1小節の拍数",
"click.groupTitle": "小節の区切り方 (例: 3+2+2)。空欄ならその拍子の一般的な区切りを使います。",
"click.groupAria": "拍のグループ分け",
+ "click.groupLabel": "区切り",
"click.barAccentAria": "N拍ごとにアクセント",
"click.auto": "自動 (検出済み)",
"click.autoNone": "自動 (未検出)",
@@ -1963,6 +1970,8 @@ const ja = {
"player.playbackDisabled": "このトラックの再生は無効です。",
"metro.note.full": "クリック {bpm} BPM — ビートの{conf}%がドラムに一致、{accent}拍ごとにアクセント。半分/倍速に感じる場合は/2かx2を使ってください。",
+ "metro.note.grouped": "クリック {bpm} BPM — ビートの{conf}%がドラムに一致、{accent}拍を{groups}に分けて数えます ({stress})。半分/倍速に感じる場合は/2かx2を使ってください。",
+ "metro.note.stress": "{beats}拍目を強く",
"metro.note.noAccent": "クリック {bpm} BPM — ビートの{conf}%がドラムに一致。半分/倍速に感じる場合は/2かx2を使ってください。",
"metro.note.detectedSingleBar": "クリック {bpm} BPM — ビートの{conf}%がドラムに一致、検出されたダウンビートから{beats}/4拍子でアクセント。半分/倍速に感じる場合は/2かx2を使ってください。",
"metro.note.detectedMultiBar": "クリック {bpm} BPM — ビートの{conf}%がドラムに一致、検出された{count}個の拍子領域でアクセント。半分/倍速に感じる場合は/2かx2を使ってください。",
@@ -2231,6 +2240,7 @@ const zhHans = {
"click.barCustomTitle": "每小节拍数",
"click.groupTitle": "小节如何划分,例如 3+2+2。留空则使用该拍号的常用分组。",
"click.groupAria": "节拍分组",
+ "click.groupLabel": "分组",
"click.barAccentAria": "每 N 拍重音一次",
"click.auto": "自动(已检测)",
"click.autoNone": "自动(未检测到)",
@@ -2538,6 +2548,8 @@ const zhHans = {
"player.playbackDisabled": "此曲目的播放已禁用。",
"metro.note.full": "节拍器 {bpm} BPM — {conf}% 的节拍落在鼓点上,每 {accent} 拍重音一次。如果感觉速度慢了一半或快了一倍,请使用 /2 或 x2。",
+ "metro.note.grouped": "节拍器 {bpm} BPM — {conf}% 的拍点落在鼓声上,将 {accent} 拍按 {groups} 分组 ({stress})。若感觉快或慢一倍,请用 /2 或 x2。",
+ "metro.note.stress": "重音在第 {beats} 拍",
"metro.note.noAccent": "节拍器 {bpm} BPM — {conf}% 的节拍落在鼓点上。如果感觉速度慢了一半或快了一倍,请使用 /2 或 x2。",
"metro.note.detectedSingleBar": "节拍器 {bpm} BPM — {conf}% 的节拍落在鼓点上,从检测到的强拍开始按 {beats}/4 重音。如果感觉速度慢了一半或快了一倍,请使用 /2 或 x2。",
"metro.note.detectedMultiBar": "节拍器 {bpm} BPM — {conf}% 的节拍落在鼓点上,在检测到的 {count} 个节拍区域重音。如果感觉速度慢了一半或快了一倍,请使用 /2 或 x2。",
@@ -2807,6 +2819,7 @@ const de = {
"click.barCustomTitle": "Schläge pro Takt",
"click.groupTitle": "Wie sich der Takt teilt, z. B. 3+2+2. Leer nutzt die übliche Gruppierung des Taktes.",
"click.groupAria": "Taktgruppierung",
+ "click.groupLabel": "Gruppen",
"click.barAccentAria": "Jeden N. Beat betonen",
"click.auto": "Auto (erkannt)",
"click.autoNone": "Auto (nicht gefunden)",
@@ -3121,6 +3134,8 @@ const de = {
"player.playbackDisabled": "Die Wiedergabe ist für diesen Track deaktiviert.",
"metro.note.full": "Click bei {bpm} BPM — {conf}% der Beats treffen auf einen Drum-Hit, Betonung alle {accent} Beats. Nutze /2 oder x2, wenn sich der Click halb oder doppelt so schnell anfühlt.",
+ "metro.note.grouped": "Klick {bpm} BPM — {conf}% der Schläge liegen auf einem Drum-Hit, {accent} gezählt als {groups} ({stress}). Nutze /2 oder x2, wenn der Klick halb oder doppelt so schnell wirkt.",
+ "metro.note.stress": "Betonung auf {beats}",
"metro.note.noAccent": "Click bei {bpm} BPM — {conf}% der Beats treffen auf einen Drum-Hit. Nutze /2 oder x2, wenn sich der Click halb oder doppelt so schnell anfühlt.",
"metro.note.detectedSingleBar": "Click bei {bpm} BPM — {conf}% der Beats treffen auf einen Drum-Hit, Betonung {beats}/4 ab dem erkannten Taktschlag. Nutze /2 oder x2, wenn sich der Click halb oder doppelt so schnell anfühlt.",
"metro.note.detectedMultiBar": "Click bei {bpm} BPM — {conf}% der Beats treffen auf einen Drum-Hit, Betonung in {count} erkannten Taktbereichen. Nutze /2 oder x2, wenn sich der Click halb oder doppelt so schnell anfühlt.",
@@ -3393,6 +3408,7 @@ const pt = {
"click.barCustomTitle": "Batidas por compasso",
"click.groupTitle": "Como o compasso se divide, ex. 3+2+2. Em branco usa o agrupamento usual do compasso.",
"click.groupAria": "Agrupamento de batidas",
+ "click.groupLabel": "Grupos",
"click.barAccentAria": "Acentuar a cada N batidas",
"click.auto": "Auto (detectado)",
"click.autoNone": "Auto (nenhum encontrado)",
@@ -3707,6 +3723,8 @@ const pt = {
"player.playbackDisabled": "A reprodução está desativada para esta faixa.",
"metro.note.full": "Clique a {bpm} BPM — {conf}% das batidas coincidem com uma batida de bateria, acentuando a cada {accent} batidas. Use /2 ou x2 se o clique parecer meio ou duas vezes mais rápido.",
+ "metro.note.grouped": "Clique {bpm} BPM — {conf}% das batidas caem num golpe de bateria, contando {accent} em {groups} ({stress}). Use /2 ou x2 se o clique parecer metade ou o dobro da velocidade.",
+ "metro.note.stress": "ênfase em {beats}",
"metro.note.noAccent": "Clique a {bpm} BPM — {conf}% das batidas coincidem com uma batida de bateria. Use /2 ou x2 se o clique parecer meio ou duas vezes mais rápido.",
"metro.note.detectedSingleBar": "Clique a {bpm} BPM — {conf}% das batidas coincidem com uma batida de bateria, acentuando {beats}/4 a partir do tempo forte detectado. Use /2 ou x2 se o clique parecer meio ou duas vezes mais rápido.",
"metro.note.detectedMultiBar": "Clique a {bpm} BPM — {conf}% das batidas coincidem com uma batida de bateria, acentuando {count} regiões métricas detectadas. Use /2 ou x2 se o clique parecer meio ou duas vezes mais rápido.",
@@ -3980,6 +3998,7 @@ const id = {
"click.barCustomTitle": "Ketukan per birama",
"click.groupTitle": "Cara birama dibagi, mis. 3+2+2. Kosong memakai pengelompokan biasa untuk birama itu.",
"click.groupAria": "Pengelompokan ketukan",
+ "click.groupLabel": "Kelompok",
"click.barAccentAria": "Aksen setiap N ketukan",
"click.auto": "Otomatis (terdeteksi)",
"click.autoNone": "Otomatis (tidak ditemukan)",
@@ -4287,6 +4306,8 @@ const id = {
"player.playbackDisabled": "Pemutaran dinonaktifkan untuk trek ini.",
"metro.note.full": "Klik {bpm} BPM — {conf}% ketukan jatuh pada pukulan drum, beraksen setiap {accent} ketukan. Gunakan /2 atau x2 jika klik terasa setengah atau dua kali kecepatan.",
+ "metro.note.grouped": "Klik {bpm} BPM — {conf}% ketukan jatuh pada pukulan drum, menghitung {accent} dalam {groups} ({stress}). Gunakan /2 atau x2 jika klik terasa setengah atau dua kali lipat.",
+ "metro.note.stress": "tekanan pada {beats}",
"metro.note.noAccent": "Klik {bpm} BPM — {conf}% ketukan jatuh pada pukulan drum. Gunakan /2 atau x2 jika klik terasa setengah atau dua kali kecepatan.",
"metro.note.detectedSingleBar": "Klik {bpm} BPM — {conf}% ketukan jatuh pada pukulan drum, beraksen {beats}/4 dari ketukan berat yang terdeteksi. Gunakan /2 atau x2 jika klik terasa setengah atau dua kali kecepatan.",
"metro.note.detectedMultiBar": "Klik {bpm} BPM — {conf}% ketukan jatuh pada pukulan drum, beraksen di {count} wilayah birama yang terdeteksi. Gunakan /2 atau x2 jika klik terasa setengah atau dua kali kecepatan.",
@@ -4556,6 +4577,7 @@ const fr = {
"click.barCustomTitle": "Temps par mesure",
"click.groupTitle": "Comment la mesure se divise, ex. 3+2+2. Vide utilise le groupement habituel de la mesure.",
"click.groupAria": "Groupement des temps",
+ "click.groupLabel": "Groupes",
"click.barAccentAria": "Accentuer tous les N temps",
"click.auto": "Auto (détecté)",
"click.autoNone": "Auto (aucun trouvé)",
@@ -4870,6 +4892,8 @@ const fr = {
"player.playbackDisabled": "La lecture est désactivée pour ce morceau.",
"metro.note.full": "Métronome à {bpm} BPM — {conf} % des temps tombent sur un coup de batterie, avec un accent tous les {accent} temps. Utilisez /2 ou x2 si le métronome semble deux fois trop lent ou trop rapide.",
+ "metro.note.grouped": "Clic {bpm} BPM — {conf}% des temps tombent sur une frappe de batterie, {accent} comptés en {groups} ({stress}). Utilisez /2 ou x2 si le clic semble deux fois trop lent ou rapide.",
+ "metro.note.stress": "accent sur {beats}",
"metro.note.noAccent": "Métronome à {bpm} BPM — {conf} % des temps tombent sur un coup de batterie. Utilisez /2 ou x2 si le métronome semble deux fois trop lent ou trop rapide.",
"metro.note.detectedSingleBar": "Métronome à {bpm} BPM — {conf} % des temps tombent sur un coup de batterie, avec un accent en {beats}/4 à partir du temps fort détecté. Utilisez /2 ou x2 si le métronome semble deux fois trop lent ou trop rapide.",
"metro.note.detectedMultiBar": "Métronome à {bpm} BPM — {conf} % des temps tombent sur un coup de batterie, avec des accents sur {count} régions de mesure détectées. Utilisez /2 ou x2 si le métronome semble deux fois trop lent ou trop rapide.",
@@ -5260,6 +5284,7 @@ const es = {
"click.barCustomTitle": "Tiempos por compás",
"click.groupTitle": "Cómo se divide el compás, p. ej. 3+2+2. En blanco usa la agrupación habitual del compás.",
"click.groupAria": "Agrupación de tiempos",
+ "click.groupLabel": "Grupos",
"click.barAccentAria": "Acentuar cada N tiempos",
"click.auto": "Automático (detectado)",
"click.autoNone": "Automático (no se detectó)",
@@ -5582,6 +5607,8 @@ const es = {
// Built as ONE full-sentence template per variant (not concatenated
// clauses), so translations can reorder freely.
"metro.note.full": "Click a {bpm} BPM — el {conf}% de los tiempos cae sobre un golpe de batería, acentuando cada {accent} tiempos. Usa /2 o x2 si el click va a mitad o al doble de velocidad.",
+ "metro.note.grouped": "Clic {bpm} BPM — {conf}% de los tiempos caen en un golpe de batería, contando {accent} en {groups} ({stress}). Usa /2 o x2 si el clic parece la mitad o el doble de rápido.",
+ "metro.note.stress": "acento en {beats}",
"metro.note.noAccent": "Click a {bpm} BPM — el {conf}% de los tiempos cae sobre un golpe de batería. Usa /2 o x2 si el click va a mitad o al doble de velocidad.",
"metro.note.detectedSingleBar": "Click a {bpm} BPM — el {conf}% de los tiempos cae sobre un golpe de batería, acentuando {beats}/4 desde el tiempo fuerte detectado. Usa /2 o x2 si el click va a mitad o al doble de velocidad.",
"metro.note.detectedMultiBar": "Click a {bpm} BPM — el {conf}% de los tiempos cae sobre un golpe de batería, acentuando {count} regiones de compás detectadas. Usa /2 o x2 si el click va a mitad o al doble de velocidad.",
@@ -5868,6 +5895,7 @@ const ko = {
"click.barCustomTitle": "마디당 박자",
"click.groupTitle": "마디를 나누는 방식 (예: 3+2+2). 비워 두면 해당 박자의 일반적인 묶음을 사용합니다.",
"click.groupAria": "박자 묶음",
+ "click.groupLabel": "묶음",
"click.barAccentAria": "N 비트마다 강세",
"click.auto": "자동 (감지됨)",
"click.autoNone": "자동 (찾지 못함)",
@@ -6175,6 +6203,8 @@ const ko = {
"player.playbackDisabled": "이 트랙은 재생할 수 없어요.",
"metro.note.full": "{bpm} BPM으로 클릭 중이에요. 비트의 {conf}%가 드럼과 맞고, {accent}비트마다 강세를 줘요. 클릭이 절반이나 두 배로 느껴지면 /2 또는 x2를 쓰세요.",
+ "metro.note.grouped": "클릭 {bpm} BPM — 박의 {conf}%가 드럼에 맞고, {accent}박을 {groups}로 나눠 셉니다 ({stress}). 절반이나 두 배로 느껴지면 /2 또는 x2를 사용하세요.",
+ "metro.note.stress": "{beats}박에 강세",
"metro.note.noAccent": "{bpm} BPM으로 클릭 중이에요. 비트의 {conf}%가 드럼과 맞아요. 클릭이 절반이나 두 배로 느껴지면 /2 또는 x2를 쓰세요.",
"metro.note.detectedSingleBar": "{bpm} BPM으로 클릭 중이에요. 비트의 {conf}%가 드럼과 맞고, 감지된 첫박부터 {beats}/4로 강세를 줘요. 클릭이 절반이나 두 배로 느껴지면 /2 또는 x2를 쓰세요.",
"metro.note.detectedMultiBar": "{bpm} BPM으로 클릭 중이에요. 비트의 {conf}%가 드럼과 맞고, 감지된 박자 구간 {count}개에 강세를 줘요. 클릭이 절반이나 두 배로 느껴지면 /2 또는 x2를 쓰세요.",
diff --git a/static/js/transport.js b/static/js/transport.js
index b832beea..d757a011 100644
--- a/static/js/transport.js
+++ b/static/js/transport.js
@@ -1148,11 +1148,14 @@ function _applyCustomBeatsPerBar() {
function _renderMetroGrouping() {
if (!metroGroupEl) return;
const n = metronomeBeatsPerBar;
+ const label = document.getElementById("t-metro-group-label");
if (!(n >= 5)) {
metroGroupEl.classList.add("hidden");
+ label?.classList.add("hidden");
return;
}
metroGroupEl.classList.remove("hidden");
+ label?.classList.remove("hidden");
metroGroupEl.value = normaliseGrouping(metronomeGrouping, n).join("+");
metroGroupEl.placeholder = defaultGrouping(n).join("+");
}
@@ -1243,7 +1246,25 @@ function _renderMetroNote(grid) {
} else if (metronomeBeatsPerBar < 0 && nBars > 1) {
text = t("metro.note.detectedMultiBar", { bpm: bpmStr, conf, count: nBars });
} else if (metronomeBeatsPerBar > 0) {
- text = t("metro.note.full", { bpm: bpmStr, conf, accent: metronomeBeatsPerBar });
+ // A grouped bar accents more than the 1, so "accenting every N beats"
+ // would describe a click that is not being played (#595). Say the
+ // grouping and name the beats it stresses, which is also the only place
+ // the panel explains what "3+2" in the grouping box means.
+ const g = normaliseGrouping(metronomeGrouping, metronomeBeatsPerBar);
+ if (g.length > 1) {
+ const stressed = [];
+ let at = 1;
+ for (const n of g) { stressed.push(at); at += n; }
+ text = t("metro.note.grouped", {
+ bpm: bpmStr,
+ conf,
+ accent: metronomeBeatsPerBar,
+ groups: g.join("+"),
+ stress: t("metro.note.stress", { beats: stressed.join(", ") }),
+ });
+ } else {
+ text = t("metro.note.full", { bpm: bpmStr, conf, accent: metronomeBeatsPerBar });
+ }
} else {
text = t("metro.note.noAccent", { bpm: bpmStr, conf });
}
diff --git a/tests/e2e/click-track.spec.mjs b/tests/e2e/click-track.spec.mjs
index b541ac24..e69e431d 100644
--- a/tests/e2e/click-track.spec.mjs
+++ b/tests/e2e/click-track.spec.mjs
@@ -77,10 +77,11 @@ test.describe("click track", () => {
const ui = metro(page);
const custom = page.locator("#t-metro-bar-custom");
- // Presets leave the free-entry box hidden.
+ // Presets leave the free-entry box hidden. 5 carries a grouping, so the
+ // note describes the 3+2 it is actually playing rather than a flat accent.
await ui.accent.selectOption("5");
await expect(custom).toBeHidden();
- await expect(ui.note).toContainText("accenting every 5 beats");
+ await expect(ui.note).toContainText("counting 5 in 3+2");
// "Custom..." reveals it, and a typed value is what actually applies.
await ui.accent.selectOption("custom");
@@ -110,11 +111,17 @@ test.describe("click track", () => {
await ui.accent.selectOption("7");
await expect(group).toBeVisible();
await expect(group).toHaveValue("3+2+2");
+ // The box is labelled and the note spells out what "3+2+2" does, so the
+ // grouping is not a bare number the user has to decode.
+ await expect(page.locator("#t-metro-group-label")).toBeVisible();
+ await expect(ui.note).toContainText("counting 7 in 3+2+2");
+ await expect(ui.note).toContainText("stress on 1, 4, 6");
// A grouping that fits the bar is taken.
await group.fill("2+2+3");
await group.blur();
await expect(group).toHaveValue("2+2+3");
+ await expect(ui.note).toContainText("stress on 1, 3, 5");
// One that does not is refused rather than repaired, and the box snaps back
// to what is actually being played.
@@ -122,9 +129,12 @@ test.describe("click track", () => {
await group.blur();
await expect(group).toHaveValue("3+2+2");
- // Leaving the odd meter puts the control away again.
+ // Leaving the odd meter puts the control away again, and the note goes back
+ // to describing a plain accent rather than a grouping that is not playing.
await ui.accent.selectOption("4");
await expect(group).toBeHidden();
+ await expect(page.locator("#t-metro-group-label")).toBeHidden();
+ await expect(ui.note).toContainText("accenting every 4 beats");
});
test("the rate control reports the tempo it is actually clicking", async ({ page }) => {
From ae54b6558dd148463d8af201602d46f98a6c12d3 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:26:29 +0100
Subject: [PATCH 3/4] fix(click): say why a grouping was refused, instead of
just undoing it
The Groups box is editable on purpose, and anything that adds up to the bar
length is accepted: on a 5 that is 2+3, 4+1, 2+2+1, 1+1+1+1+1 or a plain 5.
Anything that does not add up is refused, because a grouping the bar cannot
hold would put accents on beats nobody asked for.
The refusal was silent. The typed value simply reverted to the default with
no message, which reads as the field being broken rather than as the input
being rejected -- there was no way to learn the rule from the UI.
A refused grouping now marks the box and puts the reason in the note that
already explains the click: "A grouping has to add up to 5." Both clear
after a moment, so the note goes back to describing what is playing.
An empty box is a deliberate "use the default", not a mistake, so it is
accepted quietly. Only a non-empty grouping that does not fit is worth
complaining about.
Reuses the existing .warn note style rather than adding one.
Refs #595
---
static/css/daw.css | 3 +++
static/js/i18n.js | 10 ++++++++++
static/js/transport.js | 21 ++++++++++++++++++++-
tests/e2e/click-track.spec.mjs | 13 +++++++++++--
4 files changed, 44 insertions(+), 3 deletions(-)
diff --git a/static/css/daw.css b/static/css/daw.css
index 40d42f95..fcbeb7a2 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -2859,6 +2859,9 @@ input, textarea { font-family: inherit; }
.metro-group:hover { background: var(--panel-3); }
.metro-group:focus { border-color: var(--accent); }
.metro-group::placeholder { color: var(--muted); }
+/* A refused grouping: the box snapping back on its own reads as a bug unless
+ something says the numbers did not add up. */
+.metro-group.invalid { border-color: var(--danger); color: var(--danger); }
.metro-group-label {
color: var(--muted); font-size: 11px; font-weight: 500;
letter-spacing: .02em; flex-shrink: 0; align-self: center;
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 2c9c864c..4798da88 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -450,6 +450,7 @@ const en = {
"click.groupTitle": "How the bar divides, e.g. 3+2+2. Blank uses the usual grouping for the meter.",
"click.groupAria": "Beat grouping",
"click.groupLabel": "Groups",
+ "click.groupMustSum": "A grouping has to add up to {beats}.",
"click.barAccentAria": "Accent every N beats",
"click.auto": "Auto (detected)",
"click.autoNone": "Auto (none found)",
@@ -1061,6 +1062,7 @@ const pl = {
"click.groupTitle": "Jak dzieli się takt, np. 3+2+2. Puste pole używa typowego podziału dla metrum.",
"click.groupAria": "Podział taktu",
"click.groupLabel": "Podział",
+ "click.groupMustSum": "Podział musi sumować się do {beats}.",
"click.barAccentAria": "Akcentuj co N uderzeń",
"click.auto": "Auto (wykryto)",
"click.autoNone": "Auto (nie znaleziono)",
@@ -1663,6 +1665,7 @@ const ja = {
"click.groupTitle": "小節の区切り方 (例: 3+2+2)。空欄ならその拍子の一般的な区切りを使います。",
"click.groupAria": "拍のグループ分け",
"click.groupLabel": "区切り",
+ "click.groupMustSum": "区切りの合計は{beats}拍にする必要があります。",
"click.barAccentAria": "N拍ごとにアクセント",
"click.auto": "自動 (検出済み)",
"click.autoNone": "自動 (未検出)",
@@ -2241,6 +2244,7 @@ const zhHans = {
"click.groupTitle": "小节如何划分,例如 3+2+2。留空则使用该拍号的常用分组。",
"click.groupAria": "节拍分组",
"click.groupLabel": "分组",
+ "click.groupMustSum": "分组之和必须等于 {beats}。",
"click.barAccentAria": "每 N 拍重音一次",
"click.auto": "自动(已检测)",
"click.autoNone": "自动(未检测到)",
@@ -2820,6 +2824,7 @@ const de = {
"click.groupTitle": "Wie sich der Takt teilt, z. B. 3+2+2. Leer nutzt die übliche Gruppierung des Taktes.",
"click.groupAria": "Taktgruppierung",
"click.groupLabel": "Gruppen",
+ "click.groupMustSum": "Eine Gruppierung muss {beats} ergeben.",
"click.barAccentAria": "Jeden N. Beat betonen",
"click.auto": "Auto (erkannt)",
"click.autoNone": "Auto (nicht gefunden)",
@@ -3409,6 +3414,7 @@ const pt = {
"click.groupTitle": "Como o compasso se divide, ex. 3+2+2. Em branco usa o agrupamento usual do compasso.",
"click.groupAria": "Agrupamento de batidas",
"click.groupLabel": "Grupos",
+ "click.groupMustSum": "O agrupamento tem de somar {beats}.",
"click.barAccentAria": "Acentuar a cada N batidas",
"click.auto": "Auto (detectado)",
"click.autoNone": "Auto (nenhum encontrado)",
@@ -3999,6 +4005,7 @@ const id = {
"click.groupTitle": "Cara birama dibagi, mis. 3+2+2. Kosong memakai pengelompokan biasa untuk birama itu.",
"click.groupAria": "Pengelompokan ketukan",
"click.groupLabel": "Kelompok",
+ "click.groupMustSum": "Pengelompokan harus berjumlah {beats}.",
"click.barAccentAria": "Aksen setiap N ketukan",
"click.auto": "Otomatis (terdeteksi)",
"click.autoNone": "Otomatis (tidak ditemukan)",
@@ -4578,6 +4585,7 @@ const fr = {
"click.groupTitle": "Comment la mesure se divise, ex. 3+2+2. Vide utilise le groupement habituel de la mesure.",
"click.groupAria": "Groupement des temps",
"click.groupLabel": "Groupes",
+ "click.groupMustSum": "Un groupement doit totaliser {beats}.",
"click.barAccentAria": "Accentuer tous les N temps",
"click.auto": "Auto (détecté)",
"click.autoNone": "Auto (aucun trouvé)",
@@ -5285,6 +5293,7 @@ const es = {
"click.groupTitle": "Cómo se divide el compás, p. ej. 3+2+2. En blanco usa la agrupación habitual del compás.",
"click.groupAria": "Agrupación de tiempos",
"click.groupLabel": "Grupos",
+ "click.groupMustSum": "La agrupación tiene que sumar {beats}.",
"click.barAccentAria": "Acentuar cada N tiempos",
"click.auto": "Automático (detectado)",
"click.autoNone": "Automático (no se detectó)",
@@ -5896,6 +5905,7 @@ const ko = {
"click.groupTitle": "마디를 나누는 방식 (예: 3+2+2). 비워 두면 해당 박자의 일반적인 묶음을 사용합니다.",
"click.groupAria": "박자 묶음",
"click.groupLabel": "묶음",
+ "click.groupMustSum": "묶음의 합이 {beats}가 되어야 합니다.",
"click.barAccentAria": "N 비트마다 강세",
"click.auto": "자동 (감지됨)",
"click.autoNone": "자동 (찾지 못함)",
diff --git a/static/js/transport.js b/static/js/transport.js
index d757a011..00a94491 100644
--- a/static/js/transport.js
+++ b/static/js/transport.js
@@ -1164,6 +1164,8 @@ function _renderMetroGrouping() {
// A grouping that does not sum to the bar length is rejected rather than
// repaired -- a half-understood one would accent beats the user never asked
// for -- and the box snaps back to what is actually being played.
+let _groupWarnTimer = null;
+
function _applyGrouping() {
if (!metroGroupEl) return;
const n = metronomeBeatsPerBar;
@@ -1172,11 +1174,28 @@ function _applyGrouping() {
.filter(Boolean)
.map((x) => parseInt(x, 10));
const sum = parts.reduce((a, b) => a + b, 0);
- setMetronomeGrouping(parts.length && sum === n ? parts : null);
+ const ok = parts.length > 0 && sum === n;
+ setMetronomeGrouping(ok ? parts : null);
_renderMetroGrouping();
applyMetronomeAccent();
_renderMetroNote(_lastGrid);
_saveMetroPrefs();
+ // Snapping back to the default without saying why reads as the box being
+ // broken rather than as the input being refused. An empty box is a
+ // deliberate "use the default", so only a non-empty one that does not fit
+ // is worth complaining about.
+ if (!ok && parts.length) {
+ metroGroupEl.classList.add("invalid");
+ if (metroNoteEl) {
+ metroNoteEl.textContent = t("click.groupMustSum", { beats: n });
+ metroNoteEl.className = "metro-note warn";
+ }
+ clearTimeout(_groupWarnTimer);
+ _groupWarnTimer = setTimeout(() => {
+ metroGroupEl.classList.remove("invalid");
+ _renderMetroNote(_lastGrid);
+ }, 2600);
+ }
}
// Count-in is a length select rather than the press-to-arm toggle it used to
diff --git a/tests/e2e/click-track.spec.mjs b/tests/e2e/click-track.spec.mjs
index e69e431d..dfd2cd61 100644
--- a/tests/e2e/click-track.spec.mjs
+++ b/tests/e2e/click-track.spec.mjs
@@ -123,11 +123,20 @@ test.describe("click track", () => {
await expect(group).toHaveValue("2+2+3");
await expect(ui.note).toContainText("stress on 1, 3, 5");
- // One that does not is refused rather than repaired, and the box snaps back
- // to what is actually being played.
+ // One that does not is refused rather than repaired -- and says so, because
+ // a box that snaps back on its own reads as broken rather than as refused.
await group.fill("3+3");
await group.blur();
await expect(group).toHaveValue("3+2+2");
+ await expect(group).toHaveClass(/invalid/);
+ await expect(ui.note).toContainText("add up to 7");
+
+ // Clearing the box is a deliberate "use the default", not a mistake, so it
+ // must not be scolded.
+ await group.fill("");
+ await group.blur();
+ await expect(group).toHaveValue("3+2+2");
+ await expect(group).not.toHaveClass(/invalid/);
// Leaving the odd meter puts the control away again, and the note goes back
// to describing a plain accent rather than a grouping that is not playing.
From 7d1c138921ec5bd9a5f9c181a21c215b658a6a49 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:46:31 +0100
Subject: [PATCH 4/4] test(click): pin playback and export to one shared
expectation table
Parity between the live click and the rendered one was enforced by writing
the same expected numbers into tests/js/count-in.test.mjs and
tests/test_click_render.py by hand. That catches a mistake in one file. It
cannot catch the same wrong reasoning applied to both, which is the failure
mode that actually matters here: a symmetric error ships a click that
monitors one way and exports another, with two green suites.
Both sides are now pinned to tests/fixtures/click_levels.json, generated
from Python by tests/_click_parity.py. test_click_parity.py asserts the
committed fixture is still what Python produces; tests/js/parity.test.mjs
asserts the browser produces the same. Neither language can drift without a
visible diff to that file, and regenerating it is a deliberate act in review
rather than an edit buried in a test body.
The table covers 14 meters, 18 level runs over two full bars each (so a
wrong modulo shows on the second), and 24 count-ins across 1 and 2 bars --
including groupings that do not fit their bar and must be refused, and Auto,
where each detected bar is grouped by its own length.
A shared fixture rather than one side shelling out to the other, because
neither CI job can run both languages: pytest runs in a uv container with no
node, js-syntax in a node container with no python. A cross-invocation test
would have skipped in CI and only ever run locally, which is the shape of a
gate that quietly stops gating.
Verified the gate fails as intended rather than assuming: grouping 7 as
2+2+3 on the JS side alone fails 13 checks and exits 1, and the mirror-image
change on the Python side fails test_click_parity and exits 1. Both pass
clean when restored.
Refs #595
---
tests/_click_parity.py | 91 ++
tests/fixtures/click_levels.json | 1340 ++++++++++++++++++++++++++++++
tests/js/parity.test.mjs | 90 ++
tests/test_click_parity.py | 56 ++
4 files changed, 1577 insertions(+)
create mode 100644 tests/_click_parity.py
create mode 100644 tests/fixtures/click_levels.json
create mode 100644 tests/js/parity.test.mjs
create mode 100644 tests/test_click_parity.py
diff --git a/tests/_click_parity.py b/tests/_click_parity.py
new file mode 100644
index 00000000..8c3638d1
--- /dev/null
+++ b/tests/_click_parity.py
@@ -0,0 +1,91 @@
+"""The shared click-level expectation table, generated from the Python side.
+
+Playback (static/js/metronome.js) and export (app/pipeline/click_render.py) must
+agree beat for beat, or a player monitors one thing and exports another. That was
+previously enforced by writing the same expected numbers into two test files by
+hand, which only catches a mistake in one of them: reason wrongly the same way
+twice and both suites pass.
+
+This builds the table once, from Python. tests/test_click_parity.py asserts the
+committed fixture still matches what Python produces, and tests/js/parity.test.mjs
+asserts the JS produces the same. Neither language can drift without a visible
+diff to the fixture, and changing the fixture is a deliberate act in review.
+"""
+
+from __future__ import annotations
+
+from app.pipeline.click_render import beat_level, count_in_beats, default_grouping
+
+# Meters worth pinning: the simple ones that must not change, the odd and
+# compound ones the grouping exists for, and a prime with no conventional
+# reading. 32 is the upper bound the API accepts.
+METERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 15, 16, 32]
+
+# Explicit groupings, including ones that do not fit the bar and must be refused.
+GROUPINGS: list[tuple[int, list[int] | None]] = [
+ (7, None),
+ (7, [3, 2, 2]),
+ (7, [2, 2, 3]),
+ (7, [2, 3, 2]),
+ (7, [3, 3]), # does not sum: must fall back to the default
+ (7, []), # empty: default
+ (5, [2, 3]),
+ (6, [2, 2, 2]),
+ (6, [4, 2]),
+ (12, [3, 3, 3, 3]),
+ (12, [4, 4, 4]),
+ (4, [2, 2]), # a user may group 4 even though the default does not
+]
+
+_STEADY = [0.5 + i * 0.5 for i in range(32)]
+
+
+def build() -> dict:
+ """Every parity-relevant answer the two implementations must agree on."""
+ table: dict = {"defaultGrouping": {}, "levels": [], "countIn": []}
+
+ for n in METERS:
+ table["defaultGrouping"][str(n)] = default_grouping(n)
+
+ for n, groups in GROUPINGS:
+ # Two full bars, so a wrong modulo shows up on the second one.
+ table["levels"].append(
+ {
+ "beatsPerBar": n,
+ "groups": groups,
+ "levels": [beat_level(i, [], n, groups) for i in range(n * 2)],
+ }
+ )
+
+ # Auto: each detected bar is grouped by its own length, and a supplied
+ # grouping must not be forced onto a bar it does not fit.
+ for per_bar in (4, 5, 6, 7, 9, 12):
+ table["levels"].append(
+ {
+ "beatsPerBar": -1,
+ "bars": [{"beat": 0, "beats_per_bar": per_bar}],
+ "groups": None,
+ "levels": [
+ beat_level(i, [{"beat": 0, "beats_per_bar": per_bar}], -1)
+ for i in range(per_bar * 2)
+ ],
+ }
+ )
+
+ for n, groups in GROUPINGS:
+ for bars_count in (1, 2):
+ lead_in, clicks = count_in_beats(
+ _STEADY, [], count_bars=bars_count, accent_mode=n, groups=groups or None
+ )
+ table["countIn"].append(
+ {
+ "beatsPerBar": n,
+ "groups": groups,
+ "countBars": bars_count,
+ "leadIn": round(lead_in, 6),
+ "offsets": [round(o, 6) for o, _ in clicks],
+ "levels": [lv for _, lv in clicks],
+ }
+ )
+
+ return table
diff --git a/tests/fixtures/click_levels.json b/tests/fixtures/click_levels.json
new file mode 100644
index 00000000..d278254b
--- /dev/null
+++ b/tests/fixtures/click_levels.json
@@ -0,0 +1,1340 @@
+{
+ "countIn": [
+ {
+ "beatsPerBar": 7,
+ "countBars": 1,
+ "groups": null,
+ "leadIn": 3.5,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "countBars": 2,
+ "groups": null,
+ "leadIn": 7.0,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5,
+ 5.0,
+ 5.5,
+ 6.0,
+ 6.5
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "countBars": 1,
+ "groups": [
+ 3,
+ 2,
+ 2
+ ],
+ "leadIn": 3.5,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "countBars": 2,
+ "groups": [
+ 3,
+ 2,
+ 2
+ ],
+ "leadIn": 7.0,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5,
+ 5.0,
+ 5.5,
+ 6.0,
+ 6.5
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "countBars": 1,
+ "groups": [
+ 2,
+ 2,
+ 3
+ ],
+ "leadIn": 3.5,
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "countBars": 2,
+ "groups": [
+ 2,
+ 2,
+ 3
+ ],
+ "leadIn": 7.0,
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ 2,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5,
+ 5.0,
+ 5.5,
+ 6.0,
+ 6.5
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "countBars": 1,
+ "groups": [
+ 2,
+ 3,
+ 2
+ ],
+ "leadIn": 3.5,
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "countBars": 2,
+ "groups": [
+ 2,
+ 3,
+ 2
+ ],
+ "leadIn": 7.0,
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5,
+ 5.0,
+ 5.5,
+ 6.0,
+ 6.5
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "countBars": 1,
+ "groups": [
+ 3,
+ 3
+ ],
+ "leadIn": 3.5,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "countBars": 2,
+ "groups": [
+ 3,
+ 3
+ ],
+ "leadIn": 7.0,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5,
+ 5.0,
+ 5.5,
+ 6.0,
+ 6.5
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "countBars": 1,
+ "groups": [],
+ "leadIn": 3.5,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "countBars": 2,
+ "groups": [],
+ "leadIn": 7.0,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5,
+ 5.0,
+ 5.5,
+ 6.0,
+ 6.5
+ ]
+ },
+ {
+ "beatsPerBar": 5,
+ "countBars": 1,
+ "groups": [
+ 2,
+ 3
+ ],
+ "leadIn": 2.5,
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0
+ ]
+ },
+ {
+ "beatsPerBar": 5,
+ "countBars": 2,
+ "groups": [
+ 2,
+ 3
+ ],
+ "leadIn": 5.0,
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 0,
+ 2,
+ 0,
+ 1,
+ 0,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5
+ ]
+ },
+ {
+ "beatsPerBar": 6,
+ "countBars": 1,
+ "groups": [
+ 2,
+ 2,
+ 2
+ ],
+ "leadIn": 3.0,
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5
+ ]
+ },
+ {
+ "beatsPerBar": 6,
+ "countBars": 2,
+ "groups": [
+ 2,
+ 2,
+ 2
+ ],
+ "leadIn": 6.0,
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5,
+ 5.0,
+ 5.5
+ ]
+ },
+ {
+ "beatsPerBar": 6,
+ "countBars": 1,
+ "groups": [
+ 4,
+ 2
+ ],
+ "leadIn": 3.0,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5
+ ]
+ },
+ {
+ "beatsPerBar": 6,
+ "countBars": 2,
+ "groups": [
+ 4,
+ 2
+ ],
+ "leadIn": 6.0,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5,
+ 5.0,
+ 5.5
+ ]
+ },
+ {
+ "beatsPerBar": 12,
+ "countBars": 1,
+ "groups": [
+ 3,
+ 3,
+ 3,
+ 3
+ ],
+ "leadIn": 6.0,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5,
+ 5.0,
+ 5.5
+ ]
+ },
+ {
+ "beatsPerBar": 12,
+ "countBars": 2,
+ "groups": [
+ 3,
+ 3,
+ 3,
+ 3
+ ],
+ "leadIn": 12.0,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5,
+ 5.0,
+ 5.5,
+ 6.0,
+ 6.5,
+ 7.0,
+ 7.5,
+ 8.0,
+ 8.5,
+ 9.0,
+ 9.5,
+ 10.0,
+ 10.5,
+ 11.0,
+ 11.5
+ ]
+ },
+ {
+ "beatsPerBar": 12,
+ "countBars": 1,
+ "groups": [
+ 4,
+ 4,
+ 4
+ ],
+ "leadIn": 6.0,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5,
+ 5.0,
+ 5.5
+ ]
+ },
+ {
+ "beatsPerBar": 12,
+ "countBars": 2,
+ "groups": [
+ 4,
+ 4,
+ 4
+ ],
+ "leadIn": 12.0,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5,
+ 4.0,
+ 4.5,
+ 5.0,
+ 5.5,
+ 6.0,
+ 6.5,
+ 7.0,
+ 7.5,
+ 8.0,
+ 8.5,
+ 9.0,
+ 9.5,
+ 10.0,
+ 10.5,
+ 11.0,
+ 11.5
+ ]
+ },
+ {
+ "beatsPerBar": 4,
+ "countBars": 1,
+ "groups": [
+ 2,
+ 2
+ ],
+ "leadIn": 2.0,
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5
+ ]
+ },
+ {
+ "beatsPerBar": 4,
+ "countBars": 2,
+ "groups": [
+ 2,
+ 2
+ ],
+ "leadIn": 4.0,
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 1,
+ 0
+ ],
+ "offsets": [
+ 0.0,
+ 0.5,
+ 1.0,
+ 1.5,
+ 2.0,
+ 2.5,
+ 3.0,
+ 3.5
+ ]
+ }
+ ],
+ "defaultGrouping": {
+ "1": [
+ 1
+ ],
+ "11": [
+ 11
+ ],
+ "12": [
+ 3,
+ 3,
+ 3,
+ 3
+ ],
+ "15": [
+ 3,
+ 3,
+ 3,
+ 3,
+ 3
+ ],
+ "16": [
+ 16
+ ],
+ "2": [
+ 2
+ ],
+ "3": [
+ 3
+ ],
+ "32": [
+ 32
+ ],
+ "4": [
+ 4
+ ],
+ "5": [
+ 3,
+ 2
+ ],
+ "6": [
+ 3,
+ 3
+ ],
+ "7": [
+ 3,
+ 2,
+ 2
+ ],
+ "8": [
+ 8
+ ],
+ "9": [
+ 3,
+ 3,
+ 3
+ ]
+ },
+ "levels": [
+ {
+ "beatsPerBar": 7,
+ "groups": null,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "groups": [
+ 3,
+ 2,
+ 2
+ ],
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "groups": [
+ 2,
+ 2,
+ 3
+ ],
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0,
+ 2,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "groups": [
+ 2,
+ 3,
+ 2
+ ],
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "groups": [
+ 3,
+ 3
+ ],
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ]
+ },
+ {
+ "beatsPerBar": 7,
+ "groups": [],
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ]
+ },
+ {
+ "beatsPerBar": 5,
+ "groups": [
+ 2,
+ 3
+ ],
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 0,
+ 2,
+ 0,
+ 1,
+ 0,
+ 0
+ ]
+ },
+ {
+ "beatsPerBar": 6,
+ "groups": [
+ 2,
+ 2,
+ 2
+ ],
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ]
+ },
+ {
+ "beatsPerBar": 6,
+ "groups": [
+ 4,
+ 2
+ ],
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0
+ ]
+ },
+ {
+ "beatsPerBar": 12,
+ "groups": [
+ 3,
+ 3,
+ 3,
+ 3
+ ],
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0
+ ]
+ },
+ {
+ "beatsPerBar": 12,
+ "groups": [
+ 4,
+ 4,
+ 4
+ ],
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "beatsPerBar": 4,
+ "groups": [
+ 2,
+ 2
+ ],
+ "levels": [
+ 2,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 1,
+ 0
+ ]
+ },
+ {
+ "bars": [
+ {
+ "beat": 0,
+ "beats_per_bar": 4
+ }
+ ],
+ "beatsPerBar": -1,
+ "groups": null,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "bars": [
+ {
+ "beat": 0,
+ "beats_per_bar": 5
+ }
+ ],
+ "beatsPerBar": -1,
+ "groups": null,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0
+ ]
+ },
+ {
+ "bars": [
+ {
+ "beat": 0,
+ "beats_per_bar": 6
+ }
+ ],
+ "beatsPerBar": -1,
+ "groups": null,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0
+ ]
+ },
+ {
+ "bars": [
+ {
+ "beat": 0,
+ "beats_per_bar": 7
+ }
+ ],
+ "beatsPerBar": -1,
+ "groups": null,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 1,
+ 0
+ ]
+ },
+ {
+ "bars": [
+ {
+ "beat": 0,
+ "beats_per_bar": 9
+ }
+ ],
+ "beatsPerBar": -1,
+ "groups": null,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0
+ ]
+ },
+ {
+ "bars": [
+ {
+ "beat": 0,
+ "beats_per_bar": 12
+ }
+ ],
+ "beatsPerBar": -1,
+ "groups": null,
+ "levels": [
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/js/parity.test.mjs b/tests/js/parity.test.mjs
new file mode 100644
index 00000000..ba51d341
--- /dev/null
+++ b/tests/js/parity.test.mjs
@@ -0,0 +1,90 @@
+// JS half of the click parity gate (#595).
+//
+// Playback and export must agree beat for beat, or a player monitors one thing
+// and exports another. This used to be enforced by writing the same expected
+// numbers into tests/js/count-in.test.mjs and tests/test_click_render.py by
+// hand, which only catches a mistake in one of them: reason wrongly the same
+// way twice and both suites pass.
+//
+// Both sides are now pinned to one artifact instead. tests/fixtures/
+// click_levels.json is generated from Python and asserted by
+// tests/test_click_parity.py; this file asserts the browser implementation
+// produces the same numbers. Neither language can drift without a visible diff
+// to that file.
+//
+// The two CI jobs cannot run both languages -- pytest runs in a uv container
+// with no node, js-syntax in a node container with no python -- so a test that
+// shelled out to the other side would simply skip. The shared fixture is what
+// makes this gate actually run.
+//
+// Run: node tests/js/parity.test.mjs
+
+import { readFileSync } from "node:fs";
+import { levelAt, defaultGrouping, computeCountIn } from "../../static/js/metronome.js";
+
+const fixture = JSON.parse(
+ readFileSync(new URL("../fixtures/click_levels.json", import.meta.url), "utf8"),
+);
+
+let pass = 0,
+ fail = 0;
+const check = (name, cond, detail = "") => {
+ if (cond) {
+ pass++;
+ } else {
+ fail++;
+ console.log(`FAIL ${name}${detail ? " -- " + detail : ""}`);
+ }
+};
+const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+// Python normalises an empty grouping to None before use; mirror that here so
+// the two are compared on the same input, not on a difference in falsiness.
+const norm = (g) => (Array.isArray(g) && g.length ? g : null);
+
+// The same grid tests/_click_parity.py counts against.
+const STEADY = Array.from({ length: 32 }, (_, i) => 0.5 + i * 0.5);
+
+for (const [n, expected] of Object.entries(fixture.defaultGrouping)) {
+ const got = defaultGrouping(Number(n));
+ check(`defaultGrouping(${n})`, eq(got, expected), `got ${JSON.stringify(got)}, want ${JSON.stringify(expected)}`);
+}
+
+for (const e of fixture.levels) {
+ if (e.beatsPerBar === -1) {
+ // Auto: each detected bar is grouped by its own length.
+ const per = e.bars[0].beats_per_bar;
+ const got = e.levels.map((_, i) => levelAt(i % per, per, null));
+ check(`auto levels, detected ${per}/bar`, eq(got, e.levels), `got ${got}`);
+ } else {
+ const n = e.beatsPerBar;
+ const got = e.levels.map((_, i) => levelAt(i % n, n, norm(e.groups)));
+ check(
+ `levels ${n} groups=${JSON.stringify(e.groups)}`,
+ eq(got, e.levels),
+ `got ${got}, want ${e.levels}`,
+ );
+ }
+}
+
+for (const e of fixture.countIn) {
+ const { leadIn, clicks } = computeCountIn(STEADY, [], {
+ countBars: e.countBars,
+ accentMode: e.beatsPerBar,
+ groups: norm(e.groups),
+ });
+ const label = `countIn ${e.beatsPerBar} x${e.countBars} groups=${JSON.stringify(e.groups)}`;
+ check(`${label}: leadIn`, Math.abs(leadIn - e.leadIn) < 1e-6, `got ${leadIn}, want ${e.leadIn}`);
+ check(
+ `${label}: levels`,
+ eq(clicks.map((c) => c.level), e.levels),
+ `got ${clicks.map((c) => c.level)}`,
+ );
+ check(
+ `${label}: offsets`,
+ eq(clicks.map((c) => Number(c.offset.toFixed(6))), e.offsets),
+ `got ${clicks.map((c) => Number(c.offset.toFixed(6)))}`,
+ );
+}
+
+console.log(`\n${pass}/${pass + fail} parity checks passed against tests/fixtures/click_levels.json`);
+process.exit(fail ? 1 : 0);
diff --git a/tests/test_click_parity.py b/tests/test_click_parity.py
new file mode 100644
index 00000000..d561e5a1
--- /dev/null
+++ b/tests/test_click_parity.py
@@ -0,0 +1,56 @@
+"""Python half of the click parity gate.
+
+Asserts the committed fixture is still what Python produces. The JS half
+(tests/js/parity.test.mjs) asserts the same fixture is what the browser
+produces. Neither language can drift from the other without a visible diff to
+tests/fixtures/click_levels.json, and regenerating that file is a deliberate
+act somebody reviews -- which is the part hand-written duplicate expectations
+in two test files could never give us.
+
+Regenerate after an intentional change:
+
+ uv run python -c "import json,sys; sys.path.insert(0,'tests'); \
+from _click_parity import build; \
+json.dump(build(), open('tests/fixtures/click_levels.json','w'), indent=2, sort_keys=True)"
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from tests._click_parity import build
+
+FIXTURE = Path(__file__).parent / "fixtures" / "click_levels.json"
+
+
+def test_the_committed_parity_fixture_matches_what_python_produces():
+ assert FIXTURE.is_file(), "parity fixture is missing; regenerate it (see module docstring)"
+ committed = json.loads(FIXTURE.read_text(encoding="utf-8"))
+ assert committed == build(), (
+ "Python's click levels no longer match tests/fixtures/click_levels.json. "
+ "If the change is intended, regenerate the fixture and check that "
+ "static/js/metronome.js was changed to match -- tests/js/parity.test.mjs "
+ "is pinned to the same file."
+ )
+
+
+def test_the_fixture_actually_covers_the_cases_worth_pinning():
+ """A fixture that silently shrank would keep passing while covering nothing."""
+ t = build()
+ assert len(t["defaultGrouping"]) >= 14
+ assert len(t["levels"]) >= 18
+ assert len(t["countIn"]) >= 24
+ # The whole point: at least one meter is grouped, and one is not.
+ assert any(1 in e["levels"] for e in t["levels"]), "no grouped meter is pinned"
+ assert any(set(e["levels"]) == {2, 0} for e in t["levels"]), "no flat meter is pinned"
+
+
+@pytest.mark.parametrize("simple", [2, 3, 4])
+def test_simple_meters_are_pinned_flat_so_a_regression_is_loud(simple):
+ """2, 3 and 4 must keep sounding exactly as they did. This is the assertion
+ that fails first if a future default grouping starts touching them."""
+ t = build()
+ assert t["defaultGrouping"][str(simple)] == [simple]