batoon commited on
Commit
21c2288
·
verified ·
1 Parent(s): 5549f9f

Handle short hums with F0 fallback and initial beat anchor

Browse files
Files changed (7) hide show
  1. README.md +6 -0
  2. f0_fallback.py +64 -0
  3. logic.py +56 -1
  4. pipeline.py +24 -2
  5. test_f0_fallback.py +40 -0
  6. test_logic.py +18 -1
  7. test_sampler.py +43 -1
README.md CHANGED
@@ -29,6 +29,12 @@ follows voice pitch and amplitude. The sampler uses full-size FreePats electric
29
  guitar and fingered bass SoundFonts, with FFmpeg compression, filtering,
30
  oversampled soft clipping, and a short guitar room echo in processed versions.
31
 
 
 
 
 
 
 
32
  YuE2 is on by default, so the same click also starts three experiments from
33
  SheetSage2 ABC: solo, melody-with-drums, and composition. Uncheck its option
34
  when only the faster sampler previews are wanted. The drums in these
 
29
  guitar and fingered bass SoundFonts, with FFmpeg compression, filtering,
30
  oversampled soft clipping, and a short guitar room echo in processed versions.
31
 
32
+ If SheetSage2 returns an empty vocal melody, a conservative pYIN fallback
33
+ extracts source-timed notes from sustained voiced pitch and supplies a simple
34
+ instrumental ABC for YuE2. It rejects unpitched noise. Fallback note boundaries
35
+ are approximate, and the run details identify which transcription was used.
36
+ SheetSage2's beat grid remains the source for sampled drums when available.
37
+
38
  YuE2 is on by default, so the same click also starts three experiments from
39
  SheetSage2 ABC: solo, melody-with-drums, and composition. Uncheck its option
40
  when only the faster sampler previews are wanted. The drums in these
f0_fallback.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source-timed monophonic fallback for a hum SheetSage cannot notate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def transcribe_f0(source: Path, target: Path) -> int:
9
+ """Write MIDI from voiced F0 spans; reject inputs without a sustained pitch."""
10
+ import librosa
11
+ import numpy as np
12
+ import pretty_midi
13
+ from scipy.ndimage import binary_closing, median_filter
14
+ import soundfile as sf
15
+
16
+ audio, rate = sf.read(source, dtype="float32")
17
+ if audio.ndim != 1 or rate != 24000:
18
+ raise ValueError("Для резервной транскрипции нужен моно PCM 24 кГц")
19
+ reduced = librosa.resample(audio, orig_sr=rate, target_sr=16000)
20
+ f0, voiced, _ = librosa.pyin(reduced, sr=16000, fmin=55., fmax=1000.,
21
+ frame_length=1024, hop_length=160, center=True)
22
+ rms = librosa.feature.rms(y=reduced, frame_length=1024,
23
+ hop_length=160, center=True)[0][:len(f0)]
24
+ threshold = max(.001, float(np.percentile(rms, 95)) * 10 ** (-35 / 20))
25
+ active = voiced & np.isfinite(f0) & (rms >= threshold)
26
+ # Bridge only pitch-tracker dropouts shorter than a consonant or breath.
27
+ active = binary_closing(active, structure=np.ones(4), border_value=0)
28
+ boundaries = np.flatnonzero(np.diff(np.r_[False, active, False]))
29
+ score = pretty_midi.PrettyMIDI(initial_tempo=120)
30
+ track = pretty_midi.Instrument(program=0, name="F0 fallback")
31
+ for first, last in zip(boundaries[::2], boundaries[1::2]):
32
+ if last - first < 18: # Reject short accidental pitched patches in noise.
33
+ continue
34
+ pitch = median_filter(69 + 12 * np.log2(np.maximum(f0[first:last], 55.) / 440),
35
+ size=9, mode="nearest")
36
+ # pYIN may be unvoiced for a few interpolated frames.
37
+ good = np.isfinite(pitch)
38
+ if not good.any():
39
+ continue
40
+ pitch = np.interp(np.arange(len(pitch)), np.flatnonzero(good), pitch[good])
41
+ rounded = median_filter(np.rint(pitch), size=11, mode="nearest").astype(int)
42
+ changes = np.r_[0, np.flatnonzero(np.diff(rounded)) + 1, len(rounded)]
43
+ cuts = [0]
44
+ for index in range(1, len(changes) - 1):
45
+ point, next_point = int(changes[index]), int(changes[index + 1])
46
+ if (point - cuts[-1] >= 12 and next_point - point >= 12
47
+ and abs(np.median(pitch[cuts[-1]:point])
48
+ - np.median(pitch[point:next_point])) >= 1.5):
49
+ cuts.append(point)
50
+ cuts.append(len(rounded))
51
+ for start, end in zip(cuts, cuts[1:]):
52
+ if end - start < 8:
53
+ continue
54
+ begin = max(0., (first + start) * .01 - .005)
55
+ finish = min(len(audio) / rate, (first + end) * .01 - .005)
56
+ if finish - begin < .075:
57
+ continue
58
+ note = int(np.clip(np.rint(np.median(pitch[start:end])), 0, 127))
59
+ track.notes.append(pretty_midi.Note(90, note, begin, finish))
60
+ if not track.notes:
61
+ raise ValueError("Не удалось выделить устойчивую высоту напева")
62
+ score.instruments.append(track)
63
+ score.write(str(target))
64
+ return len(track.notes)
logic.py CHANGED
@@ -28,7 +28,12 @@ def drum_hits(beat_lab: str, melody_spans: list[tuple[float, float]],
28
  duration: float) -> list[tuple[float, int, int]]:
