Use full GM SoundFont and verify sampler timing
Browse files- Dockerfile +2 -1
- README.md +2 -1
- pipeline.py +4 -3
- test_sampler.py +60 -0
Dockerfile
CHANGED
|
@@ -6,7 +6,8 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|
| 6 |
GRADIO_ANALYTICS_ENABLED=False
|
| 7 |
|
| 8 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 9 |
-
ffmpeg fluidsynth
|
|
|
|
| 10 |
rm -rf /var/lib/apt/lists/*
|
| 11 |
|
| 12 |
# Full multi-sampled CC0 electric guitar and bass. Pin release archives so a
|
|
|
|
| 6 |
GRADIO_ANALYTICS_ENABLED=False
|
| 7 |
|
| 8 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 9 |
+
ffmpeg fluidsynth fluid-soundfont-gm libsndfile1 libgomp1 \
|
| 10 |
+
curl ca-certificates p7zip-full && \
|
| 11 |
rm -rf /var/lib/apt/lists/*
|
| 12 |
|
| 13 |
# Full multi-sampled CC0 electric guitar and bass. Pin release archives so a
|
README.md
CHANGED
|
@@ -18,7 +18,8 @@ models:
|
|
| 18 |
|
| 19 |
Upload a short hum or record one in the browser. One request generates seven
|
| 20 |
source-timed comparisons. SheetSage2 extracts notes and beats; FluidSynth
|
| 21 |
-
|
|
|
|
| 22 |
guitar and fingered bass in clean and processed versions. The bass is shifted
|
| 23 |
down one octave, with note starts and lengths preserved. A continuous-F0 synth
|
| 24 |
follows voice pitch and amplitude. The sampler uses full-size FreePats electric
|
|
|
|
| 18 |
|
| 19 |
Upload a short hum or record one in the browser. One request generates seven
|
| 20 |
source-timed comparisons. SheetSage2 extracts notes and beats; FluidSynth
|
| 21 |
+
uses the full [FluidR3 GM bank](https://www.fluidsynth.org/wiki/GettingStarted/)
|
| 22 |
+
for the selected instrument and drum kit, plus sampled electric
|
| 23 |
guitar and fingered bass in clean and processed versions. The bass is shifted
|
| 24 |
down one octave, with note starts and lengths preserved. A continuous-F0 synth
|
| 25 |
follows voice pitch and amplitude. The sampler uses full-size FreePats electric
|
pipeline.py
CHANGED
|
@@ -18,6 +18,8 @@ ROOT = Path(__file__).resolve().parent
|
|
| 18 |
SHEET_PYTHON = Path(os.environ.get("HUM_SHEET_PYTHON", "/opt/sheet/bin/python"))
|
| 19 |
YUE_PYTHON = Path(os.environ.get("HUM_YUE_PYTHON", "/opt/yue/bin/python"))
|
| 20 |
SOUNDFONT_DIR = Path(os.environ.get("HUM_SOUNDFONTS", "/opt/soundfonts"))
|
|
|
|
|
|
|
| 21 |
VARIANTS = (
|
| 22 |
"instrument", "instrument_drums", "contour",
|
| 23 |
"guitar_clean", "guitar_amp", "bass_clean", "bass_amp",
|
|
@@ -77,12 +79,11 @@ def transcribe(pcm24: Path, folder: Path, log: Path) -> tuple[Path, Path, Path,
|
|
| 77 |
def render_midi(midi_path: Path, output: Path, duration: float, log: Path,
|
| 78 |
soundfont: Path | None = None) -> Path:
|
| 79 |
import numpy as np
|
| 80 |
-
import pretty_midi
|
| 81 |
import soundfile as sf
|
| 82 |
|
| 83 |
-
soundfont = soundfont or
|
| 84 |
if not soundfont.is_file():
|
| 85 |
-
raise RuntimeError("SoundFont
|
| 86 |
raw = output.with_name(output.stem + "-tail.wav")
|
| 87 |
command(["fluidsynth", "-ni", "-C", "0", "-R", "0", "-r", "48000",
|
| 88 |
"-z", "64", "-g", ".2", "-o", "player.timing-source=sample",
|
|
|
|
| 18 |
SHEET_PYTHON = Path(os.environ.get("HUM_SHEET_PYTHON", "/opt/sheet/bin/python"))
|
| 19 |
YUE_PYTHON = Path(os.environ.get("HUM_YUE_PYTHON", "/opt/yue/bin/python"))
|
| 20 |
SOUNDFONT_DIR = Path(os.environ.get("HUM_SOUNDFONTS", "/opt/soundfonts"))
|
| 21 |
+
GM_SOUNDFONT = Path(os.environ.get("HUM_GM_SOUNDFONT",
|
| 22 |
+
"/usr/share/sounds/sf2/FluidR3_GM.sf2"))
|
| 23 |
VARIANTS = (
|
| 24 |
"instrument", "instrument_drums", "contour",
|
| 25 |
"guitar_clean", "guitar_amp", "bass_clean", "bass_amp",
|
|
|
|
| 79 |
def render_midi(midi_path: Path, output: Path, duration: float, log: Path,
|
| 80 |
soundfont: Path | None = None) -> Path:
|
| 81 |
import numpy as np
|
|
|
|
| 82 |
import soundfile as sf
|
| 83 |
|
| 84 |
+
soundfont = soundfont or GM_SOUNDFONT
|
| 85 |
if not soundfont.is_file():
|
| 86 |
+
raise RuntimeError(f"SoundFont не найден: {soundfont}")
|
| 87 |
raw = output.with_name(output.stem + "-tail.wav")
|
| 88 |
command(["fluidsynth", "-ni", "-C", "0", "-R", "0", "-r", "48000",
|
| 89 |
"-z", "64", "-g", ".2", "-o", "player.timing-source=sample",
|
test_sampler.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sampler note timing and audio effect contracts."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import tempfile
|
| 7 |
+
import unittest
|
| 8 |
+
from unittest.mock import patch
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SamplerTest(unittest.TestCase):
|
| 12 |
+
def test_bass_octave_preserves_note_times(self):
|
| 13 |
+
import pretty_midi
|
| 14 |
+
from pipeline import render_preset
|
| 15 |
+
|
| 16 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 17 |
+
folder = Path(directory)
|
| 18 |
+
original = pretty_midi.PrettyMIDI()
|
| 19 |
+
voice = pretty_midi.Instrument(program=0)
|
| 20 |
+
voice.notes = [
|
| 21 |
+
pretty_midi.Note(90, 60, .125, .375),
|
| 22 |
+
pretty_midi.Note(90, 64, .5, .875),
|
| 23 |
+
]
|
| 24 |
+
original.instruments.append(voice)
|
| 25 |
+
source = folder / "source.mid"
|
| 26 |
+
original.write(str(source))
|
| 27 |
+
with patch("pipeline.render_midi", return_value=folder / "bass.wav"):
|
| 28 |
+
_, path = render_preset(source, folder, "bass", 0, 1.,
|
| 29 |
+
folder / "log", transpose=-12)
|
| 30 |
+
notes = pretty_midi.PrettyMIDI(str(path)).instruments[0].notes
|
| 31 |
+
self.assertEqual([n.pitch for n in notes], [48, 52])
|
| 32 |
+
for before, after in zip(voice.notes, notes):
|
| 33 |
+
self.assertAlmostEqual(before.start, after.start, places=4)
|
| 34 |
+
self.assertAlmostEqual(before.end, after.end, places=4)
|
| 35 |
+
|
| 36 |
+
def test_effects_preserve_duration_and_change_waveform(self):
|
| 37 |
+
import numpy as np
|
| 38 |
+
import soundfile as sf
|
| 39 |
+
from pipeline import apply_effect
|
| 40 |
+
|
| 41 |
+
rate = 48000
|
| 42 |
+
t = np.arange(rate, dtype=np.float32) / rate
|
| 43 |
+
source_wave = (.12 * np.sin(2 * np.pi * 220 * t)).astype("float32")
|
| 44 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 45 |
+
folder = Path(directory)
|
| 46 |
+
source = folder / "source.wav"
|
| 47 |
+
sf.write(source, source_wave, rate, subtype="FLOAT")
|
| 48 |
+
for kind in ("guitar", "bass"):
|
| 49 |
+
result = apply_effect(source, folder / f"{kind}.wav", kind,
|
| 50 |
+
1., folder / "log")
|
| 51 |
+
output, output_rate = sf.read(result, dtype="float32",
|
| 52 |
+
always_2d=True)
|
| 53 |
+
self.assertEqual(output_rate, rate)
|
| 54 |
+
self.assertEqual(len(output), rate)
|
| 55 |
+
self.assertTrue(np.isfinite(output).all())
|
| 56 |
+
self.assertGreater(np.mean(np.abs(output[:, 0] - source_wave)), .001)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
if __name__ == "__main__":
|
| 60 |
+
unittest.main()
|