pachet commited on
Commit
62b65ae
·
1 Parent(s): 7e3364f

Add generated score SVG fallback

Browse files
Files changed (1) hide show
  1. apps/theme_lab/app.py +95 -2
apps/theme_lab/app.py CHANGED
@@ -3,6 +3,7 @@
3
  from __future__ import annotations
4
 
5
  import argparse
 
6
  import os
7
  import pickle
8
  import random
@@ -46,7 +47,7 @@ MARKOV_CACHE_DIR = ROOT / "models" / "theme_lab_markov_cache"
46
  DEFAULT_MARKOV_CACHE = MARKOV_CACHE_DIR / "default.pkl"
47
  MARKOV_CACHE_FORMAT_VERSION = 1
48
  MARKOV_PRECOMPUTED_WARM_SAMPLES = 4
49
- SCORE_PREVIEW_RENDER_VERSION = "strip-ties-beams-v1"
50
  GENERATED_ROOT.mkdir(parents=True, exist_ok=True)
51
  CATALOG_SCORE_ROOT.mkdir(parents=True, exist_ok=True)
52
 
@@ -371,6 +372,95 @@ def sequence_note_events(sequence: tuple[Symbol, ...], key_name: str) -> list[di
371
  return events
372
 
373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  def has_verovio_renderer() -> bool:
375
  if shutil.which("verovio") is not None:
376
  return True
@@ -721,14 +811,17 @@ def generate(request: GenerateRequest) -> dict[str, object]:
721
  for index, sequence in enumerate(generated, start=1):
722
  stem = f"generated_{index:02d}"
723
  musicxml_path = output_dir / f"{stem}.musicxml"
 
724
  svg_path = render_musicxml_to_svg(musicxml_path)
 
 
725
  samples.append(
726
  {
727
  "index": index,
728
  "title": f"{request.engine} sample {index:02d}",
729
  "relative_pcs": [symbol.rpc for symbol in sequence],
730
  "durations": [symbol.duration for symbol in sequence],
731
- "notes": sequence_note_events(sequence, request.key),
732
  "midi_url": f"/generated/{run_id}/{stem}.mid",
733
  "abc_url": f"/generated/{run_id}/{stem}.abc",
734
  "musicxml_url": f"/generated/{run_id}/{stem}.musicxml",
 
3
  from __future__ import annotations
4
 
5
  import argparse
6
+ from html import escape
7
  import os
8
  import pickle
9
  import random
 
47
  DEFAULT_MARKOV_CACHE = MARKOV_CACHE_DIR / "default.pkl"
48
  MARKOV_CACHE_FORMAT_VERSION = 1
49
  MARKOV_PRECOMPUTED_WARM_SAMPLES = 4
50
+ SCORE_PREVIEW_RENDER_VERSION = "strip-ties-beams-simple-svg-v2"
51
  GENERATED_ROOT.mkdir(parents=True, exist_ok=True)
52
  CATALOG_SCORE_ROOT.mkdir(parents=True, exist_ok=True)
53
 
 
372
  return events
373
 
374
 
375
+ def write_simple_score_svg(notes: list[dict[str, object]], svg_path: Path, title: str) -> Path | None:
376
+ if not notes:
377
+ return None
378
+
379
+ total_beats = max(float(note["start_beat"]) + float(note["duration_beats"]) for note in notes)
380
+ width = max(980, int(160 + total_beats * 58))
381
+ height = 260
382
+ left = 72
383
+ right = 42
384
+ staff_top = 92
385
+ line_gap = 12
386
+ staff_bottom = staff_top + line_gap * 4
387
+ pitch_step = 3.5
388
+ x_scale = (width - left - right) / max(total_beats, 1.0)
389
+
390
+ def note_x(note: dict[str, object]) -> float:
391
+ return left + float(note["start_beat"]) * x_scale
392
+
393
+ def note_y(pitch: int) -> float:
394
+ return staff_bottom - (pitch - 64) * pitch_step
395
+
396
+ def duration_to_fill(duration: float) -> str:
397
+ return "#fff" if duration >= 2.0 else "#000"
398
+
399
+ def duration_to_stem(duration: float) -> bool:
400
+ return duration < 4.0
401
+
402
+ elements = [
403
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">',
404
+ '<rect width="100%" height="100%" fill="#fff"/>',
405
+ f'<text x="{width / 2:.1f}" y="42" text-anchor="middle" font-family="Georgia, serif" font-size="26">{escape(title)}</text>',
406
+ f'<text x="22" y="{staff_top + 30}" font-family="Georgia, serif" font-size="48">&#119070;</text>',
407
+ f'<text x="{left - 20}" y="{staff_top + 33}" font-family="Georgia, serif" font-size="27" font-weight="700">4</text>',
408
+ f'<text x="{left - 20}" y="{staff_top + 58}" font-family="Georgia, serif" font-size="27" font-weight="700">4</text>',
409
+ ]
410
+ for line in range(5):
411
+ y = staff_top + line * line_gap
412
+ elements.append(f'<line x1="18" x2="{width - 24}" y1="{y}" y2="{y}" stroke="#000" stroke-width="1"/>')
413
+
414
+ measure_beat = 4.0
415
+ bar = measure_beat
416
+ while bar < total_beats:
417
+ x = left + bar * x_scale
418
+ elements.append(f'<line x1="{x:.1f}" x2="{x:.1f}" y1="{staff_top}" y2="{staff_bottom}" stroke="#000" stroke-width="1.3"/>')
419
+ bar += measure_beat
420
+
421
+ for note in notes:
422
+ pitch = int(note["pitch"])
423
+ duration = float(note["duration_beats"])
424
+ x = note_x(note)
425
+ y = note_y(pitch)
426
+ while y < staff_top - line_gap:
427
+ elements.append(
428
+ f'<line x1="{x - 12:.1f}" x2="{x + 12:.1f}" y1="{y:.1f}" y2="{y:.1f}" stroke="#000" stroke-width="1"/>'
429
+ )
430
+ y += line_gap
431
+ y = note_y(pitch)
432
+ while y > staff_bottom + line_gap:
433
+ elements.append(
434
+ f'<line x1="{x - 12:.1f}" x2="{x + 12:.1f}" y1="{y:.1f}" y2="{y:.1f}" stroke="#000" stroke-width="1"/>'
435
+ )
436
+ y -= line_gap
437
+ y = note_y(pitch)
438
+ accidental = {1: "#", 3: "#", 6: "#", 8: "#", 10: "#"}.get(pitch % 12)
439
+ if accidental:
440
+ elements.append(
441
+ f'<text x="{x - 22:.1f}" y="{y + 5:.1f}" font-family="Georgia, serif" font-size="18">{accidental}</text>'
442
+ )
443
+ elements.append(
444
+ f'<ellipse cx="{x:.1f}" cy="{y:.1f}" rx="8" ry="5.5" fill="{duration_to_fill(duration)}" stroke="#000" stroke-width="1.6" transform="rotate(-18 {x:.1f} {y:.1f})"/>'
445
+ )
446
+ if duration_to_stem(duration):
447
+ stem_x = x + 7
448
+ stem_y = y - 42
449
+ elements.append(f'<line x1="{stem_x:.1f}" x2="{stem_x:.1f}" y1="{y:.1f}" y2="{stem_y:.1f}" stroke="#000" stroke-width="1.6"/>')
450
+ if duration <= 0.5:
451
+ elements.append(
452
+ f'<path d="M {stem_x:.1f} {stem_y:.1f} q 18 8 6 24" fill="none" stroke="#000" stroke-width="1.5"/>'
453
+ )
454
+
455
+ final_x = left + total_beats * x_scale + 14
456
+ elements.append(f'<line x1="{final_x:.1f}" x2="{final_x:.1f}" y1="{staff_top}" y2="{staff_bottom}" stroke="#000" stroke-width="1.3"/>')
457
+ elements.append(f'<line x1="{final_x + 5:.1f}" x2="{final_x + 5:.1f}" y1="{staff_top}" y2="{staff_bottom}" stroke="#000" stroke-width="3"/>')
458
+ elements.append("</svg>")
459
+
460
+ svg_path.write_text("\n".join(elements), encoding="utf-8")
461
+ return svg_path if svg_path.exists() else None
462
+
463
+
464
  def has_verovio_renderer() -> bool:
465
  if shutil.which("verovio") is not None:
466
  return True
 
811
  for index, sequence in enumerate(generated, start=1):
812
  stem = f"generated_{index:02d}"
813
  musicxml_path = output_dir / f"{stem}.musicxml"
814
+ notes = sequence_note_events(sequence, request.key)
815
  svg_path = render_musicxml_to_svg(musicxml_path)
816
+ if svg_path is None:
817
+ svg_path = write_simple_score_svg(notes, musicxml_path.with_suffix(".svg"), f"{request.engine} sample {index:02d}")
818
  samples.append(
819
  {
820
  "index": index,
821
  "title": f"{request.engine} sample {index:02d}",
822
  "relative_pcs": [symbol.rpc for symbol in sequence],
823
  "durations": [symbol.duration for symbol in sequence],
824
+ "notes": notes,
825
  "midi_url": f"/generated/{run_id}/{stem}.mid",
826
  "abc_url": f"/generated/{run_id}/{stem}.abc",
827
  "musicxml_url": f"/generated/{run_id}/{stem}.musicxml",