29
  """Place a small GM kit on SheetSage's source-time beat grid."""
30
  beats = []
31
- for line in beat_lab.splitlines():
 
 
 
 
 
32
  fields = line.split()
33
  if len(fields) != 4:
34
  raise ValueError("SheetSage2 вернул некорректную сетку долей")
@@ -81,6 +86,8 @@ def drum_hits(beat_lab: str, melody_spans: list[tuple[float, float]],
81
 
82
  def instrumental_abc(native: str, keep_chords: bool = False) -> str:
83
  """Move the native Vocal melody into Ins, failing on unknown ABC layout."""
 
 
84
  lines = native.splitlines()
85
  header = []
86
  body = []
@@ -123,6 +130,54 @@ def instrumental_abc(native: str, keep_chords: bool = False) -> str:
123
  return result
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  def validate_duration(seconds: float) -> None:
127
  if not 0.4 <= seconds <= MAX_SECONDS:
128
  raise ValueError(f"Нужен напев от 0,4 до {MAX_SECONDS:g} секунд; сейчас {seconds:.1f} с.")
 
28
  duration: float) -> list[tuple[float, int, int]]:
29
  """Place a small GM kit on SheetSage's source-time beat grid."""
30
  beats = []
31
+ lines = beat_lab.splitlines()
32
+ if (len(lines) > 1 and lines[0].split() == ["0.0", "1", "1", "8"]
33
+ and lines[1].split()[1:2] == ["1"]):
34
+ # SheetSage sometimes exports a 1/8 time-zero anchor before its real grid.
35
+ lines = lines[1:]
36
+ for line in lines:
37
  fields = line.split()
38
  if len(fields) != 4:
39
  raise ValueError("SheetSage2 вернул некорректную сетку долей")
 
86
 
87
  def instrumental_abc(native: str, keep_chords: bool = False) -> str:
88
  """Move the native Vocal melody into Ins, failing on unknown ABC layout."""
89
+ if "V: Vocal" not in native and 'V: Ins clef=treble name="Instrumental Melody"' in native:
90
+ return native
91
  lines = native.splitlines()
92
  header = []
93
  body = []
 
130
  return result
131
 
132
 
