Stream previews and remove duplicate initial drum beat
Browse files- README.md +2 -1
- app.py +1 -1
- logic.py +5 -0
- pipeline.py +74 -23
- test_logic.py +8 -0
README.md
CHANGED
|
@@ -23,7 +23,8 @@ on those beats. A continuous-F0 synth follows voice pitch and amplitude. A
|
|
| 23 |
single YuE2 model load produces solo, melody-with-drums, and composition
|
| 24 |
variants from the SheetSage2 ABC. YuE2 drums are requested in the style prompt,
|
| 25 |
not imposed as a separate symbolic track. YuE2 can introduce voices or other
|
| 26 |
-
sounds and does not preserve source timing exactly.
|
|
|
|
| 27 |
|
| 28 |
Output audio is downloadable 320 kbit/s CBR MP3. This Space is public: uploads
|
| 29 |
are processed on its Hugging Face server and results are served from temporary
|
|
|
|
| 23 |
single YuE2 model load produces solo, melody-with-drums, and composition
|
| 24 |
variants from the SheetSage2 ABC. YuE2 drums are requested in the style prompt,
|
| 25 |
not imposed as a separate symbolic track. YuE2 can introduce voices or other
|
| 26 |
+
sounds and does not preserve source timing exactly. The three deterministic
|
| 27 |
+
previews appear first; YuE2 previews then appear as each generation finishes.
|
| 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
|
app.py
CHANGED
|
@@ -34,7 +34,7 @@ def run(audio: str | None, instrument: str, solo_style: str, drum_style: str,
|
|
| 34 |
try:
|
| 35 |
styles = {"yue_solo": solo_style, "yue_drums": drum_style,
|
| 36 |
"yue_arrangement": arrangement_style}
|
| 37 |
-
|
| 38 |
except Exception as error:
|
| 39 |
raise gr.Error(str(error)) from error
|
| 40 |
|
|
|
|
| 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 |
|
logic.py
CHANGED
|
@@ -45,6 +45,11 @@ def drum_hits(beat_lab: str, melody_spans: list[tuple[float, float]],
|
|
| 45 |
beats.append(Beat(time, int(position), int(meter)))
|
| 46 |
if len(beats) < 3 or not melody_spans:
|
| 47 |
raise ValueError("Для барабанов нужно хотя бы три доли и слышимая мелодия")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
period = median(b.time - a.time for a, b in zip(beats, beats[1:]))
|
| 49 |
if not 0.12 <= period <= 2.0:
|
| 50 |
raise ValueError("Не удалось определить разумный темп барабанов")
|
|
|
|
| 45 |
beats.append(Beat(time, int(position), int(meter)))
|
| 46 |
if len(beats) < 3 or not melody_spans:
|
| 47 |
raise ValueError("Для барабанов нужно хотя бы три доли и слышимая мелодия")
|
| 48 |
+
# SheetSage can emit a time-zero anchor immediately before its first real
|
| 49 |
+
# downbeat. Two adjacent beat-1 labels cannot both start the same measure.
|
| 50 |
+
if (beats[0].time == 0 and beats[0].position == beats[1].position == 1
|
| 51 |
+
and beats[0].meter == beats[1].meter):
|
| 52 |
+
beats = beats[1:]
|
| 53 |
period = median(b.time - a.time for a, b in zip(beats, beats[1:]))
|
| 54 |
if not 0.12 <= period <= 2.0:
|
| 55 |
raise ValueError("Не удалось определить разумный темп барабанов")
|
pipeline.py
CHANGED
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
| 9 |
import subprocess
|
| 10 |
import tempfile
|
| 11 |
import time
|
|
|
|
| 12 |
|
| 13 |
from logic import INSTRUMENTS, drum_hits, instrumental_abc, validate_duration
|
| 14 |
|
|
@@ -199,7 +200,7 @@ def render_contour(source: Path, folder: Path) -> Path:
|
|
| 199 |
|
| 200 |
|
| 201 |
def render_yue_batch(melody_abc: Path, full_abc: Path, folder: Path,
|
| 202 |
-
styles: dict[str, str], seed: int, log: Path) ->
|
| 203 |
if not 0 <= seed < 2**31:
|
| 204 |
raise ValueError("Seed должен быть целым числом от 0 до 2³¹−1")
|
| 205 |
for style in styles.values():
|
|
@@ -219,8 +220,39 @@ def render_yue_batch(melody_abc: Path, full_abc: Path, folder: Path,
|
|
| 219 |
]}
|
| 220 |
request_path = folder / "yue-requests.json"
|
| 221 |
request_path.write_text(json.dumps(request, ensure_ascii=False, indent=2) + "\n")
|
| 222 |
-
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
|
| 226 |
def encode_mp3(source: Path, destination: Path, log: Path,
|
|
@@ -245,7 +277,7 @@ def encode_mp3(source: Path, destination: Path, log: Path,
|
|
| 245 |
|
| 246 |
|
| 247 |
def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int,
|
| 248 |
-
progress=None) -> tuple:
|
| 249 |
if not source or instrument not in INSTRUMENTS:
|
| 250 |
raise ValueError("Загрузите или запишите напев и выберите инструмент")
|
| 251 |
folder = Path(tempfile.mkdtemp(prefix="hum-"))
|
|
@@ -256,6 +288,8 @@ def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int
|
|
| 256 |
outputs = {name: None for name in VARIANTS}
|
| 257 |
errors = {}
|
| 258 |
durations = {}
|
|
|
|
|
|
|
| 259 |
|
| 260 |
def deliver(name: str, raw: Path, *, normalize: bool = True) -> None:
|
| 261 |
destination = folder / f"{name}.mp3"
|
|
@@ -263,6 +297,18 @@ def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int
|
|
| 263 |
outputs[name] = str(destination)
|
| 264 |
durations[name] = round(__import__("soundfile").info(raw).duration, 3)
|
| 265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
if progress:
|
| 267 |
progress(.02, desc="Проверяю и декодирую напев")
|
| 268 |
pcm48, pcm24, duration = decode(uploaded, folder, log)
|
|
@@ -272,7 +318,6 @@ def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int
|
|
| 272 |
if progress:
|
| 273 |
progress(.30, desc="Синтезирую инструмент и барабаны")
|
| 274 |
melody_raw, melody_midi = render_instrument(midi, folder, instrument, duration, log)
|
| 275 |
-
drums_midi = None
|
| 276 |
try:
|
| 277 |
drums_raw, drums_midi, drum_count = render_drums(midi, beat_lab, folder,
|
| 278 |
duration, log)
|
|
@@ -289,13 +334,14 @@ def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int
|
|
| 289 |
deliver("contour", render_contour(pcm48, folder))
|
| 290 |
except (ValueError, RuntimeError, OSError) as error:
|
| 291 |
errors["contour"] = str(error)
|
|
|
|
|
|
|
|
|
|
| 292 |
if progress:
|
| 293 |
progress(.50, desc="YuE2 создаёт сразу три варианта; это займёт несколько минут")
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
errors["yue_batch"] = str(error)
|
| 298 |
-
for name in VARIANTS[3:]:
|
| 299 |
raw = folder / name / "raw.wav"
|
| 300 |
if raw.is_file():
|
| 301 |
try:
|
|
@@ -304,16 +350,21 @@ def generate_all(source: str, instrument: str, styles: dict[str, str], seed: int
|
|
| 304 |
errors[name] = str(error)
|
| 305 |
else:
|
| 306 |
detail = folder / name / "error.txt"
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
import subprocess
|
| 10 |
import tempfile
|
| 11 |
import time
|
| 12 |
+
from typing import Iterator
|
| 13 |
|
| 14 |
from logic import INSTRUMENTS, drum_hits, instrumental_abc, validate_duration
|
| 15 |
|
|
|
|
| 200 |
|
| 201 |
|
| 202 |
def render_yue_batch(melody_abc: Path, full_abc: Path, folder: Path,
|
| 203 |
+
styles: dict[str, str], seed: int, log: Path) -> Iterator[str]:
|
| 204 |
if not 0 <= seed < 2**31:
|
| 205 |
raise ValueError("Seed должен быть целым числом от 0 до 2³¹−1")
|
| 206 |
for style in styles.values():
|
|
|
|
| 220 |
]}
|
| 221 |
request_path = folder / "yue-requests.json"
|
| 222 |
request_path.write_text(json.dumps(request, ensure_ascii=False, indent=2) + "\n")
|
| 223 |
+
pending = {run["name"] for run in request["runs"]}
|
| 224 |
+
with log.open("a") as stream:
|
| 225 |
+
process = subprocess.Popen([str(YUE_PYTHON), str(ROOT / "yue_worker.py"),
|
| 226 |
+
str(request_path), str(folder)],
|
| 227 |
+
stdout=stream, stderr=subprocess.STDOUT)
|
| 228 |
+
deadline = time.monotonic() + 1800
|
| 229 |
+
try:
|
| 230 |
+
while process.poll() is None:
|
| 231 |
+
for name in tuple(pending):
|
| 232 |
+
target = folder / name
|
| 233 |
+
if (target / "yue.json").is_file() or (target / "error.txt").is_file():
|
| 234 |
+
pending.remove(name)
|
| 235 |
+
yield name
|
| 236 |
+
if time.monotonic() > deadline:
|
| 237 |
+
process.kill()
|
| 238 |
+
process.wait()
|
| 239 |
+
raise TimeoutError("YuE2 превысил лимит 30 минут")
|
| 240 |
+
time.sleep(1)
|
| 241 |
+
for name in tuple(pending):
|
| 242 |
+
target = folder / name
|
| 243 |
+
if (target / "yue.json").is_file() or (target / "error.txt").is_file():
|
| 244 |
+
yield name
|
| 245 |
+
if process.returncode:
|
| 246 |
+
tail = "\n".join(log.read_text(errors="replace").splitlines()[-18:])
|
| 247 |
+
raise RuntimeError(f"YuE2 завершился с кодом {process.returncode}:\n{tail}")
|
| 248 |
+
finally:
|
| 249 |
+
if process.poll() is None:
|
| 250 |
+
process.terminate()
|
| 251 |
+
try:
|
| 252 |
+
process.wait(timeout=5)
|
| 253 |
+
except subprocess.TimeoutExpired:
|
| 254 |
+
process.kill()
|
| 255 |
+
process.wait()
|
| 256 |
|
| 257 |
|
| 258 |
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("Загрузите или запишите напев и выберите инструмент")
|
| 283 |
folder = Path(tempfile.mkdtemp(prefix="hum-"))
|
|
|
|
| 288 |
outputs = {name: None for name in VARIANTS}
|
| 289 |
errors = {}
|
| 290 |
durations = {}
|
| 291 |
+
drum_count = 0
|
| 292 |
+
drums_midi = None
|
| 293 |
|
| 294 |
def deliver(name: str, raw: Path, *, normalize: bool = True) -> None:
|
| 295 |
destination = folder / f"{name}.mp3"
|
|
|
|
| 297 |
outputs[name] = str(destination)
|
| 298 |
durations[name] = round(__import__("soundfile").info(raw).duration, 3)
|
| 299 |
|
| 300 |
+
def snapshot(*, complete: bool = False) -> tuple:
|
| 301 |
+
metadata = {"instrument": instrument, "source_sha256": digest,
|
| 302 |
+
"source_seconds": round(duration, 3),
|
| 303 |
+
"result_seconds": dict(durations), "drum_hits": drum_count,
|
| 304 |
+
"errors": dict(errors), "complete": complete,
|
| 305 |
+
"source_timing_preserved": ["instrument", "instrument_drums", "contour"],
|
| 306 |
+
"yue_vocal_absence_guaranteed": False,
|
| 307 |
+
"elapsed_seconds": round(time.monotonic() - began, 2)}
|
| 308 |
+
return (*(outputs[name] for name in VARIANTS), str(melody_midi),
|
| 309 |
+
str(drums_midi) if drums_midi else None, str(beat_lab),
|
| 310 |
+
str(folder / "instrumental-melody.abc"), metadata)
|
| 311 |
+
|
| 312 |
if progress:
|
| 313 |
progress(.02, desc="Проверяю и декодирую напев")
|
| 314 |
pcm48, pcm24, duration = decode(uploaded, folder, log)
|
|
|
|
| 318 |
if progress:
|
| 319 |
progress(.30, desc="Синтезирую инструмент и барабаны")
|
| 320 |
melody_raw, melody_midi = render_instrument(midi, folder, instrument, duration, log)
|
|
|
|
| 321 |
try:
|
| 322 |
drums_raw, drums_midi, drum_count = render_drums(midi, beat_lab, folder,
|
| 323 |
duration, log)
|
|
|
|
| 334 |
deliver("contour", render_contour(pcm48, folder))
|
| 335 |
except (ValueError, RuntimeError, OSError) as error:
|
| 336 |
errors["contour"] = str(error)
|
| 337 |
+
(folder / "instrumental-melody.abc").write_text(
|
| 338 |
+
instrumental_abc(melody_abc.read_text()))
|
| 339 |
+
yield snapshot()
|
| 340 |
if progress:
|
| 341 |
progress(.50, desc="YuE2 создаёт сразу три варианта; это займёт несколько минут")
|
| 342 |
+
completed = set()
|
| 343 |
+
|
| 344 |
+
def accept_yue(name: str) -> None:
|
|
|
|
|
|
|
| 345 |
raw = folder / name / "raw.wav"
|
| 346 |
if raw.is_file():
|
| 347 |
try:
|
|
|
|
| 350 |
errors[name] = str(error)
|
| 351 |
else:
|
| 352 |
detail = folder / name / "error.txt"
|
| 353 |
+
errors[name] = (detail.read_text(errors="replace").splitlines()[-1]
|
| 354 |
+
if detail.is_file() else "YuE2 не вернул файл")
|
| 355 |
+
completed.add(name)
|
| 356 |
+
|
| 357 |
+
try:
|
| 358 |
+
for name in render_yue_batch(melody_abc, full_abc, folder, styles, int(seed), log):
|
| 359 |
+
accept_yue(name)
|
| 360 |
+
if progress:
|
| 361 |
+
progress(.50 + .15 * len(completed), desc=f"YuE2: {len(completed)} из 3 готовы")
|
| 362 |
+
yield snapshot()
|
| 363 |
+
except (ValueError, RuntimeError, OSError) as error:
|
| 364 |
+
errors["yue_batch"] = str(error)
|
| 365 |
+
for name in VARIANTS[3:]:
|
| 366 |
+
if name not in completed:
|
| 367 |
+
accept_yue(name)
|
| 368 |
+
final = snapshot(complete=True)
|
| 369 |
+
(folder / "result.json").write_text(json.dumps(final[-1], ensure_ascii=False, indent=2) + "\n")
|
| 370 |
+
yield final
|
test_logic.py
CHANGED
|
@@ -66,6 +66,14 @@ class LogicTest(unittest.TestCase):
|
|
| 66 |
with self.assertRaises(ValueError):
|
| 67 |
drum_hits(beats, [(0.1, 1.1)], 2.0)
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
if __name__ == "__main__":
|
| 71 |
unittest.main()
|
|
|
|
| 66 |
with self.assertRaises(ValueError):
|
| 67 |
drum_hits(beats, [(0.1, 1.1)], 2.0)
|
| 68 |
|
| 69 |
+
def test_drums_ignore_duplicate_zero_anchor_before_first_downbeat(self):
|
| 70 |
+
# Seen in the source-time SheetSage2 beat.lab for input-001.
|
| 71 |
+
beats = ("0\t1\t4\t4\n.44\t1\t4\t4\n.92\t2\t4\t4\n"
|
| 72 |
+
"1.44\t3\t4\t4\n1.92\t4\t4\t4\n2.44\t1\t4\t4\n")
|
| 73 |
+
hits = drum_hits(beats, [(.22, 2.5)], 2.6)
|
| 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()
|