feat(drums): capture velocities alongside times in unmapped-percussion reporting

Both drum converters opt-in out_unmapped capture (convert_drum_track_from_midi,
convert_drum_track_to_drumtab) gain an index-aligned `velocities` list next to
`times`, carrying each dropped note real dynamics — MIDI velocity verbatim; GP
velocity with the same 1-127 gate as mapped hits, falling back to the 100
import default. A hand-mapping UI (the editor unmapped-notes dialog) can then
restore mapped notes at their source dynamics instead of flattening to v:100
(editor-side consumer: feedBack-plugin-editor#111).

The GP path chronological sort now reorders times and velocities in LOCKSTEP
so multi-voice measures cannot silently reassign dynamics. Additive: callers
that ignore the new key are unaffected.

Tests: extended tests/test_midi_import_drums.py + tests/test_gp2rs_drums.py
(alignment, lockstep sort, out-of-range fallback) — 26 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu
This commit is contained in:
ChrisBeWithYou
2026-07-07 07:49:12 -05:00
co-authored by Claude Fable 5
parent cb72c5ab34
commit 228f2da2b0
5 changed files with 84 additions and 19 deletions
+1
View File
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
### Added
- **Unmapped-percussion capture now records velocities alongside times.** Both drum converters' opt-in `out_unmapped` reporting (`lib/midi_import.py` `convert_drum_track_from_midi`, `lib/gp2rs.py` `convert_drum_track_to_drumtab`) gain an index-aligned `velocities` list next to `times`, carrying each dropped note's real dynamics (MIDI velocity verbatim; GP velocity with the same 1127 gate as mapped hits, falling back to the 100 import default). This lets a hand-mapping UI (the editor's unmapped-notes dialog) restore mapped notes at their source dynamics instead of flattening everything to `v:100`. The GP path's chronological sort now reorders times and velocities in lockstep so multi-voice measures can't silently reassign dynamics. Additive — callers that ignore the new key are unaffected. Tests: `tests/test_midi_import_drums.py`, `tests/test_gp2rs_drums.py`.
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing OkabeIto "Colorblind-friendly" preset — contributed by a deuteranopic player who found the OkabeIto set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
+22 -5
View File
@@ -1836,9 +1836,10 @@ def convert_drum_track_to_drumtab(
drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are
skipped — round-tripping them would require teaching `lib/drums.py` first.
Callers can pass an empty dict as ``out_unmapped`` to receive a per-MIDI
record of every skipped note (``{midi: {"count": int, "times": [...]}}``,
times capped at 100 samples per note) so they can surface a warning or
offer a manual mapping UI.
record of every skipped note (``{midi: {"count": int, "times": [...],
"velocities": [...]}}``, times/velocities index-aligned and capped at
100 samples per note — velocities carry the source notes' real dynamics)
so they can surface a warning or offer a manual mapping UI.
Honours GP repeat brackets and D.S./D.C./Coda/Fine jumps when
``expand_repeats`` is true — same `_build_playback_schedule` machinery
@@ -1894,10 +1895,18 @@ def convert_drum_track_to_drumtab(
# NB: do NOT shadow the outer `entry` loop
# variable from `for entry in schedule:`.
unmapped_rec = out_unmapped.setdefault(
int(midi_note), {"count": 0, "times": []})
int(midi_note),
{"count": 0, "times": [], "velocities": []})
unmapped_rec["count"] += 1
if len(unmapped_rec["times"]) < 100:
unmapped_rec["times"].append(round(t, 3))
# Index-aligned with times: the note's real
# dynamics (same 1-127 gate as mapped hits,
# falling back to the 100 import default) so
# a hand-mapping UI doesn't flatten them.
_uv = int(getattr(note, "velocity", 0) or 0)
unmapped_rec["velocities"].append(
_uv if 1 <= _uv <= 127 else 100)
continue
hit: dict = {"t": round(t, 3), "p": piece}
@@ -1946,9 +1955,17 @@ def convert_drum_track_to_drumtab(
# Times for unmapped notes were collected in beat-iteration order;
# multi-voice measures can produce out-of-order beats, so sort each
# entry's `times` list chronologically before returning to the caller.
# Velocities are index-aligned with times, so they must sort in
# LOCKSTEP — sorting times alone would silently reassign dynamics.
if out_unmapped is not None:
for _rec in out_unmapped.values():
_rec["times"].sort()
_vels = _rec.get("velocities")
if _vels and len(_vels) == len(_rec["times"]):
_pairs = sorted(zip(_rec["times"], _vels))
_rec["times"] = [p[0] for p in _pairs]
_rec["velocities"] = [p[1] for p in _pairs]
else:
_rec["times"].sort()
return {
"version": drums_mod.SCHEMA_VERSION,
+10 -5
View File
@@ -486,10 +486,12 @@ def convert_drum_track_from_midi(
Callers can pass an empty dict as ``out_unmapped`` to receive a
per-MIDI record of every channel-9 note_on that didn't resolve to a
piece-id (``{midi: {"count": int, "times": [float, ...]}}``, times
capped at 100 samples per note). The default path skips this
capture entirely so MIDIs heavy with cowbell/tambourine/etc. take
no extra work.
piece-id (``{midi: {"count": int, "times": [float, ...],
"velocities": [int, ...]}}``, times/velocities index-aligned and
capped at 100 samples per note — velocities carry the source notes'
real dynamics so a hand-mapping UI doesn't have to flatten them to a
default). The default path skips this capture entirely so MIDIs
heavy with cowbell/tambourine/etc. take no extra work.
"""
offset = float(audio_offset)
if not math.isfinite(offset):
@@ -527,10 +529,13 @@ def convert_drum_track_from_midi(
continue
t = tick_to_seconds(abs_tick) + offset
entry = out_unmapped.setdefault(
midi_note, {"count": 0, "times": []})
midi_note, {"count": 0, "times": [], "velocities": []})
entry["count"] += 1
if len(entry["times"]) < 100:
entry["times"].append(round(t, 3))
# Index-aligned with times: the note's real dynamics,
# so hand-mapping doesn't flatten everything to 100.
entry["velocities"].append(int(msg.velocity))
continue
# Mapped note: compute t once for the raw entry.
t = tick_to_seconds(abs_tick) + offset
+43 -5
View File
@@ -223,15 +223,15 @@ def test_unmapped_percussion_silently_skipped(monkeypatch):
def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
"""Opting in via out_unmapped records the dropped MIDI notes (count +
times) so a caller can surface a warning / mapping UI."""
times + velocities) so a caller can surface a warning / mapping UI."""
_setup(monkeypatch)
track = _fake_track(
string_midis=[56, 36, 54],
beats=[
(0.0, [_fake_note(string_idx=1)]), # cowbell — drop
(1.0, [_fake_note(string_idx=2)]), # kick — keep
(1.5, [_fake_note(string_idx=3)]), # tambourine — drop
(2.0, [_fake_note(string_idx=1)]), # cowbell again — drop
(0.0, [_fake_note(string_idx=1, velocity=88)]), # cowbell — drop
(1.0, [_fake_note(string_idx=2)]), # kick — keep
(1.5, [_fake_note(string_idx=3, velocity=25)]), # tambourine — drop
(2.0, [_fake_note(string_idx=1, velocity=44)]), # cowbell again — drop
],
)
song = SimpleNamespace(tracks=[track])
@@ -247,6 +247,44 @@ def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
# Times are captured (rounded to 3 dp).
assert unmapped[56]["times"] == [0.0, 2.0]
assert unmapped[54]["times"] == [1.5]
# Velocities ride index-aligned with times — the mapping UI can carry
# the source dynamics through instead of flattening to a default.
assert unmapped[56]["velocities"] == [88, 44]
assert unmapped[54]["velocities"] == [25]
def test_unmapped_velocities_sort_in_lockstep_with_times(monkeypatch):
"""Multi-voice measures can capture times out of order; the final sort
must reorder velocities WITH their times, not leave them behind."""
_setup(monkeypatch)
track = _fake_track(
string_midis=[56],
beats=[
# Deliberately reversed chronology within the measure.
(2.0, [_fake_note(string_idx=1, velocity=44)]),
(0.0, [_fake_note(string_idx=1, velocity=88)]),
],
)
song = SimpleNamespace(tracks=[track])
unmapped: dict[int, dict] = {}
gp2rs.convert_drum_track_to_drumtab(song, 0, out_unmapped=unmapped)
assert unmapped[56]["times"] == [0.0, 2.0]
assert unmapped[56]["velocities"] == [88, 44], \
"velocity must follow its time through the sort"
def test_unmapped_out_of_range_velocity_falls_back_to_default(monkeypatch):
"""A corrupt/zero GP velocity records the 100 import default rather
than poisoning the aligned list."""
_setup(monkeypatch)
track = _fake_track(
string_midis=[56],
beats=[(0.0, [_fake_note(string_idx=1, velocity=0)])],
)
song = SimpleNamespace(tracks=[track])
unmapped: dict[int, dict] = {}
gp2rs.convert_drum_track_to_drumtab(song, 0, out_unmapped=unmapped)
assert unmapped[56]["velocities"] == [100]
def test_zero_velocity_omitted_from_wire(monkeypatch):
+8 -4
View File
@@ -185,18 +185,18 @@ def test_unmapped_drum_note_skipped(tmp_path):
def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
"""Opting in via out_unmapped records the dropped MIDI notes (count +
times) so a caller can surface a warning / mapping UI."""
times + velocities) so a caller can surface a warning / mapping UI."""
mid = mido.MidiFile(type=1, ticks_per_beat=480)
track = mido.MidiTrack()
mid.tracks.append(track)
track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
track.append(mido.Message("note_on", channel=9, note=56, velocity=100, time=0)) # cowbell — drop
track.append(mido.Message("note_on", channel=9, note=56, velocity=88, time=0)) # cowbell — drop
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
track.append(mido.Message("note_on", channel=9, note=36, velocity=100, time=0)) # kick — keep
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=240))
track.append(mido.Message("note_on", channel=9, note=54, velocity=100, time=0)) # tambourine — drop
track.append(mido.Message("note_on", channel=9, note=54, velocity=25, time=0)) # tambourine — drop
track.append(mido.Message("note_off", channel=9, note=54, velocity=0, time=240))
track.append(mido.Message("note_on", channel=9, note=56, velocity=100, time=0)) # cowbell again — drop
track.append(mido.Message("note_on", channel=9, note=56, velocity=44, time=0)) # cowbell again — drop
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
unmapped: dict[int, dict] = {}
@@ -209,6 +209,10 @@ def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
# Each unmapped MIDI carries the times at which it fired (rounded 3 dp).
assert all(isinstance(t, float) for t in unmapped[56]["times"])
assert len(unmapped[56]["times"]) == 2
# Velocities ride index-aligned with times — the mapping UI can carry
# the source dynamics through instead of flattening to a default.
assert unmapped[56]["velocities"] == [88, 44]
assert unmapped[54]["velocities"] == [25]
def test_non_channel9_events_ignored(tmp_path):