mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 21:01:40 +00:00
* feat(midi): convert_midi_tempo_map — extract tempos, time signatures, beat grid
The keys/drums MIDI converters always built a tempo-aware tick->seconds
map internally (to bake note times) and then discarded it — and never
read time_signature meta at all — so every MIDI import landed with no
bars, no measures, and an implied 4/4 no matter what the file said.
New lib/midi_import.py helper convert_midi_tempo_map(midi_path,
track_index) extracts the grid a .mid actually carries:
- tempos: {time, bpm} per tempo event (deduped per tick, 120 default)
- time_signatures: {time, ts: [num, den]} — the song-timeline shape
- beats: one row per beat on the editor grid shape — numbered downbeats
with a den hint, measure:-1 interior beats; the beat unit follows the
active signature (6/8 = six eighth-note rows per bar)
Event scope mirrors _build_tick_to_seconds: SMF type 0/1 merge meta
from all tracks, type 2 reads ONLY the chosen track (independent
timelines — callers must never share one grid across type-2 tracks).
Mid-bar signature events (ill-formed but seen in the wild) apply at the
next bar boundary. All times compute from absolute ticks through the
cumulative tempo table and round once at emit — rounding error never
accumulates with song length. A bar-count safety valve guards malformed
files. Consumer: the editor's multitrack MIDI import (tempo-seed
dialog, feedBack-plugin-editor roadmap Phase 3).
Tests: tests/test_midi_tempo_map.py — 10 cases driving the REAL
function against real .mid files built with mido (no stubs): default
grid, tempo bends, 500-bar rounding-drift check, 4/4->3/4 and 6/8
signatures, mid-bar signature deferral, duplicate-tick last-wins,
type-2 meta isolation from a bogus sibling track, empty files, grid
coverage bounds. Full MIDI-adjacent suite green: 55 passed
(test_midi_tempo_map + test_midi_import + test_midi_import_drums +
test_gp2midi) under the project venv.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu
* fix(midi): make convert_midi_tempo_map robust — real division guard, symmetric tempo default, single-pass meta
- Push the ticks-per-beat fallback into _build_tick_to_seconds (the single
place ticks route through), guarding on `> 0` so a division==0 (malformed)
or negative SMPTE-division header no longer raises ZeroDivisionError or
walks the beat grid into negative times. Mirror the guard at the
convert_midi_tempo_map beat_ticks site. The local `or 480` was cosmetic
before — the closure still divided by the raw division.
- Seed tempos_out with a 120 BPM row at time 0 when the first set_tempo
lands after tick 0, symmetric with the (0, 4, 4) time-signature default,
so the sidecar matches the grid the head of the song actually used.
- Collapse the duplicated meta_source/note_source lists into one
source_tracks walked in a single pass (meta collection + end_tick).
- Fix a weak assert in test_mid_bar_signature_applies_at_the_next_boundary
(operator-precedence `(A and B) or C`) to assert den == 4 outright.
- Add tests: non-positive division (0 + negative SMPTE), first tempo after
start seeds 120@0, explicit SMF type-0 file, and the _TEMPO_MAP_MAX_BARS
safety valve.
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>
252 lines
12 KiB
Python
252 lines
12 KiB
Python
"""Tests for lib/midi_import.py — convert_midi_tempo_map.
|
|
|
|
The note converters always computed a tempo-aware tick→seconds map internally
|
|
(to bake note times) and then threw it away — and never read time_signature
|
|
meta at all — so every MIDI import landed with no bars, no measures, and an
|
|
implied 4/4 regardless of the file. convert_midi_tempo_map extracts the grid:
|
|
tempos, time signatures (song-timeline shape), and a full beat grid on the
|
|
editor's row shape (numbered downbeats with a `den` hint, `-1` sub-beats).
|
|
|
|
Every test drives the REAL function against a real .mid built in-memory with
|
|
mido and saved to tmp_path — no stubs, adversarial inputs included (type-2
|
|
scoping, mid-bar signatures, duplicate meta ticks, empty files, long files
|
|
for rounding drift).
|
|
|
|
Run: pytest tests/test_midi_tempo_map.py -v
|
|
"""
|
|
|
|
import mido
|
|
import pytest
|
|
|
|
from midi_import import _TEMPO_MAP_MAX_BARS, convert_midi_tempo_map
|
|
|
|
|
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def _save(mid: mido.MidiFile, tmp_path, name: str = "t.mid") -> str:
|
|
p = tmp_path / name
|
|
mid.save(str(p))
|
|
return str(p)
|
|
|
|
|
|
def _note_pair(track, pitch=60, at=0, dur=240):
|
|
track.append(mido.Message("note_on", note=pitch, velocity=90, time=at))
|
|
track.append(mido.Message("note_off", note=pitch, velocity=0, time=dur))
|
|
|
|
|
|
def _downbeats(result):
|
|
return [b for b in result["beats"] if b["measure"] > 0]
|
|
|
|
|
|
def _subbeats(result):
|
|
return [b for b in result["beats"] if b["measure"] == -1]
|
|
|
|
|
|
# ── the plain case ────────────────────────────────────────────────────────────
|
|
|
|
def test_default_grid_is_120_bpm_four_four(tmp_path):
|
|
mid = mido.MidiFile(ticks_per_beat=480)
|
|
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
|
_note_pair(tr, at=0, dur=480 * 8) # two 4/4 bars of content
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
|
|
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
|
|
dbs = _downbeats(res)
|
|
assert [d["measure"] for d in dbs] == [1, 2]
|
|
assert [d["time"] for d in dbs] == [0.0, 2.0] # 4 beats at 0.5 s
|
|
assert all(d["den"] == 4 for d in dbs)
|
|
# 3 interior beats per full bar at 0.5 s spacing.
|
|
assert [b["time"] for b in _subbeats(res)][:3] == [0.5, 1.0, 1.5]
|
|
|
|
|
|
# ── tempo handling ────────────────────────────────────────────────────────────
|
|
|
|
def test_tempo_change_bends_the_grid(tmp_path):
|
|
mid = mido.MidiFile(ticks_per_beat=480)
|
|
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
|
meta.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120
|
|
meta.append(mido.MetaMessage("set_tempo", tempo=250000, time=480 * 4)) # 240 at bar 2
|
|
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
|
_note_pair(notes, at=0, dur=480 * 8)
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
assert [t["bpm"] for t in res["tempos"]] == [120.0, 240.0]
|
|
dbs = _downbeats(res)
|
|
# Bar 1 spans 2.0 s at 120; bar 2 starts at 2.0 and its beats halve.
|
|
assert dbs[0]["time"] == 0.0 and dbs[1]["time"] == 2.0
|
|
bar2_subs = [b["time"] for b in _subbeats(res) if b["time"] > 2.0]
|
|
assert bar2_subs[:3] == [2.25, 2.5, 2.75]
|
|
|
|
def test_rounding_does_not_accumulate_over_a_long_file(tmp_path):
|
|
# 500 bars at 120 BPM: beat times must stay exactly on the 0.5 s lattice
|
|
# (absolute-tick computation — never beat N derived from beat N-1).
|
|
mid = mido.MidiFile(ticks_per_beat=480)
|
|
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
|
_note_pair(tr, at=0, dur=480 * 4 * 500)
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
dbs = _downbeats(res)
|
|
assert len(dbs) == 500
|
|
assert dbs[-1]["time"] == pytest.approx((500 - 1) * 2.0, abs=0.0005)
|
|
assert dbs[250]["time"] == pytest.approx(250 * 2.0, abs=0.0005)
|
|
|
|
|
|
# ── time signatures (the previously-unread meta) ─────────────────────────────
|
|
|
|
def test_time_signature_changes_shape_the_bars(tmp_path):
|
|
mid = mido.MidiFile(ticks_per_beat=480)
|
|
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
|
meta.append(mido.MetaMessage("time_signature", numerator=4, denominator=4, time=0))
|
|
meta.append(mido.MetaMessage("time_signature", numerator=3, denominator=4, time=480 * 4))
|
|
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
|
_note_pair(notes, at=0, dur=480 * 10) # 4/4 bar + two 3/4 bars
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
assert [s["ts"] for s in res["time_signatures"]] == [[4, 4], [3, 4]]
|
|
dbs = _downbeats(res)
|
|
assert [d["time"] for d in dbs] == [0.0, 2.0, 3.5] # 3/4 bars are 1.5 s
|
|
# Bar 2 has exactly two interior beats.
|
|
bar2 = [b for b in res["beats"] if 2.0 < b["time"] < 3.5]
|
|
assert [b["measure"] for b in bar2] == [-1, -1]
|
|
|
|
def test_six_eight_uses_eighth_note_rows(tmp_path):
|
|
mid = mido.MidiFile(ticks_per_beat=480)
|
|
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
|
meta.append(mido.MetaMessage("time_signature", numerator=6, denominator=8, time=0))
|
|
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
|
_note_pair(notes, at=0, dur=480 * 3) # one full 6/8 bar
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
dbs = _downbeats(res)
|
|
assert dbs[0]["den"] == 8
|
|
bar1 = [b["time"] for b in res["beats"] if b["time"] < 1.5]
|
|
# Six eighth-note rows at 120 BPM (quarter = 0.5 s ⇒ eighth = 0.25 s).
|
|
assert bar1 == [0.0, 0.25, 0.5, 0.75, 1.0, 1.25]
|
|
|
|
def test_mid_bar_signature_applies_at_the_next_boundary(tmp_path):
|
|
mid = mido.MidiFile(ticks_per_beat=480)
|
|
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
|
# Ill-formed: 3/4 lands halfway through bar 1.
|
|
meta.append(mido.MetaMessage("time_signature", numerator=3, denominator=4, time=480 * 2))
|
|
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
|
_note_pair(notes, at=0, dur=480 * 8)
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
dbs = _downbeats(res)
|
|
# Bar 1 stays 4/4 (2.0 s); bar 2 onward is 3/4.
|
|
assert dbs[0]["time"] == 0.0 and dbs[0]["den"] == 4
|
|
# Bar 2 is the 3/4 bar, but its denominator is still 4 (3 quarter notes).
|
|
assert dbs[1]["time"] == 2.0 and dbs[1]["den"] == 4
|
|
assert dbs[2]["time"] - dbs[1]["time"] == pytest.approx(1.5, abs=0.002)
|
|
|
|
def test_duplicate_signature_ticks_last_wins(tmp_path):
|
|
mid = mido.MidiFile(ticks_per_beat=480)
|
|
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
|
meta.append(mido.MetaMessage("time_signature", numerator=4, denominator=4, time=0))
|
|
meta.append(mido.MetaMessage("time_signature", numerator=7, denominator=8, time=0))
|
|
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
|
_note_pair(notes, at=0, dur=480 * 4)
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
assert res["time_signatures"][-1]["ts"] == [7, 8]
|
|
assert _downbeats(res)[0]["den"] == 8
|
|
|
|
|
|
# ── SMF type scoping (adversarial) ───────────────────────────────────────────
|
|
|
|
def test_type2_reads_meta_from_the_chosen_track_only(tmp_path):
|
|
mid = mido.MidiFile(ticks_per_beat=480, type=2)
|
|
bogus = mido.MidiTrack(); mid.tracks.append(bogus)
|
|
bogus.append(mido.MetaMessage("set_tempo", tempo=100000, time=0)) # 600 BPM
|
|
bogus.append(mido.MetaMessage("time_signature", numerator=7, denominator=8, time=0))
|
|
_note_pair(bogus, at=0, dur=480)
|
|
real = mido.MidiTrack(); mid.tracks.append(real)
|
|
real.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120 BPM
|
|
_note_pair(real, at=0, dur=480 * 4)
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path), track_index=1)
|
|
# The bogus track's 600 BPM / 7-8 never leak into track 1's grid.
|
|
assert [t["bpm"] for t in res["tempos"]] == [120.0]
|
|
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
|
|
assert _downbeats(res)[0]["den"] == 4
|
|
|
|
|
|
# ── degenerate inputs ────────────────────────────────────────────────────────
|
|
|
|
def test_empty_file_yields_empty_beats_but_valid_shape(tmp_path):
|
|
mid = mido.MidiFile(ticks_per_beat=480)
|
|
mid.tracks.append(mido.MidiTrack())
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
assert res["beats"] == []
|
|
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
|
|
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
|
|
|
|
def test_grid_covers_all_notes_and_stops_after_them(tmp_path):
|
|
mid = mido.MidiFile(ticks_per_beat=480)
|
|
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
|
_note_pair(tr, at=480 * 5, dur=480) # note inside bar 2 only
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
dbs = _downbeats(res)
|
|
assert dbs[0]["time"] == 0.0, "grid starts at zero (SMF convention)"
|
|
assert dbs[-1]["measure"] == 2
|
|
assert all(b["time"] <= 3.0 + 1e-9 for b in res["beats"]), \
|
|
"no beats past the end of musical content"
|
|
|
|
|
|
@pytest.mark.parametrize("division", [0, -1, -25600])
|
|
def test_non_positive_division_header_does_not_crash(tmp_path, division):
|
|
# A malformed header reloads with ticks_per_beat == 0; a true SMPTE-division
|
|
# file reloads negative (mido reads the division as a signed short). Either
|
|
# way the tick→seconds closure would divide by a non-positive number —
|
|
# raising ZeroDivisionError (0) or walking off into negative times
|
|
# (negative) — without the header fallback. The grid must still come out on
|
|
# a sane, bounded 4/4 / 120-BPM default.
|
|
mid = mido.MidiFile(ticks_per_beat=division)
|
|
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
|
_note_pair(tr, at=0, dur=480 * 8)
|
|
assert mido.MidiFile(_save(mid, tmp_path)).ticks_per_beat == division # precondition
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
|
|
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
|
|
dbs = _downbeats(res)
|
|
assert [d["measure"] for d in dbs] == [1, 2]
|
|
assert all(isinstance(b["time"], float) and b["time"] >= 0.0
|
|
for b in res["beats"])
|
|
|
|
|
|
def test_first_tempo_after_start_seeds_default_120_at_zero(tmp_path):
|
|
# First (and only) set_tempo lands at bar 2. The head of the song already
|
|
# played at the MIDI default of 120 BPM, so the tempos sidecar must open
|
|
# with a 120-BPM row at time 0 — symmetric with the 4/4 signature default.
|
|
mid = mido.MidiFile(ticks_per_beat=480)
|
|
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
|
meta.append(mido.MetaMessage("set_tempo", tempo=250000, time=480 * 4)) # 240 at bar 2
|
|
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
|
_note_pair(notes, at=0, dur=480 * 8)
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
assert res["tempos"][0] == {"time": 0.0, "bpm": 120.0}
|
|
assert res["tempos"][1] == {"time": 2.0, "bpm": 240.0}
|
|
# The seeded default actually matches the grid the head of the song used.
|
|
assert _downbeats(res)[0]["time"] == 0.0
|
|
|
|
|
|
def test_type0_single_track_carries_tempo_timesig_and_notes(tmp_path):
|
|
# Explicit SMF format 0: one track holds tempo + signature + notes.
|
|
mid = mido.MidiFile(ticks_per_beat=480, type=0)
|
|
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
|
tr.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120
|
|
tr.append(mido.MetaMessage("time_signature", numerator=3, denominator=4, time=0))
|
|
_note_pair(tr, at=0, dur=480 * 6) # two 3/4 bars
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
assert mido.MidiFile(_save(mid, tmp_path)).type == 0 # precondition
|
|
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
|
|
assert res["time_signatures"] == [{"time": 0.0, "ts": [3, 4]}]
|
|
dbs = _downbeats(res)
|
|
assert [d["measure"] for d in dbs] == [1, 2]
|
|
assert [d["time"] for d in dbs] == [0.0, 1.5] # 3/4 bar = 1.5 s at 120
|
|
assert all(d["den"] == 4 for d in dbs)
|
|
|
|
|
|
def test_max_bars_safety_valve_caps_the_walk(tmp_path):
|
|
# A note one bar past the cap must not blow the walk past its ceiling.
|
|
mid = mido.MidiFile(ticks_per_beat=480)
|
|
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
|
_note_pair(tr, at=0, dur=480 * 4 * (_TEMPO_MAP_MAX_BARS + 1))
|
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
|
dbs = _downbeats(res)
|
|
assert len(dbs) == _TEMPO_MAP_MAX_BARS
|
|
assert dbs[-1]["measure"] == _TEMPO_MAP_MAX_BARS
|