133
+ def notes_to_abc(notes: list[tuple[float, float, int]], duration: float) -> str:
134
+ """Make a coarse instrumental ABC solely for YuE when SheetSage has no notes."""
135
+ if not notes:
136
+ raise ValueError("ABC requires notes")
137
+ unit = .0625 # A 1/32 ABC note at 120 quarter-notes per minute.
138
+ events = []
139
+ previous = 0
140
+ for start, end, pitch in sorted(notes):
141
+ first = max(previous, round(start / unit))
142
+ last = max(first + 1, round(end / unit))
143
+ events.append((first, last, pitch))
144
+ previous = last
145
+ total = max(32, round(duration / unit), events[-1][1])
146
+ names = ("C", "^C", "D", "^D", "E", "F", "^F", "G", "^G", "A", "^A", "B")
147
+
148
+ def name(pitch: int) -> str:
149
+ octave = pitch // 12 - 1
150
+ base = names[pitch % 12]
151
+ if octave >= 5:
152
+ return base.lower() + "'" * (octave - 5)
153
+ return base + "," * (4 - octave)
154
+
155
+ body = []
156
+ cursor = 0
157
+ for first, last, pitch in events:
158
+ for event_start, event_end, symbol in ((cursor, first, "z"),
159
+ (first, last, name(pitch))):
160
+ while event_start < event_end:
161
+ chunk = min(event_end - event_start, 32 - event_start % 32)
162
+ more = symbol != "z" and event_start + chunk < event_end
163
+ body.append(f"{symbol}{chunk}{'-' if more else ''}")
164
+ event_start += chunk
165
+ if event_start % 32 == 0:
166
+ body.append("|")
167
+ cursor = last
168
+ while cursor < total:
169
+ chunk = min(total - cursor, 32 - cursor % 32)
170
+ body.append(f"z{chunk}")
171
+ cursor += chunk
172
+ if cursor % 32 == 0:
173
+ body.append("|")
174
+ if body[-1] != "|":
175
+ body.append("|")
176
+ return ('X:1\nT:Hum F0 fallback\nM:4/4\nL:1/32\nQ:1/4=120\n'
177
+ 'V: Ins clef=treble name="Instrumental Melody" snm="Ins"\n'
178
+ 'K:C\nV: Ins\n' + " ".join(body) + "\n")
179
+
180
+
181
  def validate_duration(seconds: float) -> None:
182
  if not 0.4 <= seconds <= MAX_SECONDS:
183
  raise ValueError(f"Нужен напев от 0,4 до {MAX_SECONDS:g} секунд; сейчас {seconds:.1f} с.")
pipeline.py CHANGED
@@ -14,7 +14,7 @@ from typing import Iterator
14
  from datetime import datetime, timezone
15
  from uuid import uuid4
16
 
17
- from logic import INSTRUMENTS, drum_hits, instrumental_abc, validate_duration
18
 
19
 
20
  ROOT = Path(__file__).resolve().parent
@@ -387,6 +387,7 @@ def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int
387
  drums_midi = None
388
  bass_shift = None
389
  bass_source = None
 
390
  archive = {"requested": save_recording, "saved": False}
391
 
392
  def deliver(name: str, raw: Path, *, normalize: bool = True,
@@ -405,6 +406,7 @@ def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int
405
  "source_timing_preserved": list(SAMPLER_VARIANTS),
406
  "bass_transpose_semitones": bass_shift,
407
  "bass_source": bass_source,
 
408
  "recording_archive": dict(archive),
409
  "yue_vocal_absence_guaranteed": False,
410
  "elapsed_seconds": round(time.monotonic() - began, 2)}
@@ -423,7 +425,27 @@ def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int
423
  errors["recording_archive"] = str(error)
424
  if progress:
425
  progress(.12, desc="SheetSage2 извлекает мелодию и сетку долей")
426
- midi, melody_abc, full_abc, beat_lab = transcribe(pcm24, folder, log)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  bass_shift, bass_font, bass_program, bass_source = choose_bass_route(midi)
428
  if progress:
