mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 20:34:30 +00:00
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:
co-authored by
Claude Fable 5
parent
cb72c5ab34
commit
228f2da2b0
@@ -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.5x–2x), 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 1–127 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 Okabe–Ito "Colorblind-friendly" preset — contributed by a deuteranopic player who found the Okabe–Ito 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 117–123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
|
||||
|
||||
+22
-5
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user