mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 04:41:23 +00:00
Some checks are pending
ship-ci / ci (push) Waiting to run
convert_midi_track_to_keys_wire builds its own inline tempo map and divides by the raw midi.ticks_per_beat at two sites (the tempo-table precompute and the tick_to_seconds closure). A malformed header with division == 0 raised ZeroDivisionError, and an SMPTE division (which mido returns as a NEGATIVE signed short) produced negative/garbage note times. Guard the divisor with `ticks_per_beat if ticks_per_beat > 0 else 480` so both the zero and negative cases fall back to the SMF default. The `> 0` form (not `or 480`) is required because a negative value is truthy and would slip past `or`. Positive-division behavior is unchanged. Follow-up to #796, which fixed the same class of bug in the newer convert_midi_tempo_map / _build_tick_to_seconds path. Adds two focused tests: division == 0 no longer crashes and emits a non-negative time, and a negative/SMPTE division yields sane non-negative times. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
333 lines
15 KiB
Python
333 lines
15 KiB
Python
"""Tests for lib/midi_import.py — list_midi_tracks and convert_midi_track_to_keys_wire.
|
|
|
|
Synthetic mido.MidiFile objects are built in-memory and saved to tmp_path so
|
|
the helpers can be exercised without shipping fixture .mid files.
|
|
|
|
Covers:
|
|
- format-0 per-channel splitting
|
|
- drum-channel (GM channel 9) filtering
|
|
- format-1 no-split (multi-channel track stays merged)
|
|
- tempo-change-aware tick→seconds conversion
|
|
- CC64 sustain pedal note extension
|
|
- same-pitch retrigger (FIFO stacking — no dropped notes)
|
|
- pitch encoding: s = pitch // 24, f = pitch % 24
|
|
"""
|
|
|
|
import pytest
|
|
import mido
|
|
|
|
from midi_import import list_midi_tracks, convert_midi_track_to_keys_wire
|
|
|
|
|
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def _save(mid: mido.MidiFile, tmp_path, name: str = "test.mid") -> str:
|
|
p = tmp_path / name
|
|
mid.save(str(p))
|
|
return str(p)
|
|
|
|
|
|
# ── list_midi_tracks ──────────────────────────────────────────────────────────
|
|
|
|
def test_format0_splits_into_per_channel_entries(tmp_path):
|
|
"""Format-0 with two non-drum channels → two separate picker entries."""
|
|
mid = mido.MidiFile(type=0, ticks_per_beat=480)
|
|
track = mido.MidiTrack()
|
|
mid.tracks.append(track)
|
|
# Channel 0: piano (program 0)
|
|
track.append(mido.Message("program_change", channel=0, program=0, time=0))
|
|
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))
|
|
# Channel 1: bass (program 33)
|
|
track.append(mido.Message("program_change", channel=1, program=33, time=0))
|
|
track.append(mido.Message("note_on", channel=1, note=48, velocity=64, time=0))
|
|
track.append(mido.Message("note_off", channel=1, note=48, velocity=0, time=480))
|
|
|
|
tracks = list_midi_tracks(_save(mid, tmp_path))
|
|
assert len(tracks) == 2
|
|
channels = {t["channel"] for t in tracks}
|
|
assert channels == {0, 1}
|
|
# Each entry should carry a channel_filter for isolation
|
|
assert all(t["channel_filter"] is not None for t in tracks)
|
|
|
|
|
|
def test_format0_drum_channel_excluded(tmp_path):
|
|
"""Channel-9 notes are not returned, even in format-0 split mode."""
|
|
mid = mido.MidiFile(type=0, ticks_per_beat=480)
|
|
track = mido.MidiTrack()
|
|
mid.tracks.append(track)
|
|
# Melodic channel
|
|
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))
|
|
# Drum channel — must be filtered out
|
|
track.append(mido.Message("note_on", channel=9, note=36, velocity=64, time=0))
|
|
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=480))
|
|
|
|
tracks = list_midi_tracks(_save(mid, tmp_path))
|
|
assert len(tracks) == 1
|
|
assert tracks[0]["channel"] == 0
|
|
assert all(t["channel"] != 9 for t in tracks)
|
|
|
|
|
|
def test_format0_drums_only_returns_empty(tmp_path):
|
|
"""A format-0 file with only channel-9 notes yields an empty list."""
|
|
mid = mido.MidiFile(type=0, ticks_per_beat=480)
|
|
track = mido.MidiTrack()
|
|
mid.tracks.append(track)
|
|
track.append(mido.Message("note_on", channel=9, note=36, velocity=64, time=0))
|
|
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=480))
|
|
|
|
tracks = list_midi_tracks(_save(mid, tmp_path))
|
|
assert tracks == []
|
|
|
|
|
|
def test_format1_multi_channel_not_split(tmp_path):
|
|
"""Format-1 tracks are not split per-channel — the whole track is one entry."""
|
|
mid = mido.MidiFile(type=1, ticks_per_beat=480)
|
|
# Conductor track (required for type-1)
|
|
conductor = mido.MidiTrack()
|
|
mid.tracks.append(conductor)
|
|
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
|
# Multi-channel note track
|
|
note_track = mido.MidiTrack()
|
|
mid.tracks.append(note_track)
|
|
note_track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=0))
|
|
note_track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=480))
|
|
note_track.append(mido.Message("note_on", channel=1, note=48, velocity=64, time=0))
|
|
note_track.append(mido.Message("note_off", channel=1, note=48, velocity=0, time=480))
|
|
|
|
tracks = list_midi_tracks(_save(mid, tmp_path))
|
|
# Conductor track has no melodic notes → skipped; note track → 1 merged entry
|
|
assert len(tracks) == 1
|
|
assert tracks[0]["channel_filter"] is None
|
|
|
|
|
|
def test_piano_program_flagged(tmp_path):
|
|
"""Tracks with a piano-family program are flagged is_piano=True."""
|
|
mid = mido.MidiFile(type=1, ticks_per_beat=480)
|
|
conductor = mido.MidiTrack()
|
|
mid.tracks.append(conductor)
|
|
note_track = mido.MidiTrack()
|
|
mid.tracks.append(note_track)
|
|
note_track.append(mido.MetaMessage("track_name", name="Piano", time=0))
|
|
note_track.append(mido.Message("program_change", channel=0, program=0, time=0))
|
|
note_track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=0))
|
|
note_track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=480))
|
|
|
|
tracks = list_midi_tracks(_save(mid, tmp_path))
|
|
assert len(tracks) == 1
|
|
assert tracks[0]["is_piano"] is True
|
|
|
|
|
|
# ── convert_midi_track_to_keys_wire ──────────────────────────────────────────
|
|
|
|
def test_pitch_encoding(tmp_path):
|
|
"""MIDI pitch is encoded as s = pitch // 24, f = pitch % 24."""
|
|
mid = mido.MidiFile(type=0, ticks_per_beat=480)
|
|
track = mido.MidiTrack()
|
|
mid.tracks.append(track)
|
|
# Middle C = MIDI 60 → s=2, f=12
|
|
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))
|
|
|
|
result = convert_midi_track_to_keys_wire(_save(mid, tmp_path), track_index=0)
|
|
assert len(result["notes"]) == 1
|
|
n = result["notes"][0]
|
|
assert n["s"] == 60 // 24
|
|
assert n["f"] == 60 % 24
|
|
# Reconstruct: s*24 + f should recover original MIDI pitch
|
|
assert n["s"] * 24 + n["f"] == 60
|
|
|
|
|
|
def test_constant_tempo_timing(tmp_path):
|
|
"""At 120 BPM with 480 ticks/beat, one beat = 0.5 s."""
|
|
tpb = 480
|
|
mid = mido.MidiFile(type=0, ticks_per_beat=tpb)
|
|
track = mido.MidiTrack()
|
|
mid.tracks.append(track)
|
|
# One-beat note starting at tick 0
|
|
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=tpb))
|
|
|
|
result = convert_midi_track_to_keys_wire(_save(mid, tmp_path), track_index=0)
|
|
assert len(result["notes"]) == 1
|
|
n = result["notes"][0]
|
|
assert n["t"] == pytest.approx(0.0)
|
|
assert n["sus"] == pytest.approx(0.5)
|
|
|
|
|
|
def test_tempo_change_boundary(tmp_path):
|
|
"""Notes crossing a tempo change get correct durations on each side."""
|
|
tpb = 480
|
|
mid = mido.MidiFile(type=1, ticks_per_beat=tpb)
|
|
# Conductor: 120 BPM → switch to 60 BPM at tick 480
|
|
conductor = mido.MidiTrack()
|
|
mid.tracks.append(conductor)
|
|
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120 BPM
|
|
conductor.append(mido.MetaMessage("set_tempo", tempo=1000000, time=tpb)) # 60 BPM
|
|
|
|
note_track = mido.MidiTrack()
|
|
mid.tracks.append(note_track)
|
|
# Note before tempo change: starts tick 0, ends tick 480 → 0.5 s
|
|
note_track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=0))
|
|
note_track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=tpb))
|
|
# Note after tempo change: starts tick 480, ends tick 960 → 1.0 s
|
|
note_track.append(mido.Message("note_on", channel=0, note=62, velocity=64, time=0))
|
|
note_track.append(mido.Message("note_off", channel=0, note=62, velocity=0, time=tpb))
|
|
|
|
result = convert_midi_track_to_keys_wire(_save(mid, tmp_path), track_index=1)
|
|
notes = sorted(result["notes"], key=lambda n: n["t"])
|
|
assert len(notes) == 2
|
|
assert notes[0]["t"] == pytest.approx(0.0)
|
|
assert notes[0]["sus"] == pytest.approx(0.5) # 1 beat at 120 BPM
|
|
assert notes[1]["t"] == pytest.approx(0.5)
|
|
assert notes[1]["sus"] == pytest.approx(1.0) # 1 beat at 60 BPM
|
|
|
|
|
|
def test_cc64_sustain_extends_note(tmp_path):
|
|
"""While the sustain pedal is held, note-off defers end time to pedal-up."""
|
|
tpb = 480
|
|
mid = mido.MidiFile(type=0, ticks_per_beat=tpb)
|
|
track = mido.MidiTrack()
|
|
mid.tracks.append(track)
|
|
# Pedal down at tick 0
|
|
track.append(mido.Message("control_change", channel=0, control=64, value=64, time=0))
|
|
# Note starts at tick 0
|
|
track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=0))
|
|
# Key released at tick 240 (while pedal still held)
|
|
track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=240))
|
|
# Pedal up at tick 480 → note should end here (0.5 s total duration)
|
|
track.append(mido.Message("control_change", channel=0, control=64, value=0, time=240))
|
|
|
|
result = convert_midi_track_to_keys_wire(_save(mid, tmp_path), track_index=0)
|
|
assert len(result["notes"]) == 1
|
|
n = result["notes"][0]
|
|
assert n["t"] == pytest.approx(0.0)
|
|
assert n["sus"] == pytest.approx(0.5) # extended to pedal-up at tick 480
|
|
|
|
|
|
def test_same_pitch_retrigger(tmp_path):
|
|
"""Rapid same-pitch retrigger emits two separate notes (FIFO stack)."""
|
|
tpb = 480
|
|
mid = mido.MidiFile(type=0, ticks_per_beat=tpb)
|
|
track = mido.MidiTrack()
|
|
mid.tracks.append(track)
|
|
# First note on
|
|
track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=0))
|
|
# Retrigger: second note_on before first note_off
|
|
track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=240))
|
|
# First note_off (FIFO: closes the first note_on at tick 0)
|
|
track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=240))
|
|
# Second note_off
|
|
track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=240))
|
|
|
|
result = convert_midi_track_to_keys_wire(_save(mid, tmp_path), track_index=0)
|
|
assert len(result["notes"]) == 2
|
|
notes = sorted(result["notes"], key=lambda n: n["t"])
|
|
assert notes[0]["t"] == pytest.approx(0.0)
|
|
# Duration: from tick 0 to first note_off at tick 480 (cumulative)
|
|
assert notes[0]["sus"] == pytest.approx(0.5, abs=0.01)
|
|
assert notes[1]["t"] == pytest.approx(0.25)
|
|
|
|
|
|
def test_channel_filter_isolates_channel(tmp_path):
|
|
"""channel_filter restricts conversion to the specified channel only."""
|
|
mid = mido.MidiFile(type=0, ticks_per_beat=480)
|
|
track = mido.MidiTrack()
|
|
mid.tracks.append(track)
|
|
# Ch 0: pitch 60
|
|
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))
|
|
# Ch 1: pitch 72
|
|
track.append(mido.Message("note_on", channel=1, note=72, velocity=64, time=0))
|
|
track.append(mido.Message("note_off", channel=1, note=72, velocity=0, time=480))
|
|
|
|
path = _save(mid, tmp_path)
|
|
result = convert_midi_track_to_keys_wire(path, track_index=0, channel_filter=0)
|
|
pitches = {n["s"] * 24 + n["f"] for n in result["notes"]}
|
|
assert 60 in pitches
|
|
assert 72 not in pitches
|
|
|
|
|
|
def test_audio_offset_applied(tmp_path):
|
|
"""audio_offset shifts all note start times by the given amount."""
|
|
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))
|
|
|
|
path = _save(mid, tmp_path)
|
|
result = convert_midi_track_to_keys_wire(path, track_index=0, audio_offset=1.5)
|
|
assert result["notes"][0]["t"] == pytest.approx(1.5)
|
|
|
|
|
|
def test_wire_format_shape(tmp_path):
|
|
"""Returned dict has all required sloppak arrangement keys."""
|
|
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))
|
|
|
|
result = convert_midi_track_to_keys_wire(_save(mid, tmp_path), track_index=0, name="Keys")
|
|
assert result["name"] == "Keys"
|
|
assert "notes" in result
|
|
assert "chords" in result
|
|
assert "anchors" in result
|
|
assert "tuning" in result
|
|
assert "capo" in result
|
|
|
|
|
|
# ── non-positive division guard (legacy inline tempo path) ───────────────────
|
|
|
|
def test_zero_division_does_not_crash(tmp_path):
|
|
"""A malformed header (ticks_per_beat == 0) must not raise ZeroDivisionError.
|
|
|
|
The legacy inline tempo map in convert_midi_track_to_keys_wire divides by
|
|
ticks_per_beat at two sites; a 0 division falls back to the SMF default so
|
|
the note is still emitted with a sane, non-negative time.
|
|
"""
|
|
mid = mido.MidiFile(ticks_per_beat=0)
|
|
track = mido.MidiTrack()
|
|
mid.tracks.append(track)
|
|
# Note starts after a one-"beat" rest so a bad divisor would skew its start.
|
|
track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=480))
|
|
track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=480))
|
|
|
|
path = _save(mid, tmp_path)
|
|
assert mido.MidiFile(path).ticks_per_beat == 0 # precondition: divisor is 0
|
|
result = convert_midi_track_to_keys_wire(path, track_index=0)
|
|
assert len(result["notes"]) == 1
|
|
n = result["notes"][0]
|
|
# 480-tick fallback @ 120 BPM: one beat = 0.5 s.
|
|
assert n["t"] == pytest.approx(0.5)
|
|
assert n["t"] >= 0.0
|
|
assert n["sus"] == pytest.approx(0.5)
|
|
|
|
|
|
def test_smpte_negative_division_produces_nonnegative_times(tmp_path):
|
|
"""SMPTE division (mido returns a NEGATIVE ticks_per_beat) must not yield
|
|
negative times through the legacy inline path.
|
|
|
|
``or 480`` would miss this (a negative value is truthy); the ``> 0`` guard
|
|
falls back so the emitted note keeps a sane, non-negative start time.
|
|
"""
|
|
mid = mido.MidiFile()
|
|
mid.ticks_per_beat = -1 # simulate a SMPTE / malformed signed-short division
|
|
track = mido.MidiTrack()
|
|
mid.tracks.append(track)
|
|
track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=480))
|
|
track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=480))
|
|
|
|
path = _save(mid, tmp_path)
|
|
assert mido.MidiFile(path).ticks_per_beat < 0 # precondition: negative divisor
|
|
result = convert_midi_track_to_keys_wire(path, track_index=0)
|
|
assert len(result["notes"]) == 1
|
|
n = result["notes"][0]
|
|
assert n["t"] >= 0.0
|
|
assert n["sus"] >= 0.0
|
|
# 480-tick fallback @ 120 BPM: one beat = 0.5 s.
|
|
assert n["t"] == pytest.approx(0.5)
|
|
assert n["sus"] == pytest.approx(0.5)
|