429
  progress(.30, desc="Синтезирую инструмент и барабаны")
 
14
  from datetime import datetime, timezone
15
  from uuid import uuid4
16
 
17
+ from logic import INSTRUMENTS, drum_hits, instrumental_abc, notes_to_abc, validate_duration
18
 
19
 
20
  ROOT = Path(__file__).resolve().parent
 
387
  drums_midi = None
388
  bass_shift = None
389
  bass_source = None
390
+ transcription_source = "SheetSage2"
391
  archive = {"requested": save_recording, "saved": False}
392
 
393
  def deliver(name: str, raw: Path, *, normalize: bool = True,
 
406
  "source_timing_preserved": list(SAMPLER_VARIANTS),
407
  "bass_transpose_semitones": bass_shift,
408
  "bass_source": bass_source,
409
+ "transcription_source": transcription_source,
410
  "recording_archive": dict(archive),
411
  "yue_vocal_absence_guaranteed": False,
412
  "elapsed_seconds": round(time.monotonic() - began, 2)}
 
425
  errors["recording_archive"] = str(error)
426
  if progress:
427
  progress(.12, desc="SheetSage2 извлекает мелодию и сетку долей")
428
+ try:
429
+ midi, melody_abc, full_abc, beat_lab = transcribe(pcm24, folder, log)
430
+ except RuntimeError as error:
431
+ if "could not export ABC" not in str(error):
432
+ raise
433
+ errors["sheet_sage"] = "SheetSage2 не смог записать ноты в ABC"
434
+ midi, melody_abc, full_abc, beat_lab = (
435
+ folder / "notes.mid", folder / "melody.abc", folder / "full.abc", folder / "beat.lab")
436
+ import pretty_midi
437
+ if not midi.is_file() or not any(track.notes for track in
438
+ pretty_midi.PrettyMIDI(str(midi)).instruments):
439
+ from f0_fallback import transcribe_f0
440
+ transcribe_f0(pcm24, midi)
441
+ notes = [(note.start, note.end, note.pitch)
442
+ for track in pretty_midi.PrettyMIDI(str(midi)).instruments
443
+ for note in track.notes]
444
+ abc = notes_to_abc(notes, duration)
445
+ melody_abc.write_text(abc)
446
+ full_abc.write_text(abc)
447
+ transcription_source = "pYIN fallback"
448
+ errors["sheet_sage"] = "SheetSage2 не нашёл нот; использован резервный F0"
449
  bass_shift, bass_font, bass_program, bass_source = choose_bass_route(midi)
450
  if progress:
451
  progress(.30, desc="Синтезирую инструмент и барабаны")
test_f0_fallback.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source-time fallback contracts on a known hum-like signal and plain noise."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import tempfile
7
+ import unittest
8
+
9
+
10
+ class F0FallbackTest(unittest.TestCase):
11
+ def test_short_stable_note_survives_sheet_sage_empty_melody(self):
12
+ import numpy as np
13
+ import pretty_midi
14
+ import soundfile as sf
15
+ from f0_fallback import transcribe_f0
16
+
17
+ rate = 24000
18
+ wave = np.zeros(round(.6 * rate), dtype="float32")
19
+ t = np.arange(round(.48 * rate)) / rate
20
+ wave[round(.08 * rate):round(.56 * rate)] = .12 * np.sin(2 * np.pi * 261.626 * t)
21
+ with tempfile.TemporaryDirectory() as directory:
22
+ source, target = (Path(directory) / name for name in ("hum.wav", "notes.mid"))
23
+ sf.write(source, wave, rate)
24
+ self.assertEqual(transcribe_f0(source, target), 1)
25
+ note = pretty_midi.PrettyMIDI(str(target)).instruments[0].notes[0]
26
+ self.assertEqual(note.pitch, 60)
27
+ self.assertAlmostEqual(note.start, .08, delta=.05)
28
+ self.assertAlmostEqual(note.end, .56, delta=.05)
29
+
30
+ def test_unpitched_noise_does_not_create_midi(self):
31
+ import numpy as np
32
+ import soundfile as sf
33
+ from f0_fallback import transcribe_f0
34
+
35
+ with tempfile.TemporaryDirectory() as directory:
36
+ source, target = (Path(directory) / name for name in ("noise.wav", "notes.mid"))
37
+ sf.write(source, np.random.default_rng(20260926).normal(0, .02, 72000), 24000)
38
+ with self.assertRaisesRegex(ValueError, "устойчивую высоту"):
39
+ transcribe_f0(source, target)
40
+ self.assertFalse(target.exists())
test_logic.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
 
