hum / test_f0_fallback.py
batoon's picture
Improve short-hum note articulation fallback
fcf203b verified
Raw History Blame Contribute Delete
3.71 kB
"""Source-time fallback contracts on a known hum-like signal and plain noise."""
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
class F0FallbackTest(unittest.TestCase):
def test_short_stable_note_survives_sheet_sage_empty_melody(self):
import numpy as np
import pretty_midi
import soundfile as sf
from f0_fallback import transcribe_f0
rate = 24000
wave = np.zeros(round(.6 * rate), dtype="float32")
t = np.arange(round(.48 * rate)) / rate
wave[round(.08 * rate):round(.56 * rate)] = .12 * np.sin(2 * np.pi * 261.626 * t)
with tempfile.TemporaryDirectory() as directory:
source, target = (Path(directory) / name for name in ("hum.wav", "notes.mid"))
sf.write(source, wave, rate)
self.assertEqual(transcribe_f0(source, target), 1)
note = pretty_midi.PrettyMIDI(str(target)).instruments[0].notes[0]
self.assertEqual(note.pitch, 60)
self.assertAlmostEqual(note.start, .08, delta=.05)
self.assertAlmostEqual(note.end, .56, delta=.05)
def test_unpitched_noise_does_not_create_midi(self):
import numpy as np
import soundfile as sf
from f0_fallback import transcribe_f0
with tempfile.TemporaryDirectory() as directory:
source, target = (Path(directory) / name for name in ("noise.wav", "notes.mid"))
sf.write(source, np.random.default_rng(20260926).normal(0, .02, 72000), 24000)
with self.assertRaisesRegex(ValueError, "устойчивую высоту"):
transcribe_f0(source, target)
self.assertFalse(target.exists())
def test_glide_stays_one_note_but_connected_syllables_retrigger(self):
import numpy as np
import pretty_midi
import soundfile as sf
from f0_fallback import transcribe_f0
rate = 24000
times = np.arange(round(2.5 * rate)) / rate
active = (times >= .2) & (times < 2.2)
for name in ("glide", "repeats"):
pitch = np.linspace(57, 62, active.sum()) if name == "glide" else np.full(active.sum(), 57)
frequency = 440 * 2 ** ((pitch - 69) / 12)
phase = 2 * np.pi * np.cumsum(frequency) / rate
signal = np.zeros(len(times), dtype=np.float32)
signal[active] = .13 * (np.sin(phase) + .28 * np.sin(2 * phase))
if name == "repeats":
rng = np.random.default_rng(818)
for boundary in (.8, 1.5):
center = round(boundary * rate)
width = round(.035 * rate)
indices = np.arange(-width, width)
dip = .82 * np.exp(-.5 * (indices / (width / 2.5)) ** 2)
signal[center - width:center + width] *= 1 - dip
signal[center:center + round(.018 * rate)] += rng.normal(
0, .018, round(.018 * rate)).astype(np.float32)
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
source, target = (Path(directory) / filename for filename in ("hum.wav", "notes.mid"))
sf.write(source, signal, rate)
expected = 1 if name == "glide" else 3
self.assertEqual(transcribe_f0(source, target), expected)
notes = pretty_midi.PrettyMIDI(str(target)).instruments[0].notes
if name == "repeats":
for note, start in zip(notes, (.2, .8, 1.5)):
self.assertEqual(note.pitch, 57)
self.assertAlmostEqual(note.start, start, delta=.06)