Add sampled guitar and bass with effects; make YuE2 opt-in
Browse files- Dockerfile +17 -2
- README.md +23 -9
- app.py +21 -8
- pipeline.py +80 -16
Dockerfile
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
FROM python:3.12-slim
|
| 2 |
|
| 3 |
ENV DEBIAN_FRONTEND=noninteractive \
|
| 4 |
PYTHONUNBUFFERED=1 \
|
|
@@ -6,9 +6,24 @@ 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 libsndfile1 libgomp1 && \
|
| 10 |
rm -rf /var/lib/apt/lists/*
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
# SheetSage2's published code uses the older Transformers API. Separate
|
| 13 |
# processes keep that runtime independent of YuE2's pinned dependencies.
|
| 14 |
RUN python -m venv /opt/sheet && /opt/sheet/bin/pip install --no-cache-dir \
|
|
|
|
| 1 |
+
FROM python:3.12-slim-bookworm
|
| 2 |
|
| 3 |
ENV DEBIAN_FRONTEND=noninteractive \
|
| 4 |
PYTHONUNBUFFERED=1 \
|
|
|
|
| 6 |
GRADIO_ANALYTICS_ENABLED=False
|
| 7 |
|
| 8 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 9 |
+
ffmpeg fluidsynth libsndfile1 libgomp1 curl ca-certificates p7zip-full && \
|
| 10 |
rm -rf /var/lib/apt/lists/*
|
| 11 |
|
| 12 |
+
# Full multi-sampled CC0 electric guitar and bass. Pin release archives so a
|
| 13 |
+
# changed upstream download cannot silently change audible results.
|
| 14 |
+
RUN mkdir -p /opt/soundfonts /tmp/fonts && \
|
| 15 |
+
curl -fL --retry 3 -o /tmp/guitar.7z \
|
| 16 |
+
'https://github.com/freepats/electric-guitar-FSBS-clean/releases/download/2026-08-07/EGuitarFSBS-clean-SF2-20260807.7z' && \
|
| 17 |
+
echo '6d338afefe0efb629afd9ba28a5c8c129fc28b97ef1b57a31ebceb7bf0e3bf65 /tmp/guitar.7z' | sha256sum -c - && \
|
| 18 |
+
7z x -y -o/tmp/fonts /tmp/guitar.7z >/dev/null && \
|
| 19 |
+
mv '/tmp/fonts/EGuitarFSBS-clean SF2-20260807/EGuitarFSBS-clean bridge 20260807.sf2' /opt/soundfonts/electric-clean.sf2 && \
|
| 20 |
+
curl -fL --retry 3 -o /tmp/bass.7z \
|
| 21 |
+
'https://github.com/freepats/electric-bass-YR/releases/download/2019-09-30/FingerBassYR-SF2-20190930.7z' && \
|
| 22 |
+
echo 'f5b5cfe04435a9e9037d1f97d515dc596535acf26637db9579d724cd757f179e /tmp/bass.7z' | sha256sum -c - && \
|
| 23 |
+
7z x -y -o/tmp/fonts /tmp/bass.7z >/dev/null && \
|
| 24 |
+
mv '/tmp/fonts/FingerBassYR SF2-20190930/FingerBassYR 20190930.sf2' /opt/soundfonts/finger-bass.sf2 && \
|
| 25 |
+
rm -rf /tmp/fonts /tmp/guitar.7z /tmp/bass.7z
|
| 26 |
+
|
| 27 |
# SheetSage2's published code uses the older Transformers API. Separate
|
| 28 |
# processes keep that runtime independent of YuE2's pinned dependencies.
|
| 29 |
RUN python -m venv /opt/sheet && /opt/sheet/bin/pip install --no-cache-dir \
|
README.md
CHANGED
|
@@ -16,15 +16,21 @@ models:
|
|
| 16 |
|
| 17 |
# Hum
|
| 18 |
|
| 19 |
-
Upload a short hum or record one in the browser. One request generates
|
| 20 |
-
comparisons. SheetSage2 extracts
|
| 21 |
-
renders an instrument alone and
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
Output audio is downloadable 320 kbit/s CBR MP3. This Space is public: uploads
|
| 30 |
are processed on its Hugging Face server and results are served from temporary
|
|
@@ -40,5 +46,13 @@ Model licenses and source: [SheetSage2](https://huggingface.co/m-a-p/SheetSage2)
|
|
| 40 |
[YuE2-3B](https://huggingface.co/m-a-p/YuE2-3B),
|
| 41 |
[YuE2-Vae](https://huggingface.co/m-a-p/YuE2-Vae).
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
This is a research prototype; a single owner-reviewed recording is development
|
| 44 |
evidence, not a general accuracy guarantee.
|
|
|
|
| 16 |
|
| 17 |
# Hum
|
| 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 |
+
renders an instrument alone and with a GM drum kit, plus sampled electric
|
| 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
|
| 25 |
+
guitar and fingered bass SoundFonts, with FFmpeg compression, filtering,
|
| 26 |
+
oversampled soft clipping, and a short guitar room echo in processed versions.
|
| 27 |
+
|
| 28 |
+
YuE2 is off by default. Checking its option adds three slow experiments from
|
| 29 |
+
SheetSage2 ABC: solo, melody-with-drums, and composition. The drums in these
|
| 30 |
+
experiments are requested in the style prompt, not imposed as a separate
|
| 31 |
+
symbolic track. YuE2 can introduce voices or other sounds and does not
|
| 32 |
+
preserve source timing exactly. Sampler previews appear first; optional YuE2
|
| 33 |
+
previews then appear as each generation finishes.
|
| 34 |
|
| 35 |
Output audio is downloadable 320 kbit/s CBR MP3. This Space is public: uploads
|
| 36 |
are processed on its Hugging Face server and results are served from temporary
|
|
|
|
| 46 |
[YuE2-3B](https://huggingface.co/m-a-p/YuE2-3B),
|
| 47 |
[YuE2-Vae](https://huggingface.co/m-a-p/YuE2-Vae).
|
| 48 |
|
| 49 |
+
Sample library sources, both CC0 1.0: [FreePats FSBS Clean Electric Guitar](https://freepats.zenvoid.org/ElectricGuitar/clean-electric-guitar.html)
|
| 50 |
+
(full SF2, release 2026-08-07; archive SHA-256
|
| 51 |
+
`6d338afefe0efb629afd9ba28a5c8c129fc28b97ef1b57a31ebceb7bf0e3bf65`),
|
| 52 |
+
and [FreePats Finger Bass YR](https://freepats.zenvoid.org/ElectricGuitar/clean-electric-bass.html)
|
| 53 |
+
(SF2, release 2019-09-30; archive SHA-256
|
| 54 |
+
`f5b5cfe04435a9e9037d1f97d515dc596535acf26637db9579d724cd757f179e`).
|
| 55 |
+
The Docker build verifies both archives before extraction.
|
| 56 |
+
|
| 57 |
This is a research prototype; a single owner-reviewed recording is development
|
| 58 |
evidence, not a general accuracy guarantee.
|
app.py
CHANGED
|
@@ -29,12 +29,12 @@ def suggest_prompts(instrument: str) -> tuple[str, str, str]:
|
|
| 29 |
|
| 30 |
|
| 31 |
def run(audio: str | None, instrument: str, solo_style: str, drum_style: str,
|
| 32 |
-
arrangement_style: str, seed: int,
|
| 33 |
progress: gr.Progress = gr.Progress(track_tqdm=False)):
|
| 34 |
try:
|
| 35 |
styles = {"yue_solo": solo_style, "yue_drums": drum_style,
|
| 36 |
"yue_arrangement": arrangement_style}
|
| 37 |
-
yield from generate_all(audio, instrument, styles, int(seed), progress)
|
| 38 |
except Exception as error:
|
| 39 |
raise gr.Error(str(error)) from error
|
| 40 |
|
|
@@ -52,13 +52,15 @@ CSS = """
|
|
| 52 |
with gr.Blocks(title="Hum · напев в инструмент", css=CSS, theme=gr.themes.Soft()) as demo:
|
| 53 |
gr.HTML("""<div id='hero'><h1>Hum <span style='color:#6474db'>♪</span></h1>
|
| 54 |
<p>Напойте фразу в микрофон или загрузите короткую запись. Один запуск
|
| 55 |
-
создаст все варианты
|
| 56 |
-
|
| 57 |
with gr.Row():
|
| 58 |
with gr.Column(scale=4):
|
| 59 |
audio = gr.Audio(label="Ваш напев", sources=["microphone", "upload"],
|
| 60 |
type="filepath", format="wav")
|
| 61 |
instrument = gr.Dropdown(list(INSTRUMENTS), value="Флейта", label="Инструмент")
|
|
|
|
|
|
|
| 62 |
with gr.Accordion("Настройки YuE2", open=False):
|
| 63 |
defaults = suggest_prompts("Флейта")
|
| 64 |
solo_style = gr.Textbox(label="Соло · style", lines=2, value=defaults[0])
|
|
@@ -67,10 +69,12 @@ with gr.Blocks(title="Hum · напев в инструмент", css=CSS, theme
|
|
| 67 |
arrangement_style = gr.Textbox(label="Композиция · style", lines=2,
|
| 68 |
value=defaults[2])
|
| 69 |
seed = gr.Number(label="Seed", value=4096, precision=0)
|
| 70 |
-
go = gr.Button("Создать
|
| 71 |
gr.HTML("""<div class='note'><b>Текущая цель:</b> один инструмент и
|
| 72 |
-
приблизительно исходное время
|
| 73 |
-
п
|
|
|
|
|
|
|
| 74 |
длительность, добавить человеческие звуки или другие инструменты;
|
| 75 |
отсутствие вокала не гарантируется. Это публичный Space: записи
|
| 76 |
обрабатываются на сервере Hugging Face.</div>""")
|
|
@@ -83,6 +87,14 @@ with gr.Blocks(title="Hum · напев в инструмент", css=CSS, theme
|
|
| 83 |
interactive=False)
|
| 84 |
contour = gr.Audio(label="Непрерывная высота · synth",
|
| 85 |
interactive=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
with gr.Column():
|
| 87 |
yue_solo = gr.Audio(label="YuE2 · соло", interactive=False)
|
| 88 |
yue_drums = gr.Audio(label="YuE2 · мелодия + барабаны",
|
|
@@ -98,8 +110,9 @@ with gr.Blocks(title="Hum · напев в инструмент", css=CSS, theme
|
|
| 98 |
instrument.change(suggest_prompts, inputs=instrument,
|
| 99 |
outputs=[solo_style, drum_style, arrangement_style])
|
| 100 |
go.click(run, inputs=[audio, instrument, solo_style, drum_style,
|
| 101 |
-
arrangement_style, seed],
|
| 102 |
outputs=[instrument_only, instrument_drums, contour,
|
|
|
|
| 103 |
yue_solo, yue_drums, yue_arrangement,
|
| 104 |
midi, drum_midi, beat_lab, abc, details], show_progress="full")
|
| 105 |
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def run(audio: str | None, instrument: str, solo_style: str, drum_style: str,
|
| 32 |
+
arrangement_style: str, seed: int, include_yue: bool,
|
| 33 |
progress: gr.Progress = gr.Progress(track_tqdm=False)):
|
| 34 |
try:
|
| 35 |
styles = {"yue_solo": solo_style, "yue_drums": drum_style,
|
| 36 |
"yue_arrangement": arrangement_style}
|
| 37 |
+
yield from generate_all(audio, instrument, styles, int(seed), include_yue, progress)
|
| 38 |
except Exception as error:
|
| 39 |
raise gr.Error(str(error)) from error
|
| 40 |
|
|
|
|
| 52 |
with gr.Blocks(title="Hum · напев в инструмент", css=CSS, theme=gr.themes.Soft()) as demo:
|
| 53 |
gr.HTML("""<div id='hero'><h1>Hum <span style='color:#6474db'>♪</span></h1>
|
| 54 |
<p>Напойте фразу в микрофон или загрузите короткую запись. Один запуск
|
| 55 |
+
создаст все сэмплерные варианты с исходными нотами и временем: инструмент,
|
| 56 |
+
электрогитару, бас и эффекты. YuE2 включается по желанию.</p></div>""")
|
| 57 |
with gr.Row():
|
| 58 |
with gr.Column(scale=4):
|
| 59 |
audio = gr.Audio(label="Ваш напев", sources=["microphone", "upload"],
|
| 60 |
type="filepath", format="wav")
|
| 61 |
instrument = gr.Dropdown(list(INSTRUMENTS), value="Флейта", label="Инструмент")
|
| 62 |
+
include_yue = gr.Checkbox(label="Дополнительно запустить YuE2 · три генерации (~3 минуты)",
|
| 63 |
+
value=False)
|
| 64 |
with gr.Accordion("Настройки YuE2", open=False):
|
| 65 |
defaults = suggest_prompts("Флейта")
|
| 66 |
solo_style = gr.Textbox(label="Соло · style", lines=2, value=defaults[0])
|
|
|
|
| 69 |
arrangement_style = gr.Textbox(label="Композиция · style", lines=2,
|
| 70 |
value=defaults[2])
|
| 71 |
seed = gr.Number(label="Seed", value=4096, precision=0)
|
| 72 |
+
go = gr.Button("Создать сэмплерные варианты", variant="primary", size="lg")
|
| 73 |
gr.HTML("""<div class='note'><b>Текущая цель:</b> один инструмент и
|
| 74 |
+
приблизительно исходное время. Электрогитара и бас используют
|
| 75 |
+
записанные сэмплы; бас звучит октавой ниже. Обработанные версии
|
| 76 |
+
добавляют усиление и лёгкий эффект, но не новые ноты. Барабаны
|
| 77 |
+
привязаны к долям SheetSage2. YuE2 может изменить
|
| 78 |
длительность, добавить человеческие звуки или другие инструменты;
|
| 79 |
отсутствие вокала не гарантируется. Это публичный Space: записи
|
| 80 |
обрабатываются на сервере Hugging Face.</div>""")
|
|
|
|
| 87 |
interactive=False)
|
| 88 |
contour = gr.Audio(label="Непрерывная высота · synth",
|
| 89 |
interactive=False)
|
| 90 |
+
guitar_clean = gr.Audio(label="Электрогитара · чистая",
|
| 91 |
+
interactive=False)
|
| 92 |
+
guitar_amp = gr.Audio(label="Электрогитара · усилитель и эффекты",
|
| 93 |
+
interactive=False)
|
| 94 |
+
bass_clean = gr.Audio(label="Бас · чистый",
|
| 95 |
+
interactive=False)
|
| 96 |
+
bass_amp = gr.Audio(label="Бас · тёплый усилитель",
|
| 97 |
+
interactive=False)
|
| 98 |
with gr.Column():
|
| 99 |
yue_solo = gr.Audio(label="YuE2 · соло", interactive=False)
|
| 100 |
yue_drums = gr.Audio(label="YuE2 · мелодия + барабаны",
|
|
|
|
| 110 |
instrument.change(suggest_prompts, inputs=instrument,
|
| 111 |
outputs=[solo_style, drum_style, arrangement_style])
|
| 112 |
go.click(run, inputs=[audio, instrument, solo_style, drum_style,
|
| 113 |
+
arrangement_style, seed, include_yue],
|
| 114 |
outputs=[instrument_only, instrument_drums, contour,
|
| 115 |
+
guitar_clean, guitar_amp, bass_clean, bass_amp,
|
| 116 |
yue_solo, yue_drums, yue_arrangement,
|
| 117 |
midi, drum_midi, beat_lab, abc, details], show_progress="full")
|
| 118 |
|
pipeline.py
CHANGED
|
@@ -17,8 +17,10 @@ from logic import INSTRUMENTS, drum_hits, instrumental_abc, validate_duration
|
|
| 17 |
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 |
VARIANTS = (
|
| 21 |
"instrument", "instrument_drums", "contour",
|
|
|
|
| 22 |
"yue_solo", "yue_drums", "yue_arrangement",
|
| 23 |
)
|
| 24 |
|
|
@@ -72,12 +74,13 @@ def transcribe(pcm24: Path, folder: Path, log: Path) -> tuple[Path, Path, Path,
|
|
| 72 |
folder / "full.abc", folder / "beat.lab")
|
| 73 |
|
| 74 |
|
| 75 |
-
def render_midi(midi_path: Path, output: Path, duration: float, log: Path
|
|
|
|
| 76 |
import numpy as np
|
| 77 |
import pretty_midi
|
| 78 |
import soundfile as sf
|
| 79 |
|
| 80 |
-
soundfont = Path(pretty_midi.__file__).parent / "TimGM6mb.sf2"
|
| 81 |
if not soundfont.is_file():
|
| 82 |
raise RuntimeError("SoundFont missing from pretty_midi installation")
|
| 83 |
raw = output.with_name(output.stem + "-tail.wav")
|
|
@@ -94,25 +97,61 @@ def render_midi(midi_path: Path, output: Path, duration: float, log: Path) -> Pa
|
|
| 94 |
return output
|
| 95 |
|
| 96 |
|
| 97 |
-
def
|
| 98 |
-
|
|
|
|
| 99 |
import pretty_midi
|
| 100 |
|
| 101 |
-
if instrument not in INSTRUMENTS:
|
| 102 |
-
raise ValueError("Неизвестный инструмент")
|
| 103 |
source = pretty_midi.PrettyMIDI(str(midi_path))
|
| 104 |
notes = [n for track in source.instruments for n in track.notes]
|
| 105 |
if not notes:
|
| 106 |
raise RuntimeError("SheetSage2 не нашёл нот в напеве")
|
| 107 |
midi = pretty_midi.PrettyMIDI(initial_tempo=120, resolution=10000)
|
| 108 |
-
track = pretty_midi.Instrument(program=
|
| 109 |
for note in notes:
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 111 |
start=note.start, end=note.end))
|
| 112 |
midi.instruments.append(track)
|
| 113 |
-
rendered_midi = folder / "
|
| 114 |
midi.write(str(rendered_midi))
|
| 115 |
-
return render_midi(rendered_midi, folder / "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
|
| 118 |
def render_drums(melody_midi: Path, beat_lab: Path, folder: Path,
|
|
@@ -256,7 +295,7 @@ def render_yue_batch(melody_abc: Path, full_abc: Path, folder: Path,
|
|
| 256 |
|
| 257 |
|
| 258 |
def encode_mp3(source: Path, destination: Path, log: Path,
|
| 259 |
-
normalize: bool = True) -> None:
|
| 260 |
import numpy as np
|
| 261 |
import soundfile as sf
|
| 262 |
|
|
@@ -266,7 +305,7 @@ def encode_mp3(source: Path, destination: Path, log: Path,
|
|
| 266 |
peak = float(np.max(np.abs(audio)))
|
| 267 |
if peak <= 1e-8:
|
| 268 |
raise RuntimeError("Результат слишком тихий")
|
| 269 |
-
gain = min(
|
| 270 |
safe = destination.with_suffix(".safe.wav")
|
| 271 |
sf.write(safe, audio * gain, rate, subtype="PCM_24")
|
| 272 |
command(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", str(safe),
|
|
@@ -277,6 +316,7 @@ def encode_mp3(source: Path, destination: Path, log: Path,
|
|
| 277 |
|
| 278 |
|
| 279 |
def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int,
|
|
|
|
| 280 |
progress=None) -> Iterator[tuple]:
|
| 281 |
if not source or instrument not in INSTRUMENTS:
|
| 282 |
raise ValueError("Загрузите или запишите напев и выберите инструмент")
|
|
@@ -292,9 +332,10 @@ def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int
|
|
| 292 |
drum_count = 0
|
| 293 |
drums_midi = None
|
| 294 |
|
| 295 |
-
def deliver(name: str, raw: Path, *, normalize: bool = True
|
|
|
|
| 296 |
destination = folder / f"{name}.mp3"
|
| 297 |
-
encode_mp3(raw, destination, log, normalize=normalize)
|
| 298 |
outputs[name] = str(destination)
|
| 299 |
durations[name] = round(__import__("soundfile").info(raw).duration, 3)
|
| 300 |
|
|
@@ -302,9 +343,10 @@ def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int
|
|
| 302 |
metadata = {"instrument": instrument, "source_sha256": digest,
|
| 303 |
"source_seconds": round(duration, 3),
|
| 304 |
"result_seconds": dict(durations), "drum_hits": drum_count,
|
| 305 |
-
"yue_timing": dict(yue_timing),
|
| 306 |
"errors": dict(errors), "complete": complete,
|
| 307 |
-
"source_timing_preserved": [
|
|
|
|
| 308 |
"yue_vocal_absence_guaranteed": False,
|
| 309 |
"elapsed_seconds": round(time.monotonic() - began, 2)}
|
| 310 |
return (*(outputs[name] for name in VARIANTS), str(melody_midi),
|
|
@@ -339,6 +381,28 @@ def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int
|
|
| 339 |
(folder / "instrumental-melody.abc").write_text(
|
| 340 |
instrumental_abc(melody_abc.read_text()))
|
| 341 |
yield snapshot()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
if progress:
|
| 343 |
progress(.50, desc="YuE2 создаёт сразу три варианта; это займёт несколько минут")
|
| 344 |
completed = set()
|
|
|
|
| 17 |
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",
|
| 24 |
"yue_solo", "yue_drums", "yue_arrangement",
|
| 25 |
)
|
| 26 |
|
|
|
|
| 74 |
folder / "full.abc", folder / "beat.lab")
|
| 75 |
|
| 76 |
|
| 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 Path(pretty_midi.__file__).parent / "TimGM6mb.sf2"
|
| 84 |
if not soundfont.is_file():
|
| 85 |
raise RuntimeError("SoundFont missing from pretty_midi installation")
|
| 86 |
raw = output.with_name(output.stem + "-tail.wav")
|
|
|
|
| 97 |
return output
|
| 98 |
|
| 99 |
|
| 100 |
+
def render_preset(midi_path: Path, folder: Path, name: str, program: int,
|
| 101 |
+
duration: float, log: Path, *, soundfont: Path | None = None,
|
| 102 |
+
transpose: int = 0) -> tuple[Path, Path]:
|
| 103 |
import pretty_midi
|
| 104 |
|
|
|
|
|
|
|
| 105 |
source = pretty_midi.PrettyMIDI(str(midi_path))
|
| 106 |
notes = [n for track in source.instruments for n in track.notes]
|
| 107 |
if not notes:
|
| 108 |
raise RuntimeError("SheetSage2 не нашёл нот в напеве")
|
| 109 |
midi = pretty_midi.PrettyMIDI(initial_tempo=120, resolution=10000)
|
| 110 |
+
track = pretty_midi.Instrument(program=program, name=name)
|
| 111 |
for note in notes:
|
| 112 |
+
pitch = note.pitch + transpose
|
| 113 |
+
if not 0 <= pitch <= 127:
|
| 114 |
+
raise ValueError("Транспонированная нота вне MIDI-диапазона")
|
| 115 |
+
track.notes.append(pretty_midi.Note(velocity=90, pitch=pitch,
|
| 116 |
start=note.start, end=note.end))
|
| 117 |
midi.instruments.append(track)
|
| 118 |
+
rendered_midi = folder / f"{name}.mid"
|
| 119 |
midi.write(str(rendered_midi))
|
| 120 |
+
return (render_midi(rendered_midi, folder / f"{name}.wav", duration, log,
|
| 121 |
+
soundfont=soundfont), rendered_midi)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def render_instrument(midi_path: Path, folder: Path, instrument: str,
|
| 125 |
+
duration: float, log: Path) -> tuple[Path, Path]:
|
| 126 |
+
if instrument not in INSTRUMENTS:
|
| 127 |
+
raise ValueError("Неизвестный инструмент")
|
| 128 |
+
return render_preset(midi_path, folder, "instrument", INSTRUMENTS[instrument][0],
|
| 129 |
+
duration, log)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def apply_effect(source: Path, output: Path, kind: str, duration: float,
|
| 133 |
+
log: Path) -> Path:
|
| 134 |
+
# Both chains use the same sampled notes and retain the source duration.
|
| 135 |
+
chains = {
|
| 136 |
+
"guitar": ("highpass=f=75,acompressor=threshold=0.08:ratio=2.5:"
|
| 137 |
+
"attack=8:release=90,volume=2.2,"
|
| 138 |
+
"asoftclip=type=tanh:threshold=0.55:oversample=4,"
|
| 139 |
+
"lowpass=f=6500,aecho=0.9:0.85:75:0.08"),
|
| 140 |
+
"bass": ("highpass=f=32,lowpass=f=4500,"
|
| 141 |
+
"acompressor=threshold=0.09:ratio=3:attack=12:release=120,"
|
| 142 |
+
"volume=1.65,asoftclip=type=tanh:threshold=0.72:oversample=4"),
|
| 143 |
+
}
|
| 144 |
+
if kind not in chains:
|
| 145 |
+
raise ValueError("Неизвестный эффект")
|
| 146 |
+
command(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", str(source),
|
| 147 |
+
"-af", chains[kind], "-t", f"{duration:.6f}", "-ar", "48000",
|
| 148 |
+
"-c:a", "pcm_f32le", str(output)], log, timeout=120)
|
| 149 |
+
import numpy as np
|
| 150 |
+
import soundfile as sf
|
| 151 |
+
audio, rate = sf.read(output, dtype="float32", always_2d=True)
|
| 152 |
+
if rate != 48000 or abs(len(audio) - round(duration * rate)) > 1 or not np.isfinite(audio).all():
|
| 153 |
+
raise RuntimeError("Эффект нарушил длительность или повредил аудио")
|
| 154 |
+
return output
|
| 155 |
|
| 156 |
|
| 157 |
def render_drums(melody_midi: Path, beat_lab: Path, folder: Path,
|
|
|
|
| 295 |
|
| 296 |
|
| 297 |
def encode_mp3(source: Path, destination: Path, log: Path,
|
| 298 |
+
normalize: bool = True, max_gain: float = 4.) -> None:
|
| 299 |
import numpy as np
|
| 300 |
import soundfile as sf
|
| 301 |
|
|
|
|
| 305 |
peak = float(np.max(np.abs(audio)))
|
| 306 |
if peak <= 1e-8:
|
| 307 |
raise RuntimeError("Результат слишком тихий")
|
| 308 |
+
gain = min(max_gain, .8 / peak) if normalize else 1.
|
| 309 |
safe = destination.with_suffix(".safe.wav")
|
| 310 |
sf.write(safe, audio * gain, rate, subtype="PCM_24")
|
| 311 |
command(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", str(safe),
|
|
|
|
| 316 |
|
| 317 |
|
| 318 |
def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int,
|
| 319 |
+
include_yue: bool = False,
|
| 320 |
progress=None) -> Iterator[tuple]:
|
| 321 |
if not source or instrument not in INSTRUMENTS:
|
| 322 |
raise ValueError("Загрузите или запишите напев и выберите инструмент")
|
|
|
|
| 332 |
drum_count = 0
|
| 333 |
drums_midi = None
|
| 334 |
|
| 335 |
+
def deliver(name: str, raw: Path, *, normalize: bool = True,
|
| 336 |
+
max_gain: float = 4.) -> None:
|
| 337 |
destination = folder / f"{name}.mp3"
|
| 338 |
+
encode_mp3(raw, destination, log, normalize=normalize, max_gain=max_gain)
|
| 339 |
outputs[name] = str(destination)
|
| 340 |
durations[name] = round(__import__("soundfile").info(raw).duration, 3)
|
| 341 |
|
|
|
|
| 343 |
metadata = {"instrument": instrument, "source_sha256": digest,
|
| 344 |
"source_seconds": round(duration, 3),
|
| 345 |
"result_seconds": dict(durations), "drum_hits": drum_count,
|
| 346 |
+
"yue_requested": include_yue, "yue_timing": dict(yue_timing),
|
| 347 |
"errors": dict(errors), "complete": complete,
|
| 348 |
+
"source_timing_preserved": list(VARIANTS[:7]),
|
| 349 |
+
"bass_transpose_semitones": -12,
|
| 350 |
"yue_vocal_absence_guaranteed": False,
|
| 351 |
"elapsed_seconds": round(time.monotonic() - began, 2)}
|
| 352 |
return (*(outputs[name] for name in VARIANTS), str(melody_midi),
|
|
|
|
| 381 |
(folder / "instrumental-melody.abc").write_text(
|
| 382 |
instrumental_abc(melody_abc.read_text()))
|
| 383 |
yield snapshot()
|
| 384 |
+
if progress:
|
| 385 |
+
progress(.48, desc="Сэмплирую электрогитару и бас")
|
| 386 |
+
for name, font_name, transpose, effect in (
|
| 387 |
+
("guitar_clean", "electric-clean.sf2", 0, "guitar"),
|
| 388 |
+
("bass_clean", "finger-bass.sf2", -12, "bass"),
|
| 389 |
+
):
|
| 390 |
+
try:
|
| 391 |
+
raw, _ = render_preset(midi, folder, name, 0, duration, log,
|
| 392 |
+
soundfont=SOUNDFONT_DIR / font_name,
|
| 393 |
+
transpose=transpose)
|
| 394 |
+
deliver(name, raw, max_gain=20.)
|
| 395 |
+
deliver(name.replace("clean", "amp"),
|
| 396 |
+
apply_effect(raw, folder / f"{name}-amp.wav", effect,
|
| 397 |
+
duration, log), max_gain=20.)
|
| 398 |
+
except (ValueError, RuntimeError, OSError) as error:
|
| 399 |
+
errors[name] = str(error)
|
| 400 |
+
yield snapshot()
|
| 401 |
+
if not include_yue:
|
| 402 |
+
final = snapshot(complete=True)
|
| 403 |
+
(folder / "result.json").write_text(json.dumps(final[-1], ensure_ascii=False, indent=2) + "\n")
|
| 404 |
+
yield final
|
| 405 |
+
return
|
| 406 |
if progress:
|
| 407 |
progress(.50, desc="YuE2 создаёт сразу три варианта; это займёт несколько минут")
|
| 408 |
completed = set()
|