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..fcbeb7a2 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -2847,6 +2847,26 @@ 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); }
+/* 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;
+}
+
/* 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..7aca1b3d 100644
--- a/static/index.html
+++ b/static/index.html
@@ -778,6 +778,8 @@
+ Groups
+
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..4798da88 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -447,6 +447,10 @@ 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.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)",
@@ -773,6 +777,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.",
@@ -1053,6 +1059,10 @@ 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.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)",
@@ -1374,6 +1384,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.",
@@ -1650,6 +1662,10 @@ const ja = {
"click.countIn4": "カウントイン: 4小節",
"click.barCustom": "カスタム...",
"click.barCustomTitle": "1小節の拍数",
+ "click.groupTitle": "小節の区切り方 (例: 3+2+2)。空欄ならその拍子の一般的な区切りを使います。",
+ "click.groupAria": "拍のグループ分け",
+ "click.groupLabel": "区切り",
+ "click.groupMustSum": "区切りの合計は{beats}拍にする必要があります。",
"click.barAccentAria": "N拍ごとにアクセント",
"click.auto": "自動 (検出済み)",
"click.autoNone": "自動 (未検出)",
@@ -1957,6 +1973,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を使ってください。",
@@ -2223,6 +2241,10 @@ const zhHans = {
"click.countIn4": "预备拍: 4 小节",
"click.barCustom": "自定义...",
"click.barCustomTitle": "每小节拍数",
+ "click.groupTitle": "小节如何划分,例如 3+2+2。留空则使用该拍号的常用分组。",
+ "click.groupAria": "节拍分组",
+ "click.groupLabel": "分组",
+ "click.groupMustSum": "分组之和必须等于 {beats}。",
"click.barAccentAria": "每 N 拍重音一次",
"click.auto": "自动(已检测)",
"click.autoNone": "自动(未检测到)",
@@ -2530,6 +2552,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。",
@@ -2797,6 +2821,10 @@ 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.groupLabel": "Gruppen",
+ "click.groupMustSum": "Eine Gruppierung muss {beats} ergeben.",
"click.barAccentAria": "Jeden N. Beat betonen",
"click.auto": "Auto (erkannt)",
"click.autoNone": "Auto (nicht gefunden)",
@@ -3111,6 +3139,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.",
@@ -3381,6 +3411,10 @@ 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.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)",
@@ -3695,6 +3729,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.",
@@ -3966,6 +4002,10 @@ 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.groupLabel": "Kelompok",
+ "click.groupMustSum": "Pengelompokan harus berjumlah {beats}.",
"click.barAccentAria": "Aksen setiap N ketukan",
"click.auto": "Otomatis (terdeteksi)",
"click.autoNone": "Otomatis (tidak ditemukan)",
@@ -4273,6 +4313,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.",
@@ -4540,6 +4582,10 @@ 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.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é)",
@@ -4854,6 +4900,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.",
@@ -5242,6 +5290,10 @@ 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.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ó)",
@@ -5564,6 +5616,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.",
@@ -5848,6 +5902,10 @@ const ko = {
"click.countIn4": "카운트인: 4마디",
"click.barCustom": "사용자 지정...",
"click.barCustomTitle": "마디당 박자",
+ "click.groupTitle": "마디를 나누는 방식 (예: 3+2+2). 비워 두면 해당 박자의 일반적인 묶음을 사용합니다.",
+ "click.groupAria": "박자 묶음",
+ "click.groupLabel": "묶음",
+ "click.groupMustSum": "묶음의 합이 {beats}가 되어야 합니다.",
"click.barAccentAria": "N 비트마다 강세",
"click.auto": "자동 (감지됨)",
"click.autoNone": "자동 (찾지 못함)",
@@ -6155,6 +6213,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/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..00a94491 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,11 +1134,70 @@ 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;
+ 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("+");
+}
+
+// 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.
+let _groupWarnTimer = null;
+
+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);
+ 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
// be (#587): once "how many bars" is a question, on/off is just the zero case,
// and a separate toggle beside a length would be two widgets for one setting.
@@ -1196,7 +1265,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 });
}
@@ -1248,6 +1335,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 +1391,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/_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/e2e/click-track.spec.mjs b/tests/e2e/click-track.spec.mjs
index 1e813be3..dfd2cd61 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");
@@ -96,6 +97,55 @@ 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");
+ // 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 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.
+ 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 }) => {
await openStudio(page, { tauri: true });
await waitForClickTrack(page);
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/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/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]
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