"""Builds the Note Detect Benchmark sloppak (v1). A reproducible, distributable test piece for the note_detect plugin: 8 short exercises designed to isolate specific failure modes (open-string mono, fretted positions, octaves, sustained held notes, hammer-on / pull-off, sparse power chords, dense open chords, bends). How to run inside the slopsmith container (recommended — has ffmpeg + pyyaml already): docker cp docs/benchmarks/note_detect_v1/build_benchmark.py \ slopsmith-web-1:/tmp/build_benchmark.py docker exec slopsmith-web-1 python /tmp/build_benchmark.py \ /app/static/sloppak_cache/note_detect_benchmark_v1.sloppak The output sloppak lands under `static/sloppak_cache/` on the host (bind-mounted into the container). Copy / zip it from there. """ import json import math import shutil import struct import subprocess import sys import wave from pathlib import Path import yaml # bundled with the slopsmith image # ── Benchmark parameters ──────────────────────────────────────────────── BPM = 90.0 SECONDS_PER_BEAT = 60.0 / BPM # 0.6667 BEATS_PER_BAR = 4 BAR_S = BEATS_PER_BAR * SECONDS_PER_BEAT # 2.667 INTRO_BARS = 2 # silence before the first event OUTRO_BARS = 2 # tail after the last EXERCISE_BARS = 6 # length of each exercise # Standard E-tuning open MIDI per string, low → high (matches lib/tunings # convention used by note_detect when arrangement is 'guitar'). OPEN_MIDI = [40, 45, 50, 55, 59, 64] # E2 A2 D3 G3 B3 E4 SR = 44100 # sample rate for the click WAV # ── Click-track audio generator ──────────────────────────────────────── def _sine_burst(freq_hz, duration_s, amplitude): """Short sine burst with a linear attack/release envelope so the click reads as a tick, not a pop.""" n = int(SR * duration_s) out = [] fade = max(1, int(0.004 * SR)) # 4 ms fade in + out for i in range(n): env = 1.0 if i < fade: env = i / fade elif i >= n - fade: env = (n - 1 - i) / fade s = math.sin(2 * math.pi * freq_hz * (i / SR)) * amplitude * env out.append(s) return out def write_click_wav(path: Path, total_duration_s: float): """A click on every beat; the downbeat (beat 0 of each bar) is louder and a tone higher. Steady reference for the player; the chart's event times sit on the same beat grid.""" n_total = int(math.ceil(total_duration_s * SR)) buf = [0.0] * n_total click_dur = 0.045 downbeat_tone = 1500 upbeat_tone = 1000 downbeat_amp = 0.22 upbeat_amp = 0.12 beat_idx = 0 t = 0.0 while t < total_duration_s - click_dur: is_downbeat = (beat_idx % BEATS_PER_BAR) == 0 click = _sine_burst( downbeat_tone if is_downbeat else upbeat_tone, click_dur, downbeat_amp if is_downbeat else upbeat_amp, ) i0 = int(t * SR) for j, v in enumerate(click): if i0 + j < n_total: buf[i0 + j] += v t += SECONDS_PER_BEAT beat_idx += 1 # Soft clip to keep within 16-bit headroom even if a future tweak # piles bursts up. pcm = bytearray() for v in buf: s = max(-1.0, min(1.0, v)) pcm.extend(struct.pack('.zip` with forward-slash paths. Zip-level reproducibility: every entry uses a fixed `date_time` (the zip spec's earliest legal value, 1980-01-01 00:00:00), a fixed `external_attr` (rw-r--r--), and an explicit `ZipInfo` so the archive metadata depends only on contents, not on when the build ran. JSON / YAML / MD entries are byte-identical across rebuilds. Caveat: the bundled `stems/full.ogg` is still non-deterministic across rebuilds because libvorbis writes a random bitstream serial number to every Ogg page (~1% of the file's bytes are container framing, not audio). The audio PCM that the detector listens to is deterministic; only the container headers differ. So a diff of the tracked sloppak will always show OGG churn after `_build_zip`, but the chart, manifest, and audible signal are stable. If a future PR needs full byte-stability, it can either cache a hand-built OGG or switch the stem to FLAC. """ import zipfile zip_path = src_dir.with_suffix(src_dir.suffix + '.zip') if zip_path.exists(): zip_path.unlink() with zipfile.ZipFile(zip_path, 'w', compression=zipfile.ZIP_DEFLATED) as zf: for p in sorted(src_dir.rglob('*')): if p.is_file(): # Force POSIX-style arcname so a Windows build still # emits a Linux-loadable archive. rel = p.relative_to(src_dir).as_posix() info = zipfile.ZipInfo(filename=rel, date_time=(1980, 1, 1, 0, 0, 0)) info.compress_type = zipfile.ZIP_DEFLATED # rw-r--r-- in the upper 16 bits where ZIP stores # external attrs on POSIX. Avoids "executable" / weird # permission bits leaking from the host filesystem. info.external_attr = (0o644 & 0xFFFF) << 16 # Force POSIX (3) for the create-system byte so the # zip's central-directory metadata doesn't drift when # the same builder runs on Windows vs Linux. Python's # default is host-dependent (3 on POSIX, 0 on Windows) # and was the last source of zip-level non-determinism # after the date_time + external_attr fixes. info.create_system = 3 zf.writestr(info, p.read_bytes()) def _benchmark_readme(duration_s): return f"""# Slopsmith Note Detect Benchmark — v1 A short test piece for tuning Slopsmith's `note_detect` plugin. Eight exercises, each isolating a specific detection failure mode. Run with **Detect** enabled, play through, then export the diagnostic JSON (Settings → Plugins → Note Detection → Download Diagnostic JSON, or the button on the end-of-session summary modal). - **Tempo**: {BPM:g} BPM - **Tuning**: E standard (no capo) - **Audio**: metronome click track only (downbeat = louder + higher tone). Play *over* the click — `note_detect` listens to your guitar signal, not the audio in this file. - **Duration**: {duration_s:.0f} s ## Sections | Section | Tests | Watch in the diagnostic | |---|---|---| | A. Open strings (low→high→low) | Basic mono detection on each open string | `pure` (mic/audio chain), per-string accuracy | | B. 5th-fret positions | Fretted-note detection across all 6 strings | per-string variance | | C. 12th-fret octaves | Higher-frequency detection — YIN's octave-up risk | `sharp` bin spiking | | D. Sustained notes (4 s) | The `active` held-on-pitch glow | `sharp`/`flat` drift while held | | E. Hammer-on / pull-off | Transient detection without a fresh pick attack | `pure` (no transient registered) | | F. Power chords (2-string) | Chord leniency on sparse voicings | `chordPartial` | | G. Open major chords | Chord leniency on dense voicings (E, A, D, G) | `chordPartial` | | H. Bends (half- + whole-step) | Single-note pitch tolerance with pitch in motion | `sharp` bin | ## Reporting Share the JSON (schema `note_detect.diagnostic.v1`). It includes: - Hit/miss totals split single-note vs chord - Primary-cause bin per miss (pure / chord-partial / early / late / sharp / flat) - Per-string hit rate - Signed timing- and pitch-error percentiles (p10 / median / p90) - Detection settings snapshot (method, tolerances, leniency) - Per-judgment event log (capped at 2000 events) with the chart note's technique flags so each miss can be re-binned by `SUS`/`B`/`H`/etc. offline - `benchmark_hint`: `{{title, artist, arrangement}}` — filter on these to bucket reports from different runs of this benchmark. ## Source Built by `docs/benchmarks/note_detect_v1/build_benchmark.py` in the slopsmith repo. Tweak the exercise list there and regenerate. """ if __name__ == '__main__': if len(sys.argv) != 2: print('usage: build_benchmark.py ', file=sys.stderr) sys.exit(2) build(Path(sys.argv[1]))