5
  import unittest
6
 
7
- from logic import drum_hits, instrumental_abc, validate_duration
8
 
9
 
10
  NATIVE = '''X:1
@@ -44,6 +44,15 @@ class LogicTest(unittest.TestCase):
44
  with self.assertRaises(ValueError):
45
  instrumental_abc(NATIVE.replace("Z1|", "z16|", 1))
46
 
 
 
 
 
 
 
 
 
 
47
  def test_duration_bounds(self):
48
  validate_duration(0.4)
49
  validate_duration(35)
@@ -74,6 +83,14 @@ class LogicTest(unittest.TestCase):
74
  self.assertFalse(any(time == 0 for time, _, _ in hits))
75
  self.assertIn((.44, 36, 90), hits)
76
 
 
 
 
 
 
 
 
 
77
 
78
  if __name__ == "__main__":
79
  unittest.main()
 
4
 
5
  import unittest
6
 
7
+ from logic import drum_hits, instrumental_abc, notes_to_abc, validate_duration
8
 
9
 
10
  NATIVE = '''X:1
 
44
  with self.assertRaises(ValueError):
45
  instrumental_abc(NATIVE.replace("Z1|", "z16|", 1))
46
 
47
+ def test_fallback_abc_is_instrumental_and_has_source_order(self):
48
+ abc = notes_to_abc([(.08, .52, 60), (.68, 1.1, 60),
49
+ (1.3, 1.7, 62)], 2.)
50
+ self.assertEqual(instrumental_abc(abc), abc)
51
+ self.assertNotIn("Vocal", abc)
52
+ self.assertIn("V: Ins", abc)
53
+ self.assertIn("C7", abc)
54
+ self.assertIn("D6", abc)
55
+
56
  def test_duration_bounds(self):
57
  validate_duration(0.4)
58
  validate_duration(35)
 
83
  self.assertFalse(any(time == 0 for time, _, _ in hits))
84
  self.assertIn((.44, 36, 90), hits)
85
 
86
+ def test_drums_ignore_sheet_sage_initial_one_eighth_anchor(self):
87
+ # Seen in HumTrans F05/M01: 1/8 at zero precedes a real 3/4 or 4/4 grid.
88
+ beats = ("0.0\t1\t1\t8\n.19\t1\t3\t4\n.67\t2\t3\t4\n"
89
+ "1.16\t3\t3\t4\n1.67\t1\t3\t4\n")
90
+ hits = drum_hits(beats, [(.2, 1.8)], 2.0)
91
+ self.assertFalse(any(time == 0 for time, _, _ in hits))
92
+ self.assertIn((.19, 36, 90), hits)
93
+
94
 
95
  if __name__ == "__main__":
96
  unittest.main()
test_sampler.py CHANGED
@@ -10,6 +10,43 @@ from unittest.mock import patch
10
 
11
 
12
  class SamplerTest(unittest.TestCase):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  def test_archive_keeps_original_bytes_and_uses_opaque_name(self):
14
  from pipeline import archive_recording, source_sha256
15
 
@@ -62,6 +99,7 @@ class SamplerTest(unittest.TestCase):
62
 
63
  def test_optional_yue_does_not_reclassify_sampler_outputs_as_missing(self):
64
  import numpy as np
 
65
  import soundfile as sf
66
  from pipeline import generate_all
67
 
