feedBack/tests/test_midi_import_drums.py
ChrisBeWithYou 5cb4ea0623
feat(drums): capture velocities alongside times in unmapped-percussion reporting (#808)
* 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

* docs(gp2rs): clarify velocity-default comment, mark dead-path fallback

- The mapped-GP velocity comment conflated GP's authoring default (95,
  Velocities.default) with the drumtab render default (100,
  DEFAULT_VELOCITY in lib/drums.py) used when `v` is omitted. Clarify
  both defaults and that only the latter applies to omitted hits.
- Mark the `else: times.sort()` fallback in the unmapped-percussion
  time/velocity sort as belt-and-suspenders — times and velocities are
  always appended together under the same len<100 guard, so lengths
  can't actually diverge.

No behavior change; comment-only maintainability nits from PR review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 23:58:49 +02:00

244 lines
12 KiB
Python

"""Tests for `list_drum_tracks` and `convert_drum_track_from_midi`.
Builds synthetic MIDI files in-memory (no fixtures on disk) and exercises
the drum-tab extraction: channel-9 listing, piece resolution, ghost-velocity
flag, flam-window collapse, choke-window note-off, and tempo-aware timing.
"""
from __future__ import annotations
import mido
from midi_import import list_drum_tracks, convert_drum_track_from_midi
def _save(mid: mido.MidiFile, tmp_path, name: str = "drums.mid") -> str:
p = tmp_path / name
mid.save(str(p))
return str(p)
# ── list_drum_tracks ──────────────────────────────────────────────────────────
def test_list_drum_tracks_skips_tracks_without_channel9(tmp_path):
mid = mido.MidiFile(type=1, ticks_per_beat=480)
piano = mido.MidiTrack()
mid.tracks.append(piano)
piano.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=0))
piano.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=480))
assert list_drum_tracks(_save(mid, tmp_path)) == []
def test_list_drum_tracks_returns_channel9_entry(tmp_path):
mid = mido.MidiFile(type=1, ticks_per_beat=480)
track = mido.MidiTrack()
mid.tracks.append(track)
track.append(mido.MetaMessage("track_name", name="Drums", time=0))
track.append(mido.Message("note_on", channel=9, note=36, velocity=100, time=0))
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=120))
track.append(mido.Message("note_on", channel=9, note=38, velocity=92, time=0))
track.append(mido.Message("note_off", channel=9, note=38, velocity=0, time=120))
tracks = list_drum_tracks(_save(mid, tmp_path))
assert len(tracks) == 1
t = tracks[0]
assert t["channel"] == 9
assert t["channel_filter"] == 9
assert t["is_drums"] is True
assert t["notes"] == 2
assert t["name"] == "Drums"
def test_list_drum_tracks_surfaces_format0_mixed_track(tmp_path):
"""A format-0 track containing both piano (ch0) and drum (ch9) events
must surface once — list_midi_tracks already handles the piano half."""
mid = mido.MidiFile(type=0, ticks_per_beat=480)
track = mido.MidiTrack()
mid.tracks.append(track)
track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=0))
track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=480))
track.append(mido.Message("note_on", channel=9, note=36, velocity=100, time=0))
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=120))
drums = list_drum_tracks(_save(mid, tmp_path))
assert len(drums) == 1
assert drums[0]["notes"] == 1
# ── convert_drum_track_from_midi ──────────────────────────────────────────────
def test_basic_conversion_piece_and_velocity(tmp_path):
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)) # 120 BPM
track.append(mido.Message("note_on", channel=9, note=36, velocity=110, time=0)) # kick @ 0s
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=240)) # 0.25s
track.append(mido.Message("note_on", channel=9, note=38, velocity=92, time=240)) # snare @ 0.5s
track.append(mido.Message("note_off", channel=9, note=38, velocity=0, time=240))
out = convert_drum_track_from_midi(_save(mid, tmp_path), 0)
assert out["version"] == 1
hits = out["hits"]
assert [(h["t"], h["p"], h["v"]) for h in hits] == [
(0.0, "kick", 110),
(0.5, "snare", 92),
]
assert {k["id"] for k in out["kit"]} == {"kick", "snare"}
def test_ghost_velocity_flag(tmp_path):
"""Velocity below 40 marks the hit as a ghost note."""
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=38, velocity=30, time=0))
track.append(mido.Message("note_off", channel=9, note=38, velocity=0, time=120))
hits = convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"]
assert hits[0].get("g") is True
def test_flam_collapse_within_30ms(tmp_path):
"""Two snare hits 20 ms apart collapse into one flam — the louder
survives, marked `f: true`."""
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)) # 120 BPM, 480 tpqn → 1 tick = 1.0416ms
# ghost lead-in at t=0 (≈10 ticks ≈ 0.01s would be too small; use 20ms = ~19 ticks),
# then main hit at t≈20ms after that.
# 480 tpqn at 120 BPM → 1 tick ≈ 1.0417 ms. 20 ms ≈ 19 ticks.
track.append(mido.Message("note_on", channel=9, note=38, velocity=30, time=0)) # ghost
track.append(mido.Message("note_off", channel=9, note=38, velocity=0, time=5))
track.append(mido.Message("note_on", channel=9, note=38, velocity=110, time=10)) # main, 15 ticks after ghost onset → ~15.6ms (1 tick ≈ 1.04ms at 120BPM/480tpqn)
track.append(mido.Message("note_off", channel=9, note=38, velocity=0, time=120))
out = convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"]
assert len(out) == 1, f"expected flam collapse, got {out}"
assert out[0]["p"] == "snare"
assert out[0]["v"] == 110
assert out[0].get("f") is True
def test_flam_window_does_not_collapse_unrelated_pieces(tmp_path):
"""Snare + kick 10 ms apart must NOT collapse — flams only apply to the
same piece (drummers don't flam across pieces)."""
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=38, velocity=100, time=0)) # snare
track.append(mido.Message("note_off", channel=9, note=38, velocity=0, time=5))
track.append(mido.Message("note_on", channel=9, note=36, velocity=100, time=5)) # kick
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=120))
hits = convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"]
assert {h["p"] for h in hits} == {"snare", "kick"}
assert all("f" not in h for h in hits)
def test_choke_on_short_cymbal_note(tmp_path):
"""A crash with a note-off arriving 80 ms after note-on is treated as a
cymbal choke; non-cymbal pieces don't get a `k` field even for short
durations."""
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)) # 120 BPM → 1 tick = 1.0417ms
# Crash @0, off ~80ms (=76 ticks)
track.append(mido.Message("note_on", channel=9, note=49, velocity=120, time=0))
track.append(mido.Message("note_off", channel=9, note=49, velocity=0, time=77))
# Snare @t≈0.1s with a short note-off (must not produce k).
track.append(mido.Message("note_on", channel=9, note=38, velocity=100, time=0))
track.append(mido.Message("note_off", channel=9, note=38, velocity=0, time=50))
out = convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"]
crash = next(h for h in out if h["p"] == "crash_l")
snare = next(h for h in out if h["p"] == "snare")
assert "k" in crash and 0.0 < crash["k"] <= 0.12
assert "k" not in snare
def test_choke_window_does_not_fire_for_long_cymbal_sustain(tmp_path):
"""A crash held for 500 ms is a regular open ring, not a choke."""
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))
# 500ms ≈ 480 ticks at 120 BPM
track.append(mido.Message("note_on", channel=9, note=49, velocity=120, time=0))
track.append(mido.Message("note_off", channel=9, note=49, velocity=0, time=480))
hit = convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"][0]
assert "k" not in hit
def test_unmapped_drum_note_skipped(tmp_path):
"""Cowbell (MIDI 56) isn't in the vocab — must be skipped."""
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_off", channel=9, note=56, velocity=0, time=240))
track.append(mido.Message("note_on", channel=9, note=36, velocity=100, time=240)) # kick — keep
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=120))
hits = convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"]
assert len(hits) == 1
assert hits[0]["p"] == "kick"
def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
"""Opting in via out_unmapped records the dropped MIDI notes (count +
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=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=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=44, time=0)) # cowbell again — drop
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
unmapped: dict[int, dict] = {}
hits = convert_drum_track_from_midi(
_save(mid, tmp_path), 0, out_unmapped=unmapped)["hits"]
assert [h["p"] for h in hits] == ["kick"]
assert set(unmapped.keys()) == {56, 54}
assert unmapped[56]["count"] == 2
assert unmapped[54]["count"] == 1
# 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):
"""A note_on on channel 0 with MIDI 36 is NOT a kick — the converter
must scope to channel 9."""
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=0, note=36, velocity=100, time=0))
track.append(mido.Message("note_off", channel=0, note=36, velocity=0, time=240))
assert convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"] == []
def test_hihat_openness_preserved(tmp_path):
"""Three hi-hat MIDI notes round-trip as three distinct piece-ids — the
drums plugin's hit detector relies on this to reject closed-hat strikes
on open-hat notes."""
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))
for offset, midi_n in enumerate((42, 46, 44)):
track.append(mido.Message("note_on", channel=9, note=midi_n, velocity=80,
time=480 if offset else 0))
track.append(mido.Message("note_off", channel=9, note=midi_n, velocity=0, time=120))
pids = [h["p"] for h in convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"]]
assert pids == ["hh_closed", "hh_open", "hh_pedal"]