hum / test_logic.py
batoon's picture
Handle short hums with F0 fallback and initial beat anchor
21c2288 verified
Raw History Blame Contribute Delete
3.47 kB
"""Contracts that prevent vocal ABC leakage and accidental timing expansion."""
from __future__ import annotations
import unittest
from logic import drum_hits, instrumental_abc, notes_to_abc, validate_duration
NATIVE = '''X:1
M:4/4
L:1/16
V: Vocal clef=treble name="Vocal Melody" snm="Vocal"
V: Ins clef=treble name="Ins Melody" snm="Inst."
K:B
% chorus
V: Vocal
"Bm"z12F,4|
V: Ins
Z1|
V: Vocal
Z|
V: Ins
Z1|
'''
class LogicTest(unittest.TestCase):
def test_move_melody_to_instrument_without_chord_or_voice(self):
result = instrumental_abc(NATIVE)
self.assertNotIn("Vocal", result)
self.assertNotIn('"Bm"', result)
self.assertIn("z12F,4|", result)
self.assertIn("V: Ins\nz12F,4|", result)
self.assertIn("V: Ins\nZ|", result)
self.assertLess(result.index("V: Ins clef"), result.index("K:B"))
def test_composition_keeps_chords_but_no_vocal_slot(self):
result = instrumental_abc(NATIVE, keep_chords=True)
self.assertIn('"Bm"z12F,4|', result)
self.assertNotIn("Vocal", result)
def test_unknown_layout_fails(self):
with self.assertRaises(ValueError):
instrumental_abc(NATIVE.replace("Z1|", "z16|", 1))
def test_fallback_abc_is_instrumental_and_has_source_order(self):
abc = notes_to_abc([(.08, .52, 60), (.68, 1.1, 60),
(1.3, 1.7, 62)], 2.)
self.assertEqual(instrumental_abc(abc), abc)
self.assertNotIn("Vocal", abc)
self.assertIn("V: Ins", abc)
self.assertIn("C7", abc)
self.assertIn("D6", abc)
def test_duration_bounds(self):
validate_duration(0.4)
validate_duration(35)
for seconds in (0.1, 35.1):
with self.assertRaises(ValueError):
validate_duration(seconds)
def test_drums_follow_beats_and_skip_long_melody_pause(self):
beats = "".join(f"{i * .5}\t{i % 4 + 1}\t4\t4\n" for i in range(12))
hits = drum_hits(beats, [(0.2, 0.5), (2.2, 5.8)], 6.0)
self.assertIn((0.0, 36, 90), hits)
self.assertIn((2.0, 36, 90), hits)
self.assertIn((2.5, 38, 76), hits)
self.assertFalse(any(0.75 <= time <= 1.75 for time, _, _ in hits))
self.assertTrue(all(0 <= time < 6 for time, _, _ in hits))
def test_drums_reject_bad_beat_grid(self):
for beats in ("0\t1\t4\t4\n0\t2\t4\t4\n1\t3\t4\t4\n",
"0\t1\t5\t4\n.5\t2\t5\t4\n1\t3\t5\t4\n"):
with self.assertRaises(ValueError):
drum_hits(beats, [(0.1, 1.1)], 2.0)
def test_drums_ignore_duplicate_zero_anchor_before_first_downbeat(self):
# Seen in the source-time SheetSage2 beat.lab for input-001.
beats = ("0\t1\t4\t4\n.44\t1\t4\t4\n.92\t2\t4\t4\n"
"1.44\t3\t4\t4\n1.92\t4\t4\t4\n2.44\t1\t4\t4\n")
hits = drum_hits(beats, [(.22, 2.5)], 2.6)
self.assertFalse(any(time == 0 for time, _, _ in hits))
self.assertIn((.44, 36, 90), hits)
def test_drums_ignore_sheet_sage_initial_one_eighth_anchor(self):
# Seen in HumTrans F05/M01: 1/8 at zero precedes a real 3/4 or 4/4 grid.
beats = ("0.0\t1\t1\t8\n.19\t1\t3\t4\n.67\t2\t3\t4\n"
"1.16\t3\t3\t4\n1.67\t1\t3\t4\n")
hits = drum_hits(beats, [(.2, 1.8)], 2.0)
self.assertFalse(any(time == 0 for time, _, _ in hits))
self.assertIn((.19, 36, 90), hits)
if __name__ == "__main__":
unittest.main()