@@ -75,7 +113,11 @@ class SamplerTest(unittest.TestCase):
75
  sf.write(wave, np.full(48000, .1, dtype="float32"), 48000)
76
  midi, abc, beat = (folder / name for name in
77
  ("notes.mid", "melody.abc", "beat.lab"))
78
- midi.write_bytes(b"fixture")
 
 
 
 
79
  abc.write_text(abc_text)
80
  beat.write_text("0 1 4 4\n.5 2 4 4\n1 3 4 4\n")
81
 
 
10
 
11
 
12
  class SamplerTest(unittest.TestCase):
13
+ def test_empty_sheet_sage_melody_uses_f0_for_sampler(self):
14
+ import numpy as np
15
+ import pretty_midi
16
+ import soundfile as sf
17
+ from pipeline import generate_all
18
+
19
+ with tempfile.TemporaryDirectory() as directory:
20
+ folder = Path(directory)
21
+ wave = folder / "hum.wav"
22
+ t = np.arange(24000) / 24000
23
+ sf.write(wave, .12 * np.sin(2 * np.pi * 261.626 * t), 24000)
24
+ empty, abc, beat = (folder / name for name in
25
+ ("empty.mid", "empty.abc", "beat.lab"))
26
+ pretty_midi.PrettyMIDI().write(str(empty))
27
+ abc.write_text("unusable empty melody")
28
+ beat.write_text("0 1 4 4\n.5 2 4 4\n1 3 4 4\n")
29
+
30
+ def fake_mp3(_, target, __, **___):
31
+ target.write_bytes(b"fake mp3")
32
+
33
+ with (patch("pipeline.decode", return_value=(wave, wave, 1.)),
34
+ patch("pipeline.transcribe", return_value=(empty, abc, abc, beat)),
35
+ patch("pipeline.choose_bass_route", return_value=(-24, folder / "bass.sf2", 0, "test")),
36
+ patch("pipeline.render_instrument", return_value=(wave, empty)),
37
+ patch("pipeline.render_drums", return_value=(wave, empty, 3)),
38
+ patch("pipeline.pair_with_drums", return_value=(wave, wave)),
39
+ patch("pipeline.render_contour", return_value=wave),
40
+ patch("pipeline.render_preset", return_value=(wave, empty)),
41
+ patch("pipeline.apply_effect", return_value=wave),
42
+ patch("pipeline.encode_mp3", side_effect=fake_mp3)):
43
+ result = list(generate_all(str(wave), "Флейта", {}, 4096, False))[-1]
44
+ self.assertTrue(result[-1]["complete"])
45
+ self.assertEqual(result[-1]["transcription_source"], "pYIN fallback")
46
+ self.assertIn("SheetSage2 не нашёл нот", result[-1]["errors"]["sheet_sage"])
47
+ self.assertEqual(len(pretty_midi.PrettyMIDI(str(empty)).instruments[0].notes), 1)
48
+ self.assertNotIn("Vocal", abc.read_text())
49
+
50
  def test_archive_keeps_original_bytes_and_uses_opaque_name(self):
51
  from pipeline import archive_recording, source_sha256
52
 
 
99
 
100
  def test_optional_yue_does_not_reclassify_sampler_outputs_as_missing(self):
101
  import numpy as np
102
+ import pretty_midi
103
  import soundfile as sf
104
  from pipeline import generate_all
105
 
 
113
  sf.write(wave, np.full(48000, .1, dtype="float32"), 48000)
114
  midi, abc, beat = (folder / name for name in
115
  ("notes.mid", "melody.abc", "beat.lab"))
116
+ score = pretty_midi.PrettyMIDI()
117
+ track = pretty_midi.Instrument(0)
118
+ track.notes.append(pretty_midi.Note(90, 60, .1, .8))
119
+ score.instruments.append(track)
120
+ score.write(str(midi))
121
  abc.write_text(abc_text)
122
  beat.write_text("0 1 4 4\n.5 2 4 4\n1 3 4 4\n")
123