Handle short hums with F0 fallback and initial beat anchor
Browse files- README.md +6 -0
- f0_fallback.py +64 -0
- logic.py +56 -1
- pipeline.py +24 -2
- test_f0_fallback.py +40 -0
- test_logic.py +18 -1
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|