mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-25 14:21:21 +00:00
* Update GitHub repo references from feedback* to feedBack* * rename: slopsmith -> feedBack, byron -> got-feedBack Renames across the entire codebase: - slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack - byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack - /home/byron/ -> /opt/got-feedBack/ - byron@ougsoft.com -> hi@got-feedBack.org - github.com/byrongamatos/ -> github.com/got-feedback/ - com.byron. -> com.got-feedback. - SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback - Protocol/storage strings migrated with read-old/write-new pattern - window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias Refs: #rename-slopsmith * rename: complete regen against current main + fix backward-compat alias Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of current main (3 commits had landed since the branch: #572/#554/#574), resolving the four content conflicts in favour of main's newer content (autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge). Completion fixes on top of the mechanical rename: - Re-apply rename to post-branch content the original rename never saw: window.slopsmith(.Tour) consumers in lessons.js / notifications.js / onboarding-tour.js, and the matching JS + python tests (autoplay_exit, progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env vars now match server.py (which reads FEEDBACK_SYNC_STARTUP / FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real path again. - Restore the window.slopsmith backward-compat alias dropped during conflict resolution, and move the bus aliases to AFTER the _feedBackExisting merge block so they reference the fully-assembled object (also fixes the loop_api.test.js API-surface regex, which the original PR latently broke). - Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR) and gitignore it. - Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped source labels. Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669 passed / 1683 collected with 0 import errors; zero residual slopsmith/byron except the two intentional window.slopsmith aliases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rename: implement advertised backward-compat + prune dead community plugins Address gaps where PR #537's "Backward compatibility" section was advertised but not implemented, and clean up the community plugin list. Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured): - New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI / SLOPSMITH_PLUGINS_DIR / etc. deployments keep working. - Fix the rename collapsing plugins/__init__.py and minigames/routes.py from `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost). Storage (app.js update-channel): - Read feedBack-update-channel, fall back to legacy slopsmith-update-channel, and clear the legacy key on write — so a user's update-channel preference survives the rename instead of resetting to "stable". Community plugin list (README): the rename rewrote third-party repo URLs we don't own. Probed every one; their owners never renamed, so: - Restore the 13 live community plugins to their real slopsmith-* names. - Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle). - Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory). Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors, rename-sensitive + startup suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: byrongamatos <xasiklas@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
513 lines
19 KiB
Python
513 lines
19 KiB
Python
"""Builds the Note Detect Benchmark sloppak (v2).
|
||
|
||
A relaxed-pace test piece tuned for the player's strengths: half-note
|
||
spacing throughout, no hammer-on / pull-off section, no bend section,
|
||
no fast staccato. Adds explicit strumming sections (single chord
|
||
repeated at half-note cadence) so the chord scorer is exercised
|
||
across a sequence of strums on the same voicing — closer to how
|
||
chords actually appear in real songs than v1's single-stroke
|
||
voicings.
|
||
|
||
Goals vs v1:
|
||
- More breathing room between every event (half-notes, ~1.33 s at
|
||
90 BPM, instead of v1's quarter notes at ~0.667 s).
|
||
- More chord events overall, with strumming patterns.
|
||
- Drop the technique sections (HO/PO/bends) — the detector's
|
||
technique handling is the next algorithm focus, separate from
|
||
measuring "do basic single notes + chords score correctly?"
|
||
|
||
How to run inside the feedBack container:
|
||
|
||
docker cp docs/benchmarks/note_detect_v2/build_benchmark.py \\
|
||
feedBack-web-1:/tmp/build_benchmark_v2.py
|
||
docker exec feedBack-web-1 python /tmp/build_benchmark_v2.py \\
|
||
/app/static/sloppak_cache/note_detect_benchmark_v2.sloppak
|
||
|
||
After regenerating, copy the zip output to the tracked path with the
|
||
`.sloppak` (not `.sloppak.zip`) suffix — same gotcha as v1:
|
||
|
||
cp static/sloppak_cache/note_detect_benchmark_v2.sloppak.zip \\
|
||
docs/benchmarks/note_detect_v2/note_detect_benchmark_v2.sloppak
|
||
"""
|
||
|
||
import json
|
||
import math
|
||
import shutil
|
||
import struct
|
||
import subprocess
|
||
import sys
|
||
import wave
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
|
||
# ── Benchmark parameters ────────────────────────────────────────────────
|
||
BPM = 90.0
|
||
SECONDS_PER_BEAT = 60.0 / BPM
|
||
BEATS_PER_BAR = 4
|
||
BAR_S = BEATS_PER_BAR * SECONDS_PER_BEAT
|
||
INTRO_BARS = 2
|
||
OUTRO_BARS = 2
|
||
EXERCISE_BARS = 8 # v2 uses 8-bar sections (was 6 in v1) for extra breathing room.
|
||
|
||
# Standard E-tuning open MIDI per string, low → high.
|
||
OPEN_MIDI = [40, 45, 50, 55, 59, 64] # E2 A2 D3 G3 B3 E4
|
||
|
||
SR = 44100
|
||
|
||
|
||
# ── Click-track audio generator ────────────────────────────────────────
|
||
def _sine_burst(freq_hz, duration_s, amplitude):
|
||
n = int(SR * duration_s)
|
||
out = []
|
||
fade = max(1, int(0.004 * SR))
|
||
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(max(-1.0, min(1.0, s)))
|
||
return out
|
||
|
||
|
||
def write_click_wav(path: Path, duration_s: float):
|
||
"""Per-beat click track. Downbeats louder + higher pitch."""
|
||
total_samples = int(SR * duration_s)
|
||
pcm = [0] * total_samples
|
||
beat = 0
|
||
t = 0.0
|
||
while t < duration_s:
|
||
is_downbeat = (beat % BEATS_PER_BAR == 0)
|
||
freq = 1200 if is_downbeat else 800
|
||
amp = 0.6 if is_downbeat else 0.35
|
||
burst = _sine_burst(freq, 0.040, amp)
|
||
start = int(t * SR)
|
||
for i, s in enumerate(burst):
|
||
j = start + i
|
||
if 0 <= j < total_samples:
|
||
pcm[j] = int(max(-1.0, min(1.0, pcm[j] / 32767 + s)) * 32767)
|
||
t += SECONDS_PER_BEAT
|
||
beat += 1
|
||
|
||
pcm = [struct.pack('<h', v) for v in pcm]
|
||
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with wave.open(str(path), 'wb') as w:
|
||
w.setnchannels(1)
|
||
w.setsampwidth(2)
|
||
w.setframerate(SR)
|
||
w.writeframes(bytes(b''.join(pcm)))
|
||
|
||
|
||
# ── Chart helpers ─────────────────────────────────────────────────────
|
||
def note(t, s, f, sus=0.0, **flags):
|
||
return {
|
||
't': round(t, 3),
|
||
's': s,
|
||
'f': f,
|
||
'sus': round(sus, 3),
|
||
'sl': flags.get('sl', -1),
|
||
'slu': flags.get('slu', -1),
|
||
'bn': flags.get('bn', 0.0),
|
||
'ho': flags.get('ho', False),
|
||
'po': flags.get('po', False),
|
||
'hm': flags.get('hm', False),
|
||
'hp': flags.get('hp', False),
|
||
'pm': flags.get('pm', False),
|
||
'mt': flags.get('mt', False),
|
||
'vb': flags.get('vb', False),
|
||
'tr': flags.get('tr', False),
|
||
'ac': flags.get('ac', False),
|
||
'tp': flags.get('tp', False),
|
||
}
|
||
|
||
|
||
def chord(t, id_, notes):
|
||
return {
|
||
't': round(t, 3),
|
||
'id': id_,
|
||
'hd': False,
|
||
'notes': notes,
|
||
}
|
||
|
||
|
||
def chord_note(s, f, sus=0.0, **flags):
|
||
n = note(0.0, s, f, sus, **flags)
|
||
n.pop('t')
|
||
return n
|
||
|
||
|
||
# ── Exercises ─────────────────────────────────────────────────────────
|
||
# v2 single-note exercises: HALF-NOTE pacing (2 beats / 1.33 s between
|
||
# events). That's roughly half the density of v1's quarter-note pacing,
|
||
# giving the player time to mute, reset, and re-pluck cleanly.
|
||
#
|
||
# v2 chord exercises: each chord voicing is STRUMMED multiple times at
|
||
# the same half-note cadence. Two reasons:
|
||
# 1. Real songs strum chords; single-stroke voicings are an
|
||
# artificial test that doesn't exercise the chord scorer's
|
||
# consistency across repeated strikes.
|
||
# 2. Multiple strums per voicing give the user a forgiving runway —
|
||
# if they nail 3 of 4 strums of an E5 power chord, that's still
|
||
# mostly hits.
|
||
|
||
def exercise_open_strings_slow(t0):
|
||
"""Open strings, half-note pacing, low → high → low. Wide spacing
|
||
lets each string ring out before the next is plucked, so the
|
||
detector has clean steady-state pitch to lock onto."""
|
||
seq = [0, 1, 2, 3, 4, 5, 5, 4, 3, 2, 1, 0] # 12 strings, half-notes = 24 beats = 6 bars
|
||
notes_out = []
|
||
for i, s in enumerate(seq):
|
||
notes_out.append(note(t0 + i * 2 * SECONDS_PER_BEAT, s, 0,
|
||
sus=SECONDS_PER_BEAT * 1.6))
|
||
# Final note rings into the 2-bar tail of the section.
|
||
notes_out[-1]['sus'] = round(SECONDS_PER_BEAT * 4, 3)
|
||
return notes_out, [], 'Open strings (slow walk)'
|
||
|
||
|
||
def exercise_fretted_positions_slow(t0):
|
||
"""5th-fret on each string, half-note pacing, ascending. One
|
||
direction (no descent) so total runtime fits comfortably in 8 bars
|
||
with plenty of tail room."""
|
||
seq = [(s, 5) for s in range(6)] # 6 notes × 2 beats = 12 beats = 3 bars
|
||
notes_out = []
|
||
for i, (s, f) in enumerate(seq):
|
||
notes_out.append(note(t0 + i * 2 * SECONDS_PER_BEAT, s, f,
|
||
sus=SECONDS_PER_BEAT * 1.6))
|
||
notes_out[-1]['sus'] = round(SECONDS_PER_BEAT * 4, 3)
|
||
return notes_out, [], 'Fretted positions (slow walk, 5th fret)'
|
||
|
||
|
||
def exercise_sustained(t0):
|
||
"""Three 4-second sustained notes (low E, D, high E — spread across
|
||
the range). 4 s ring + 1 s gap = 5 s per event × 3 events = 15 s,
|
||
comfortably inside an 8-bar (≈ 21.3 s) section."""
|
||
sus = 4.0
|
||
targets = [(0, 5), (2, 7), (5, 5)]
|
||
notes_out = []
|
||
for i, (s, f) in enumerate(targets):
|
||
notes_out.append(note(t0 + i * (sus + 1.0), s, f, sus=sus))
|
||
return notes_out, [], 'Sustained notes (3 holds, 4 s each)'
|
||
|
||
|
||
def exercise_e5_strum(t0):
|
||
"""E5 power chord strummed at half-note cadence. 8 strums × 2
|
||
beats = 16 beats = 4 bars of strumming, plus 4 bars of tail."""
|
||
voicing = [(0, 0), (1, 2)] # low E open + A fret 2 = E5
|
||
strums = 8
|
||
chords_out = []
|
||
sus = SECONDS_PER_BEAT * 1.6 # ring through the next strum, not past it
|
||
template = {
|
||
'name': 'E5', 'displayName': 'E5', 'arp': False,
|
||
'fingers': [-1] * 6,
|
||
'frets': [0 if s == 0 else (2 if s == 1 else -1) for s in range(6)],
|
||
}
|
||
for i in range(strums):
|
||
chord_notes = [chord_note(s, f, sus=sus) for (s, f) in voicing]
|
||
chords_out.append(chord(t0 + i * 2 * SECONDS_PER_BEAT, 0, chord_notes))
|
||
return [], (chords_out, [template]), 'E5 power chord — slow strum (8×)'
|
||
|
||
|
||
def exercise_a5_e5_alternating(t0):
|
||
"""A5 / E5 alternating, half-note strums. 8 strums total (4 of
|
||
each), gives a "1 5 1 5" feel that's the simplest chord progression
|
||
a player can land — minimal hand movement between voicings."""
|
||
voicings = [
|
||
('A5', [(1, 0), (2, 2)]), # A open + D fret 2 = A5
|
||
('E5', [(0, 0), (1, 2)]), # E open + A fret 2 = E5
|
||
]
|
||
templates = []
|
||
for i, (name, sf) in enumerate(voicings):
|
||
frets = [-1] * 6
|
||
for (s, f) in sf:
|
||
frets[s] = f
|
||
templates.append({
|
||
'name': name, 'displayName': name, 'arp': False,
|
||
'fingers': [-1] * 6, 'frets': frets,
|
||
})
|
||
chords_out = []
|
||
sus = SECONDS_PER_BEAT * 1.6
|
||
strums = 8
|
||
for i in range(strums):
|
||
idx = i % 2 # alternate A5 / E5
|
||
_, sf = voicings[idx]
|
||
chord_notes = [chord_note(s, f, sus=sus) for (s, f) in sf]
|
||
chords_out.append(chord(t0 + i * 2 * SECONDS_PER_BEAT, idx, chord_notes))
|
||
return [], (chords_out, templates), 'A5 / E5 alternating strums (8×)'
|
||
|
||
|
||
def exercise_e_open_strum(t0):
|
||
"""E major open chord, half-note strums. All 6 strings ringing —
|
||
the densest voicing in the benchmark, tests the chord scorer's
|
||
per-string differentiation on the full set."""
|
||
voicing = [(0, 0), (1, 2), (2, 2), (3, 1), (4, 0), (5, 0)]
|
||
strums = 8
|
||
chords_out = []
|
||
sus = SECONDS_PER_BEAT * 1.6
|
||
template = {
|
||
'name': 'E', 'displayName': 'E', 'arp': False,
|
||
'fingers': [-1] * 6,
|
||
'frets': [0, 2, 2, 1, 0, 0],
|
||
}
|
||
for i in range(strums):
|
||
chord_notes = [chord_note(s, f, sus=sus) for (s, f) in voicing]
|
||
chords_out.append(chord(t0 + i * 2 * SECONDS_PER_BEAT, 0, chord_notes))
|
||
return [], (chords_out, [template]), 'E major open chord — slow strum (8×)'
|
||
|
||
|
||
def exercise_a_open_strum(t0):
|
||
"""A major open chord, half-note strums. 5 strings (skips low E).
|
||
Slightly easier than E for the player (less stretch) and tests
|
||
the scorer's behaviour on a missing-low-string voicing."""
|
||
voicing = [(1, 0), (2, 2), (3, 2), (4, 2), (5, 0)]
|
||
strums = 8
|
||
chords_out = []
|
||
sus = SECONDS_PER_BEAT * 1.6
|
||
template = {
|
||
'name': 'A', 'displayName': 'A', 'arp': False,
|
||
'fingers': [-1] * 6,
|
||
'frets': [-1, 0, 2, 2, 2, 0],
|
||
}
|
||
for i in range(strums):
|
||
chord_notes = [chord_note(s, f, sus=sus) for (s, f) in voicing]
|
||
chords_out.append(chord(t0 + i * 2 * SECONDS_PER_BEAT, 0, chord_notes))
|
||
return [], (chords_out, [template]), 'A major open chord — slow strum (8×)'
|
||
|
||
|
||
def exercise_d_open_strum(t0):
|
||
"""D major open chord, half-note strums. 4 strings (skips low E
|
||
and A). Tests the chord scorer on partial voicings — common in
|
||
real songs and an easy stretch for new players."""
|
||
voicing = [(2, 0), (3, 2), (4, 3), (5, 2)]
|
||
strums = 8
|
||
chords_out = []
|
||
sus = SECONDS_PER_BEAT * 1.6
|
||
template = {
|
||
'name': 'D', 'displayName': 'D', 'arp': False,
|
||
'fingers': [-1] * 6,
|
||
'frets': [-1, -1, 0, 2, 3, 2],
|
||
}
|
||
for i in range(strums):
|
||
chord_notes = [chord_note(s, f, sus=sus) for (s, f) in voicing]
|
||
chords_out.append(chord(t0 + i * 2 * SECONDS_PER_BEAT, 0, chord_notes))
|
||
return [], (chords_out, [template]), 'D major open chord — slow strum (8×)'
|
||
|
||
|
||
EXERCISES = [
|
||
('A. Open strings (slow)', exercise_open_strings_slow),
|
||
('B. 5th-fret (slow)', exercise_fretted_positions_slow),
|
||
('C. Sustained notes', exercise_sustained),
|
||
('D. E5 power chord strum', exercise_e5_strum),
|
||
('E. A5 / E5 alternating', exercise_a5_e5_alternating),
|
||
('F. E major strum', exercise_e_open_strum),
|
||
('G. A major strum', exercise_a_open_strum),
|
||
('H. D major strum', exercise_d_open_strum),
|
||
]
|
||
|
||
|
||
# ── Driver ─────────────────────────────────────────────────────────────
|
||
def build(out_dir: Path):
|
||
notes_all = []
|
||
chords_all = []
|
||
templates_all = []
|
||
sections = []
|
||
beats = []
|
||
|
||
t = INTRO_BARS * BAR_S
|
||
for label, fn in EXERCISES:
|
||
sections.append({'name': label, 'number': len(sections) + 1, 'time': round(t, 3)})
|
||
result = fn(t)
|
||
ns, ch_or_tuple, _desc = result
|
||
notes_all.extend(ns)
|
||
if isinstance(ch_or_tuple, tuple):
|
||
cs, tmpls = ch_or_tuple
|
||
# Rebase section-local chord template ids onto the global
|
||
# `templates_all` list. Each exercise emits its chords
|
||
# with `tmpl_id` numbered from 0 within the exercise; if
|
||
# we naively appended both chords and templates without
|
||
# offsetting, later sections' chords would silently
|
||
# reference earlier sections' templates (e.g. an open
|
||
# chord pointing at a power-chord shape). Apply the
|
||
# offset to each chord's `id` field before extending the
|
||
# global lists.
|
||
offset = len(templates_all)
|
||
for c in cs:
|
||
c['id'] = c.get('id', 0) + offset
|
||
chords_all.extend(cs)
|
||
templates_all.extend(tmpls)
|
||
else:
|
||
chords_all.extend(ch_or_tuple)
|
||
t += EXERCISE_BARS * BAR_S
|
||
|
||
end_t = t + OUTRO_BARS * BAR_S
|
||
|
||
# Beats — measure markers on downbeats.
|
||
bar_count = 0
|
||
bt = 0.0
|
||
while bt < end_t:
|
||
is_downbeat = abs(bt % BAR_S) < 1e-3
|
||
if is_downbeat:
|
||
bar_count += 1
|
||
beats.append({'time': round(bt, 3), 'measure': bar_count})
|
||
else:
|
||
beats.append({'time': round(bt, 3), 'measure': -1})
|
||
bt += SECONDS_PER_BEAT
|
||
|
||
# Anchors — re-anchor on each section so the camera doesn't drift.
|
||
anchors = [{'time': 0.0, 'fret': 1, 'width': 12}]
|
||
for sec in sections:
|
||
anchors.append({'time': sec['time'], 'fret': 1, 'width': 12})
|
||
|
||
arrangement = {
|
||
'name': 'Lead',
|
||
'tuning': [0, 0, 0, 0, 0, 0],
|
||
'capo': 0,
|
||
'notes': sorted(notes_all, key=lambda n: n['t']),
|
||
'chords': sorted(chords_all, key=lambda c: c['t']),
|
||
'anchors': anchors,
|
||
'handshapes': [],
|
||
'templates': templates_all,
|
||
'beats': beats,
|
||
'sections': sections,
|
||
}
|
||
|
||
manifest = {
|
||
'title': 'Note Detect Benchmark v2',
|
||
'artist': 'FeedBack',
|
||
'album': 'Note Detection Benchmark',
|
||
'year': 2026,
|
||
'duration': round(end_t, 3),
|
||
'arrangements': [
|
||
{
|
||
'id': 'lead',
|
||
'name': 'Lead',
|
||
'file': 'arrangements/lead.json',
|
||
'tuning': [0, 0, 0, 0, 0, 0],
|
||
'capo': 0,
|
||
},
|
||
],
|
||
'stems': [
|
||
{'id': 'full', 'file': 'stems/full.ogg', 'default': True},
|
||
],
|
||
'benchmark': {
|
||
'id': 'feedBack-note-detect-benchmark',
|
||
'version': 2,
|
||
},
|
||
}
|
||
|
||
# ── Write files ──
|
||
out_dir = Path(out_dir)
|
||
if out_dir.exists():
|
||
# Defensive — see v1 builder. Only rmtree something that looks
|
||
# like a sloppak so a typo on the CLI doesn't nuke an unrelated
|
||
# directory.
|
||
if not (out_dir.suffix == '.sloppak'
|
||
or (out_dir / 'manifest.yaml').exists()):
|
||
raise RuntimeError(
|
||
f"refusing to rmtree {out_dir!r}: does not look like a sloppak "
|
||
f"(no .sloppak suffix, no manifest.yaml)."
|
||
)
|
||
shutil.rmtree(out_dir)
|
||
out_dir.mkdir(parents=True)
|
||
(out_dir / 'arrangements').mkdir()
|
||
(out_dir / 'stems').mkdir()
|
||
|
||
(out_dir / 'manifest.yaml').write_text(
|
||
yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True),
|
||
encoding='utf-8',
|
||
)
|
||
(out_dir / 'arrangements' / 'lead.json').write_text(
|
||
json.dumps(arrangement, separators=(',', ':')),
|
||
encoding='utf-8',
|
||
)
|
||
|
||
wav_path = out_dir / 'stems' / 'full.wav'
|
||
write_click_wav(wav_path, end_t)
|
||
ogg_path = out_dir / 'stems' / 'full.ogg'
|
||
subprocess.run(
|
||
['ffmpeg', '-y', '-loglevel', 'error',
|
||
'-i', str(wav_path),
|
||
'-c:a', 'libvorbis', '-q:a', '5',
|
||
str(ogg_path)],
|
||
check=True,
|
||
)
|
||
wav_path.unlink()
|
||
|
||
(out_dir / 'BENCHMARK.md').write_text(_benchmark_readme(end_t), encoding='utf-8')
|
||
_build_zip(out_dir)
|
||
|
||
print(f'Built {out_dir}')
|
||
print(f' {out_dir}.zip')
|
||
print(f' Duration: {end_t:.1f} s')
|
||
print(f' Notes: {len(arrangement["notes"])}')
|
||
print(f' Chords: {len(arrangement["chords"])}')
|
||
print(f' Templates:{len(arrangement["templates"])}')
|
||
|
||
|
||
def _build_zip(src_dir: Path):
|
||
"""Pack with fixed dates / attrs for zip-metadata reproducibility.
|
||
See v1 builder docstring for full caveats (OGG framing has its own
|
||
non-determinism we don't try to fix here)."""
|
||
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():
|
||
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
|
||
info.external_attr = (0o644 & 0xFFFF) << 16
|
||
info.create_system = 3 # POSIX — see v1 builder for why
|
||
zf.writestr(info, p.read_bytes())
|
||
|
||
|
||
def _benchmark_readme(duration_s):
|
||
return f"""# FeedBack Note Detect Benchmark — v2
|
||
|
||
A slower-paced companion to v1, focused on what players can actually
|
||
land cleanly. Half-note spacing throughout (~1.33 s between events at
|
||
90 BPM), with multiple **strumming** sections — single chord voicings
|
||
repeated at half-note cadence — to exercise the chord scorer's
|
||
consistency across a sequence of strikes.
|
||
|
||
- **Tempo**: {BPM:g} BPM
|
||
- **Tuning**: E standard (no capo)
|
||
- **Audio**: metronome click track only — play *over* the click.
|
||
- **Duration**: {duration_s:.0f} s
|
||
|
||
## Sections
|
||
|
||
| Section | Tests |
|
||
|---|---|
|
||
| A. Open strings (slow walk) | Basic mono detection, low → high → low at half-note pacing |
|
||
| B. 5th-fret (slow walk) | Fretted-note detection, ascending half-notes |
|
||
| C. Sustained notes | Long-hold pitch detection, 4 s each |
|
||
| D. E5 power chord strum | Chord scorer on a 2-string voicing, 8 strums |
|
||
| E. A5 / E5 alternating | Chord scorer on a voicing change, 8 strums total |
|
||
| F. E major strum | 6-string dense voicing, 8 strums |
|
||
| G. A major strum | 5-string voicing (skips low E), 8 strums |
|
||
| H. D major strum | 4-string voicing (skips low E + A), 8 strums |
|
||
|
||
No hammer/pull, no bends — those are next on the algorithm-tuning
|
||
list and aren't useful as benchmarks until that work lands.
|
||
|
||
## Reporting
|
||
|
||
Share the diagnostic JSON (schema `note_detect.diagnostic.v1`).
|
||
Filter `benchmark_hint` to bucket v1 vs v2 runs.
|
||
|
||
## Source
|
||
|
||
Built by `docs/benchmarks/note_detect_v2/build_benchmark.py`.
|
||
"""
|
||
|
||
|
||
# ── CLI ───────────────────────────────────────────────────────────────
|
||
if __name__ == '__main__':
|
||
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('./note_detect_benchmark_v2.sloppak')
|
||
build(out)
|