batoon commited on
Commit
9249c0d
·
verified ·
1 Parent(s): 6b1f7f2

Stream previews and remove duplicate initial drum beat

Browse files
Files changed (5) hide show
  1. README.md +2 -1
  2. app.py +1 -1
  3. logic.py +5 -0
  4. pipeline.py +74 -23
  5. 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
- return generate_all(audio, instrument, styles, int(seed), progress)
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) -> None:
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
- command([str(YUE_PYTHON), str(ROOT / "yue_worker.py"),
223
- str(request_path), str(folder)], log, timeout=1800)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- try:
295
- render_yue_batch(melody_abc, full_abc, folder, styles, int(seed), log)
296
- except (ValueError, RuntimeError, OSError) as error:
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
- if detail.is_file():
308
- errors[name] = detail.read_text(errors="replace").splitlines()[-1]
309
- metadata = {"instrument": instrument, "source_sha256": digest,
310
- "source_seconds": round(duration, 3), "result_seconds": durations,
311
- "drum_hits": drum_count, "errors": errors,
312
- "source_timing_preserved": ["instrument", "instrument_drums", "contour"],
313
- "yue_vocal_absence_guaranteed": False,
314
- "elapsed_seconds": round(time.monotonic() - began, 2)}
315
- (folder / "result.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n")
316
- return (*(outputs[name] for name in VARIANTS), str(melody_midi),
317
- str(drums_midi) if drums_midi else None, str(beat_lab),
318
- str(folder / "instrumental-melody.abc") if (folder / "instrumental-melody.abc").is_file() else None,
319
- metadata)
 
 
 
 
 
 
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()