Coconuttttt Claude Opus 4.6 commited on
Commit
7f886e6
Β·
1 Parent(s): 64683b5

Remove Gradio, replace with pure FastAPI + static HTML

Browse files

- New app.py: pure FastAPI server with all existing endpoints preserved
- New static/index.html: upload/convert/MML playback page
- Corrector now served as full page at /corrector (no more iframe)
- corrector.js: auto-loads from /api/session-data on page load
- corrector.html: CSS/JS paths updated for /static/ mount
- Dockerfile: app_gradio.py β†’ app.py, removed Gradio env vars
- requirements: gradio β†’ fastapi

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Dockerfile CHANGED
@@ -50,7 +50,7 @@ RUN pip install --no-cache-dir -r /tmp/requirements.txt
50
  WORKDIR /app
51
  COPY core/ ./core/
52
  COPY static/ ./static/
53
- COPY app_gradio.py .
54
  COPY convert_3part.py .
55
  COPY omr_parser.py .
56
 
@@ -59,9 +59,8 @@ ENV AUDIVERIS_BIN=/opt/audiveris/bin/Audiveris
59
  ENV AUDIVERIS_MAX_HEAP=1500m
60
  ENV JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
61
  ENV MAX_PDF_PAGES=5
62
- ENV GRADIO_SERVER_NAME=0.0.0.0
63
- ENV GRADIO_SERVER_PORT=7860
64
 
65
  EXPOSE 7860
66
 
67
- CMD ["python", "app_gradio.py", "--port", "7860"]
 
 
50
  WORKDIR /app
51
  COPY core/ ./core/
52
  COPY static/ ./static/
53
+ COPY app.py .
54
  COPY convert_3part.py .
55
  COPY omr_parser.py .
56
 
 
59
  ENV AUDIVERIS_MAX_HEAP=1500m
60
  ENV JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
61
  ENV MAX_PDF_PAGES=5
 
 
62
 
63
  EXPOSE 7860
64
 
65
+
66
+ CMD ["python", "app.py", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,614 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py
3
+
4
+ Pure FastAPI server β€” 악보(PDF/PNG/JPG) μ—…λ‘œλ“œ β†’ MML λ³€ν™˜ + OMR ꡐ정도ꡬ.
5
+ Gradio μ˜μ‘΄μ„± μ—†μŒ.
6
+
7
+ μ‹€ν–‰:
8
+ python app.py --port 7860
9
+ """
10
+
11
+ import argparse
12
+ import datetime
13
+ import json
14
+ import logging
15
+ import os
16
+ import re
17
+ import shutil
18
+ import tempfile
19
+ import threading
20
+ from pathlib import Path
21
+ from typing import List
22
+
23
+ from fastapi import FastAPI, Query, UploadFile, File, Form
24
+ from fastapi.staticfiles import StaticFiles
25
+ from fastapi.responses import FileResponse, PlainTextResponse, JSONResponse
26
+
27
+ from core.convert_pipeline import run_score_pipeline
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ SUPPORTED_EXTENSIONS = [".pdf", ".png", ".jpg", ".jpeg"]
32
+ MAX_PDF_PAGES = int(os.environ.get("MAX_PDF_PAGES", "5"))
33
+ _SEP = "-" * 40
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Persistent stats via Hugging Face Dataset repo
37
+ # ---------------------------------------------------------------------------
38
+ _STATS_REPO = os.environ.get("STATS_REPO", "Coconuttttt/score-to-mml-stats")
39
+ _STATS_FILE = "stats.json"
40
+ _stats_lock = threading.Lock()
41
+
42
+ _active_lock = threading.Lock()
43
+ _active_count = 0
44
+
45
+
46
+ def _hf_api():
47
+ try:
48
+ from huggingface_hub import HfApi
49
+ token = os.environ.get("HF_TOKEN")
50
+ return HfApi(token=token) if token else None
51
+ except ImportError:
52
+ return None
53
+
54
+
55
+ def _load_stats() -> dict:
56
+ api = _hf_api()
57
+ if not api:
58
+ return {}
59
+ try:
60
+ from huggingface_hub import hf_hub_download
61
+ token = os.environ.get("HF_TOKEN")
62
+ path = hf_hub_download(
63
+ repo_id=_STATS_REPO, filename=_STATS_FILE,
64
+ repo_type="dataset", token=token,
65
+ force_download=True,
66
+ )
67
+ with open(path, encoding="utf-8") as f:
68
+ return json.load(f)
69
+ except Exception:
70
+ return {}
71
+
72
+
73
+ def _save_stats(data: dict):
74
+ api = _hf_api()
75
+ if not api:
76
+ return
77
+ try:
78
+ tmp = Path(tempfile.gettempdir()) / _STATS_FILE
79
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
80
+ api.upload_file(
81
+ path_or_fileobj=str(tmp),
82
+ path_in_repo=_STATS_FILE,
83
+ repo_id=_STATS_REPO,
84
+ repo_type="dataset",
85
+ )
86
+ except Exception as e:
87
+ logger.warning("stats save failed: %s", e)
88
+
89
+
90
+ def _bump_stat(key: str, extra: dict | None = None):
91
+ now = datetime.datetime.now()
92
+ today = now.strftime("%Y-%m-%d")
93
+ hour = now.strftime("%H")
94
+ with _stats_lock:
95
+ data = _load_stats()
96
+ if today not in data:
97
+ data[today] = {"visits": 0, "conversions": 0, "errors": 0, "hourly": {}}
98
+ if "hourly" not in data[today]:
99
+ data[today]["hourly"] = {}
100
+ if "errors" not in data[today]:
101
+ data[today]["errors"] = 0
102
+ data[today][key] = data[today].get(key, 0) + 1
103
+ if hour not in data[today]["hourly"]:
104
+ data[today]["hourly"][hour] = {"visits": 0, "conversions": 0, "errors": 0}
105
+ data[today]["hourly"][hour][key] = data[today]["hourly"][hour].get(key, 0) + 1
106
+ with _active_lock:
107
+ cur = _active_count
108
+ peak = data[today].get("peak_concurrent", 0)
109
+ if cur > peak:
110
+ data[today]["peak_concurrent"] = cur
111
+ if extra:
112
+ if "recent_errors" not in data[today]:
113
+ data[today]["recent_errors"] = []
114
+ data[today]["recent_errors"].append(extra)
115
+ data[today]["recent_errors"] = data[today]["recent_errors"][-10:]
116
+ _save_stats(data)
117
+
118
+
119
+ def _record_visit():
120
+ threading.Thread(target=_bump_stat, args=("visits",), daemon=True).start()
121
+
122
+
123
+ def _record_conversion():
124
+ threading.Thread(target=_bump_stat, args=("conversions",), daemon=True).start()
125
+
126
+
127
+ def _record_error(error_msg: str):
128
+ now = datetime.datetime.now().strftime("%H:%M:%S")
129
+ short = str(error_msg)[:200]
130
+ extra = {"time": now, "msg": short}
131
+ threading.Thread(target=_bump_stat, args=("errors", extra), daemon=True).start()
132
+
133
+
134
+ def _get_dashboard() -> str:
135
+ with _active_lock:
136
+ active = _active_count
137
+ data = _load_stats()
138
+ today = datetime.date.today().isoformat()
139
+ lines = []
140
+ lines.append(f"=== μ‹€μ‹œκ°„ ===")
141
+ lines.append(f"ν˜„μž¬ λ³€ν™˜ 쀑: {active}건")
142
+ lines.append("")
143
+ td = data.get(today, {})
144
+ lines.append(f"=== 였늘 ({today}) ===")
145
+ lines.append(f"λ°©λ¬Έ: {td.get('visits', 0)} | λ³€ν™˜: {td.get('conversions', 0)} | μ—λŸ¬: {td.get('errors', 0)} | 피크 λ™μ‹œ: {td.get('peak_concurrent', 0)}")
146
+ hourly = td.get("hourly", {})
147
+ if hourly:
148
+ lines.append("")
149
+ lines.append("μ‹œκ°„λŒ€λ³„:")
150
+ for h in sorted(hourly.keys()):
151
+ hd = hourly[h]
152
+ err_str = f" μ—λŸ¬: {hd.get('errors',0):>3}" if hd.get('errors', 0) else ""
153
+ lines.append(f" {h}μ‹œ λ°©λ¬Έ: {hd.get('visits',0):>3} λ³€ν™˜: {hd.get('conversions',0):>3}{err_str}")
154
+ recent_errs = td.get("recent_errors", [])
155
+ if recent_errs:
156
+ lines.append("")
157
+ lines.append("졜근 μ—λŸ¬:")
158
+ for e in recent_errs[-5:]:
159
+ lines.append(f" [{e.get('time','')}] {e.get('msg','')[:100]}")
160
+ lines.append("")
161
+ lines.append("=== 졜근 7일 ===")
162
+ lines.append(f"{'λ‚ μ§œ':<12} {'λ°©λ¬Έ':>5} {'λ³€ν™˜':>5} {'μ—λŸ¬':>4} {'피크':>4}")
163
+ lines.append("-" * 38)
164
+ dates = sorted(data.keys(), reverse=True)[:7]
165
+ total_v, total_c, total_e = 0, 0, 0
166
+ for d in dates:
167
+ dd = data[d]
168
+ v = dd.get("visits", 0)
169
+ c = dd.get("conversions", 0)
170
+ e = dd.get("errors", 0)
171
+ p = dd.get("peak_concurrent", 0)
172
+ total_v += v
173
+ total_c += c
174
+ total_e += e
175
+ lines.append(f"{d:<12} {v:>5} {c:>5} {e:>4} {p:>4}")
176
+ lines.append("-" * 38)
177
+ lines.append(f"{'합계':<12} {total_v:>5} {total_c:>5} {total_e:>4}")
178
+ return "\n".join(lines)
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # MML Helpers
183
+ # ---------------------------------------------------------------------------
184
+ _PREPROCESS_MAP = {"none": "none", "otsu": "otsu", "adaptive": "adaptive", "contrast": "contrast"}
185
+ _UPSCALE_MAP = {"none": "none", "pil_2": "pil_2", "pil_3": "pil_3"}
186
+ _MABI_CHAR_LIMIT = 1500
187
+
188
+
189
+ def _extract_mml_parts(text: str) -> List[str]:
190
+ return re.findall(r'MML@([^;]*);', text, re.IGNORECASE)
191
+
192
+
193
+ def _to_mabi_tracks(text: str) -> List[str]:
194
+ parts = _extract_mml_parts(text)
195
+ if not parts:
196
+ return []
197
+ tracks = []
198
+ for i in range(0, len(parts), 3):
199
+ chunk = parts[i:i + 3]
200
+ while len(chunk) < 3:
201
+ chunk.append("")
202
+ tracks.append("MML@" + ",".join(chunk) + ";")
203
+ return tracks
204
+
205
+
206
+ def _make_track_files(text: str, prefix: str, ts: str, tmpdir: Path) -> List[str]:
207
+ tracks = _to_mabi_tracks(text)
208
+ if not tracks:
209
+ return []
210
+ paths = []
211
+ for i, track in enumerate(tracks, 1):
212
+ p = tmpdir / f"{prefix}_track{i}_{ts}.mml"
213
+ p.write_text(track, encoding="utf-8")
214
+ paths.append(str(p))
215
+ return paths
216
+
217
+
218
+ def _mml_char_info(text: str, label: str) -> str:
219
+ parts = _extract_mml_parts(text)
220
+ if not parts:
221
+ return ""
222
+ lines = [f"[{label} κΈ€μžμˆ˜]"]
223
+ for i, p in enumerate(parts, 1):
224
+ full = f"MML@{p};"
225
+ n = len(full)
226
+ warn = f" *** {_MABI_CHAR_LIMIT}자 초과!" if n > _MABI_CHAR_LIMIT else ""
227
+ lines.append(f" Part {i}: {n}자{warn}")
228
+ return "\n".join(lines)
229
+
230
+
231
+ def _check_pdf_pages(file_path: str) -> str | None:
232
+ if Path(file_path).suffix.lower() != ".pdf":
233
+ return None
234
+ try:
235
+ import fitz
236
+ doc = fitz.open(file_path)
237
+ count = doc.page_count
238
+ doc.close()
239
+ if count > MAX_PDF_PAGES:
240
+ return f"PDFκ°€ {count}νŽ˜μ΄μ§€μž…λ‹ˆλ‹€. μ΅œλŒ€ {MAX_PDF_PAGES}νŽ˜μ΄μ§€κΉŒμ§€ μ§€μ›ν•©λ‹ˆλ‹€."
241
+ except Exception:
242
+ pass
243
+ return None
244
+
245
+
246
+ def _apply_tempo(whole_text: str, three_text: str, tempo: int):
247
+ tempo = max(32, min(255, int(tempo)))
248
+
249
+ def replace_tempo(text):
250
+ if not text:
251
+ return text
252
+ return re.sub(r'(MML@)(T\d+)?', rf'\1T{tempo}', text, flags=re.IGNORECASE)
253
+
254
+ new_whole = replace_tempo(whole_text)
255
+ new_three = replace_tempo(three_text)
256
+
257
+ ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
258
+ tmpdir = Path(tempfile.gettempdir())
259
+
260
+ whole_files = _make_track_files(new_whole, f"tempo{tempo}_whole", ts, tmpdir) if new_whole else []
261
+ three_files = _make_track_files(new_three, f"tempo{tempo}_3part", ts, tmpdir) if new_three else []
262
+
263
+ return new_whole, new_three, whole_files, three_files
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Session (single-user HF Space β€” no UUID needed)
268
+ # ---------------------------------------------------------------------------
269
+ _session_lock = threading.Lock()
270
+ _SESSION: dict = {}
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # Conversion (adapted from app_gradio.py convert())
275
+ # ---------------------------------------------------------------------------
276
+ def _do_convert(file_path: str, preprocess: str, dpi: int, upscale: str,
277
+ page_start: int, page_end: int) -> dict:
278
+ global _active_count
279
+
280
+ ext = Path(file_path).suffix.lower()
281
+ if ext not in SUPPORTED_EXTENSIONS:
282
+ raise ValueError(f"μ§€μ›ν•˜μ§€ μ•ŠλŠ” 파일 ν˜•μ‹μž…λ‹ˆλ‹€: {ext}")
283
+
284
+ page_err = _check_pdf_pages(file_path)
285
+ if page_err and page_start == 0 and page_end == 0:
286
+ raise ValueError(page_err)
287
+
288
+ work_dir = tempfile.mkdtemp(prefix="sml_ui_")
289
+
290
+ with _active_lock:
291
+ _active_count += 1
292
+ try:
293
+ combined, mxl_paths, xml_paths, warnings = run_score_pipeline(
294
+ input_path=file_path,
295
+ preprocess_mode=_PREPROCESS_MAP.get(preprocess, "none"),
296
+ dpi=dpi,
297
+ upscale_mode=_UPSCALE_MAP.get(upscale, "none"),
298
+ correct_xml=False,
299
+ save_dir=work_dir,
300
+ page_start=page_start,
301
+ page_end=page_end,
302
+ )
303
+ except Exception as e:
304
+ _record_error(str(e))
305
+ raise
306
+ finally:
307
+ with _active_lock:
308
+ _active_count -= 1
309
+
310
+ # Page preview images
311
+ pages_dir = Path(work_dir) / "pages"
312
+ preview_images = sorted(pages_dir.glob("*.png")) if pages_dir.exists() else []
313
+ pre_imgs = [p for p in preview_images if "_pre" in p.name or "_norm" in p.name]
314
+ if not pre_imgs:
315
+ pre_imgs = [p for p in preview_images if not p.name.endswith(("_norm.png",))]
316
+ preview_paths = [str(p) for p in (pre_imgs if pre_imgs else preview_images)]
317
+
318
+ # Split MML
319
+ if _SEP in combined:
320
+ parts = combined.split(_SEP, 1)
321
+ whole_text = parts[0].strip()
322
+ three_text = parts[1].strip()
323
+ else:
324
+ whole_text = combined.strip()
325
+ three_text = ""
326
+
327
+ # Generate track files
328
+ stem = Path(file_path).stem
329
+ ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
330
+ tmpdir = Path(tempfile.gettempdir())
331
+ whole_files = _make_track_files(whole_text, f"{stem}_whole", ts, tmpdir)
332
+ three_files = _make_track_files(three_text, f"{stem}_3part", ts, tmpdir) if three_text else []
333
+
334
+ _record_conversion()
335
+
336
+ # Warnings + char info
337
+ info_parts = []
338
+ if warnings:
339
+ info_parts.append("\n".join(f"[WARN] {w}" for w in warnings))
340
+ else:
341
+ info_parts.append("κ²½κ³  μ—†μŒ")
342
+ whole_info = _mml_char_info(whole_text, "Whole Part")
343
+ three_info = _mml_char_info(three_text, "3 Part")
344
+ if whole_info:
345
+ info_parts.append(whole_info)
346
+ if three_info:
347
+ info_parts.append(three_info)
348
+ warnings_text = "\n\n".join(info_parts)
349
+
350
+ # Corrector data
351
+ corrector_images = []
352
+ for mxl_p in mxl_paths:
353
+ stem2 = Path(mxl_p).stem
354
+ norm_path = pages_dir / f"{stem2}_norm.png" if pages_dir.exists() else None
355
+ if norm_path and norm_path.exists():
356
+ corrector_images.append(str(norm_path))
357
+ else:
358
+ fallback = pages_dir / f"{stem2}.png" if pages_dir.exists() else None
359
+ if fallback and fallback.exists():
360
+ corrector_images.append(str(fallback))
361
+ elif preview_paths:
362
+ idx = mxl_paths.index(mxl_p)
363
+ if idx < len(preview_paths):
364
+ corrector_images.append(preview_paths[idx])
365
+
366
+ corrector_xmls = []
367
+ for xp in (xml_paths or []):
368
+ try:
369
+ corrector_xmls.append(Path(xp).read_text(encoding="utf-8"))
370
+ except Exception:
371
+ pass
372
+
373
+ corrector_omr_data = []
374
+ corrector_omr_paths = []
375
+ mxl_dir = Path(work_dir) / "mxl" if Path(work_dir).exists() else None
376
+ if mxl_dir and mxl_dir.exists():
377
+ for mxl_p in mxl_paths:
378
+ omr_path = mxl_dir / f"{Path(mxl_p).stem}.omr"
379
+ if omr_path.exists():
380
+ corrector_omr_paths.append(str(omr_path))
381
+ try:
382
+ from omr_parser import parse_omr as _parse_omr
383
+ corrector_omr_data.append(_parse_omr(str(omr_path)))
384
+ except Exception:
385
+ corrector_omr_data.append(None)
386
+ else:
387
+ corrector_omr_paths.append(None)
388
+ corrector_omr_data.append(None)
389
+
390
+ # Build URLs
391
+ image_urls = [f"/corrector-file?path={p}" for p in corrector_images]
392
+ omr_file_urls = [f"/corrector-file?path={p}" if p else None for p in corrector_omr_paths]
393
+ preview_urls = [f"/corrector-file?path={p}" for p in preview_paths]
394
+
395
+ result = {
396
+ "ok": True,
397
+ "whole_text": whole_text,
398
+ "three_text": three_text,
399
+ "whole_files": [f"/corrector-file?path={p}" for p in whole_files],
400
+ "three_files": [f"/corrector-file?path={p}" for p in three_files],
401
+ "mxl_paths": [f"/corrector-file?path={p}" for p in mxl_paths],
402
+ "xml_paths": [f"/corrector-file?path={p}" for p in (xml_paths or [])],
403
+ "preview_urls": preview_urls,
404
+ "warnings_text": warnings_text,
405
+ # Corrector data
406
+ "image_urls": image_urls,
407
+ "xml_texts": corrector_xmls,
408
+ "omr_data_array": corrector_omr_data,
409
+ "omr_file_urls": omr_file_urls,
410
+ }
411
+
412
+ with _session_lock:
413
+ _SESSION["latest"] = result
414
+
415
+ return result
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # FastAPI App
420
+ # ---------------------------------------------------------------------------
421
+ def create_app() -> FastAPI:
422
+ _static_path = str(Path(__file__).resolve().parent / "static")
423
+ _tmp_dir = tempfile.gettempdir()
424
+
425
+ app = FastAPI(title="악보 β†’ MML λ³€ν™˜κΈ°")
426
+
427
+ # --- Pages ---
428
+ @app.get("/")
429
+ async def index_page():
430
+ _record_visit()
431
+ return FileResponse(os.path.join(_static_path, "index.html"), media_type="text/html")
432
+
433
+ @app.get("/corrector")
434
+ async def corrector_page():
435
+ return FileResponse(os.path.join(_static_path, "corrector.html"), media_type="text/html")
436
+
437
+ # --- File serving ---
438
+ def _serve_temp_file(path: str):
439
+ resolved = str(Path(path).resolve())
440
+ if not resolved.startswith(_tmp_dir):
441
+ return PlainTextResponse("Forbidden", status_code=403)
442
+ if not Path(resolved).exists():
443
+ return PlainTextResponse("Not found", status_code=404)
444
+ return FileResponse(resolved)
445
+
446
+ @app.get("/corrector-file")
447
+ async def corrector_file(path: str = Query(...)):
448
+ return _serve_temp_file(path)
449
+
450
+ @app.get("/api/download")
451
+ async def download_file(path: str = Query(...)):
452
+ return _serve_temp_file(path)
453
+
454
+ # --- Convert ---
455
+ @app.post("/api/convert")
456
+ async def api_convert(
457
+ file: UploadFile = File(...),
458
+ preprocess: str = Form("none"),
459
+ dpi: int = Form(300),
460
+ upscale: str = Form("none"),
461
+ page_start: int = Form(0),
462
+ page_end: int = Form(0),
463
+ ):
464
+ tmp_path = None
465
+ try:
466
+ suffix = Path(file.filename or "upload").suffix or ".png"
467
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
468
+ content = await file.read()
469
+ tmp.write(content)
470
+ tmp_path = tmp.name
471
+
472
+ result = _do_convert(tmp_path, preprocess, dpi, upscale, page_start, page_end)
473
+ return JSONResponse(content=result)
474
+
475
+ except ValueError as e:
476
+ return JSONResponse(content={"ok": False, "error": str(e)}, status_code=422)
477
+ except Exception as e:
478
+ return JSONResponse(content={"ok": False, "error": f"λ³€ν™˜ 쀑 였λ₯˜ λ°œμƒ: {e}"}, status_code=500)
479
+ finally:
480
+ if tmp_path and os.path.exists(tmp_path):
481
+ os.unlink(tmp_path)
482
+
483
+ # --- Session data for corrector ---
484
+ @app.get("/api/session-data")
485
+ async def session_data():
486
+ with _session_lock:
487
+ data = _SESSION.get("latest")
488
+ if not data:
489
+ return JSONResponse(content={"error": "No conversion data"}, status_code=404)
490
+ return JSONResponse(content={
491
+ "image_urls": data["image_urls"],
492
+ "xml_texts": data["xml_texts"],
493
+ "omr_data_array": data["omr_data_array"],
494
+ "omr_file_urls": data["omr_file_urls"],
495
+ })
496
+
497
+ # --- Tempo ---
498
+ @app.post("/api/apply-tempo")
499
+ async def api_apply_tempo(
500
+ whole_text: str = Form(""),
501
+ three_text: str = Form(""),
502
+ tempo: int = Form(120),
503
+ ):
504
+ new_whole, new_three, whole_files, three_files = _apply_tempo(whole_text, three_text, tempo)
505
+ return JSONResponse(content={
506
+ "whole_text": new_whole,
507
+ "three_text": new_three,
508
+ "whole_files": [f"/corrector-file?path={p}" for p in whole_files],
509
+ "three_files": [f"/corrector-file?path={p}" for p in three_files],
510
+ })
511
+
512
+ # --- Dashboard ---
513
+ @app.get("/api/dashboard")
514
+ async def dashboard():
515
+ return PlainTextResponse(_get_dashboard())
516
+
517
+ # --- OMR endpoints ---
518
+ @app.post("/api/parse-omr")
519
+ async def parse_omr_endpoint(
520
+ omr_file: UploadFile = File(...),
521
+ sheet_number: int = 1,
522
+ ):
523
+ from omr_parser import parse_omr as _parse_omr
524
+ tmp_path = None
525
+ try:
526
+ with tempfile.NamedTemporaryFile(suffix=".omr", delete=False) as tmp:
527
+ content = await omr_file.read()
528
+ tmp.write(content)
529
+ tmp_path = tmp.name
530
+ result = _parse_omr(tmp_path, sheet_number)
531
+ return JSONResponse(content=result)
532
+ except ValueError as e:
533
+ return JSONResponse(content={"error": str(e)}, status_code=422)
534
+ except Exception as e:
535
+ return JSONResponse(content={"error": f"OMR parsing failed: {e}"}, status_code=500)
536
+ finally:
537
+ if tmp_path and os.path.exists(tmp_path):
538
+ os.unlink(tmp_path)
539
+
540
+ @app.post("/api/omr/apply-edits")
541
+ async def omr_apply_edits(
542
+ omr_file: UploadFile = File(...),
543
+ edits: str = Form(""),
544
+ sheet_number: int = Form(1),
545
+ ):
546
+ import json as _json
547
+ from omr_parser import load_omr, save_omr, apply_edits, parse_omr as _parse_omr
548
+
549
+ try:
550
+ edit_list = _json.loads(edits) if edits else []
551
+ except _json.JSONDecodeError as e:
552
+ return JSONResponse(content={"error": f"Invalid edits JSON: {e}"}, status_code=422)
553
+
554
+ if not edit_list:
555
+ return JSONResponse(content={"error": "No edits provided"}, status_code=422)
556
+
557
+ tmp_path = None
558
+ try:
559
+ with tempfile.NamedTemporaryFile(suffix=".omr", delete=False) as tmp:
560
+ content = await omr_file.read()
561
+ tmp.write(content)
562
+ tmp_path = tmp.name
563
+
564
+ all_files, sheet_root, sheet_key = load_omr(tmp_path, sheet_number)
565
+ edit_results = apply_edits(sheet_root, edit_list)
566
+ save_omr(tmp_path, all_files, sheet_root, sheet_key)
567
+
568
+ xml_text = None
569
+ try:
570
+ from omr_parser import export_omr
571
+ import zipfile as _zf
572
+ mxl_path = export_omr(tmp_path)
573
+ with _zf.ZipFile(mxl_path, "r") as zf:
574
+ for name in zf.namelist():
575
+ if name.endswith(".xml") and not name.startswith("META-INF"):
576
+ xml_text = zf.read(name).decode("utf-8")
577
+ break
578
+ except (FileNotFoundError, RuntimeError):
579
+ pass
580
+
581
+ omr_data = _parse_omr(tmp_path, sheet_number)
582
+
583
+ import base64 as _b64
584
+ with open(tmp_path, "rb") as f:
585
+ omr_b64 = _b64.b64encode(f.read()).decode("ascii")
586
+
587
+ return JSONResponse(content={
588
+ "editResults": edit_results,
589
+ "xml": xml_text,
590
+ "omrData": omr_data,
591
+ "omrFileBase64": omr_b64,
592
+ })
593
+
594
+ except Exception as e:
595
+ return JSONResponse(content={"error": f"OMR edit failed: {e}"}, status_code=500)
596
+ finally:
597
+ if tmp_path and os.path.exists(tmp_path):
598
+ os.unlink(tmp_path)
599
+
600
+ # --- Static files (must be last β€” catch-all) ---
601
+ app.mount("/static", StaticFiles(directory=_static_path), name="static")
602
+
603
+ return app
604
+
605
+
606
+ if __name__ == "__main__":
607
+ print(f"===== Application Startup at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} =====")
608
+ parser = argparse.ArgumentParser()
609
+ parser.add_argument("--port", type=int, default=7860)
610
+ args = parser.parse_args()
611
+
612
+ import uvicorn
613
+ application = create_app()
614
+ uvicorn.run(application, host="0.0.0.0", port=args.port)
requirements-server.txt CHANGED
@@ -1,5 +1,5 @@
1
  # Server dependencies for Hugging Face Spaces deployment
2
- gradio>=4.0.0
3
  uvicorn>=0.20.0
4
  pymupdf>=1.23.0
5
  opencv-python-headless>=4.9.0
 
1
  # Server dependencies for Hugging Face Spaces deployment
2
+ fastapi>=0.100.0
3
  uvicorn>=0.20.0
4
  pymupdf>=1.23.0
5
  opencv-python-headless>=4.9.0
static/corrector.html CHANGED
@@ -4,7 +4,7 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>OMR Corrector</title>
7
- <link rel="stylesheet" href="corrector.css">
8
  </head>
9
  <body>
10
 
@@ -185,6 +185,6 @@
185
  <span id="status-total"></span>
186
  </div>
187
 
188
- <script src="corrector.js"></script>
189
  </body>
190
  </html>
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>OMR Corrector</title>
7
+ <link rel="stylesheet" href="/static/corrector.css">
8
  </head>
9
  <body>
10
 
 
185
  <span id="status-total"></span>
186
  </div>
187
 
188
+ <script src="/static/corrector.js"></script>
189
  </body>
190
  </html>
static/corrector.js CHANGED
@@ -6201,11 +6201,31 @@ loadBtn.addEventListener("click", loadFiles);
6201
  document.getElementById("btn-prev-page").addEventListener("click", prevPage);
6202
  document.getElementById("btn-next-page").addEventListener("click", nextPage);
6203
 
6204
- // Auto-load test set on page load (if no files selected)
6205
- loadFiles().catch(err => {
6206
- console.error("Auto-load error:", err);
6207
- document.getElementById("load-status").textContent = "Auto-load error: " + err.message;
6208
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6209
 
6210
  markerSvg.addEventListener("mouseover", (e) => {
6211
  const circle = e.target.closest("circle.marker");
 
6201
  document.getElementById("btn-prev-page").addEventListener("click", prevPage);
6202
  document.getElementById("btn-next-page").addEventListener("click", nextPage);
6203
 
6204
+ // Try loading from server session first (set by /api/convert), then fallback to loadFiles
6205
+ async function loadFromSession() {
6206
+ try {
6207
+ const resp = await fetch("/api/session-data");
6208
+ if (!resp.ok) return false;
6209
+ const data = await resp.json();
6210
+ if (!data.image_urls?.length || !data.xml_texts?.length) return false;
6211
+ await loadFromData(data.image_urls, data.xml_texts,
6212
+ data.omr_data_array || [], data.omr_file_urls || []);
6213
+ return true;
6214
+ } catch (e) {
6215
+ console.warn("Session data load failed:", e);
6216
+ return false;
6217
+ }
6218
+ }
6219
+
6220
+ (async () => {
6221
+ const loaded = await loadFromSession();
6222
+ if (!loaded) {
6223
+ loadFiles().catch(err => {
6224
+ console.error("Auto-load error:", err);
6225
+ document.getElementById("load-status").textContent = "Auto-load error: " + err.message;
6226
+ });
6227
+ }
6228
+ })();
6229
 
6230
  markerSvg.addEventListener("mouseover", (e) => {
6231
  const circle = e.target.closest("circle.marker");
static/index.html ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>악보 β†’ MML λ³€ν™˜κΈ°</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
10
+ h1 { text-align: center; padding: 20px; color: #fff; }
11
+ .subtitle { text-align: center; color: #aaa; margin-bottom: 20px; }
12
+ .container { max-width: 900px; margin: 0 auto; padding: 0 20px 40px; }
13
+
14
+ /* Feedback */
15
+ .feedback-bar { text-align: center; margin-bottom: 16px; }
16
+ .feedback-bar a { background: #4a90d9; color: #fff; padding: 8px 20px; border-radius: 6px; text-decoration: none; font-weight: bold; }
17
+ .feedback-bar a:hover { background: #5a9fe9; }
18
+
19
+ /* Form */
20
+ .upload-form { background: #16213e; border-radius: 12px; padding: 24px; margin-bottom: 24px; }
21
+ .form-row { display: flex; gap: 16px; flex-wrap: wrap; align-items: end; margin-bottom: 12px; }
22
+ .form-group { display: flex; flex-direction: column; gap: 4px; }
23
+ .form-group label { font-size: 13px; color: #aaa; }
24
+ .form-group input, .form-group select { background: #0f3460; color: #fff; border: 1px solid #333; border-radius: 6px; padding: 8px 12px; font-size: 14px; }
25
+ .form-group input[type="file"] { padding: 6px; }
26
+ .form-group input[type="number"] { width: 80px; }
27
+ .radio-group { display: flex; gap: 8px; flex-wrap: wrap; }
28
+ .radio-group label { display: flex; align-items: center; gap: 4px; font-size: 13px; color: #ccc; cursor: pointer; }
29
+ .radio-group input[type="radio"] { accent-color: #4a90d9; }
30
+ button { cursor: pointer; border: none; border-radius: 6px; padding: 10px 20px; font-size: 14px; font-weight: bold; }
31
+ .btn-primary { background: #e94560; color: #fff; }
32
+ .btn-primary:hover { background: #f05575; }
33
+ .btn-primary:disabled { background: #555; cursor: not-allowed; }
34
+ .btn-secondary { background: #4a90d9; color: #fff; }
35
+ .btn-secondary:hover { background: #5a9fe9; }
36
+ .btn-small { padding: 6px 12px; font-size: 12px; }
37
+ .btn-corrector { background: #00b894; color: #fff; font-size: 16px; padding: 14px 28px; }
38
+ .btn-corrector:hover { background: #00d9a3; }
39
+
40
+ /* Progress */
41
+ #progress { display: none; text-align: center; padding: 20px; color: #aaa; font-size: 16px; }
42
+ #progress .spinner { display: inline-block; width: 20px; height: 20px; border: 3px solid #333; border-top-color: #4a90d9; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 8px; vertical-align: middle; }
43
+ @keyframes spin { to { transform: rotate(360deg); } }
44
+
45
+ /* Results */
46
+ #results { display: none; }
47
+ .section { background: #16213e; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
48
+ .section h3 { color: #4a90d9; margin-bottom: 12px; }
49
+ .preview-gallery { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
50
+ .preview-gallery img { max-height: 200px; border-radius: 6px; border: 1px solid #333; object-fit: contain; }
51
+ textarea { width: 100%; background: #0f3460; color: #e0e0e0; border: 1px solid #333; border-radius: 6px; padding: 10px; font-family: monospace; font-size: 12px; resize: vertical; }
52
+ .btn-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
53
+ .downloads { margin-top: 8px; }
54
+ .downloads a { color: #4a90d9; text-decoration: none; margin-right: 12px; }
55
+ .downloads a:hover { text-decoration: underline; }
56
+ .tempo-row { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
57
+ .tempo-row input[type="range"] { flex: 1; accent-color: #4a90d9; }
58
+ .tempo-row span { min-width: 40px; text-align: center; }
59
+ .warnings { color: #ffa500; white-space: pre-wrap; font-size: 12px; margin-top: 8px; }
60
+ .corrector-section { text-align: center; padding: 24px; }
61
+
62
+ /* Dashboard */
63
+ .dashboard-toggle { cursor: pointer; color: #888; font-size: 13px; margin-top: 16px; }
64
+ #dashboard-content { display: none; margin-top: 8px; white-space: pre-wrap; font-family: monospace; font-size: 11px; color: #888; background: #0f3460; padding: 12px; border-radius: 6px; }
65
+ </style>
66
+ </head>
67
+ <body>
68
+
69
+ <h1>악보 β†’ MML λ³€ν™˜κΈ°</h1>
70
+ <p class="subtitle">PDF λ˜λŠ” 이미지 악보λ₯Ό μ—…λ‘œλ“œν•˜λ©΄ λ§ˆλΉ„λ…ΈκΈ° MML둜 λ³€ν™˜ν•΄λ“œλ¦½λ‹ˆλ‹€.</p>
71
+
72
+ <div class="container">
73
+ <div class="feedback-bar">
74
+ <a href="https://docs.google.com/forms/d/e/1FAIpQLScDoM53RMjDLlftORYHXmZ5kmkN4TTZOIyFIRuVsZhp4RGjEA/viewform" target="_blank">Feedback β€” μ‚¬μš© ν›„κΈ° / κ°œμ„  μš”μ²­</a>
75
+ </div>
76
+
77
+ <!-- Upload Form -->
78
+ <div class="upload-form">
79
+ <form id="upload-form">
80
+ <div class="form-row">
81
+ <div class="form-group" style="flex:1">
82
+ <label>악보 파일 (PDF, PNG, JPG)</label>
83
+ <input type="file" id="file-input" name="file" accept=".pdf,.png,.jpg,.jpeg" required>
84
+ </div>
85
+ </div>
86
+ <div class="form-row">
87
+ <div class="form-group">
88
+ <label>μ „μ²˜λ¦¬</label>
89
+ <div class="radio-group">
90
+ <label><input type="radio" name="preprocess" value="none" checked> μ—†μŒ</label>
91
+ <label><input type="radio" name="preprocess" value="otsu"> Otsu</label>
92
+ <label><input type="radio" name="preprocess" value="adaptive"> Adaptive</label>
93
+ <label><input type="radio" name="preprocess" value="contrast"> λŒ€λΉ„κ°•ν™”</label>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ <div class="form-row">
98
+ <div class="form-group">
99
+ <label>DPI (PDF만)</label>
100
+ <select name="dpi">
101
+ <option value="150">150</option>
102
+ <option value="300" selected>300</option>
103
+ <option value="450">450</option>
104
+ <option value="600">600</option>
105
+ </select>
106
+ </div>
107
+ <div class="form-group">
108
+ <label>μ—…μŠ€μΌ€μΌ</label>
109
+ <div class="radio-group">
110
+ <label><input type="radio" name="upscale" value="none" checked> μ—†μŒ</label>
111
+ <label><input type="radio" name="upscale" value="pil_2"> PIL 2Γ—</label>
112
+ <label><input type="radio" name="upscale" value="pil_3"> PIL 3Γ—</label>
113
+ </div>
114
+ </div>
115
+ <div class="form-group">
116
+ <label>μ‹œμž‘ νŽ˜μ΄μ§€</label>
117
+ <input type="number" name="page_start" value="0" min="0">
118
+ </div>
119
+ <div class="form-group">
120
+ <label>끝 νŽ˜μ΄μ§€</label>
121
+ <input type="number" name="page_end" value="0" min="0">
122
+ </div>
123
+ </div>
124
+ <div class="form-row">
125
+ <button type="submit" class="btn-primary" id="convert-btn">λ³€ν™˜ μ‹œμž‘</button>
126
+ </div>
127
+ </form>
128
+ </div>
129
+
130
+ <div id="progress"><span class="spinner"></span> <span id="progress-text">λ³€ν™˜ 쀑...</span></div>
131
+
132
+ <!-- Results -->
133
+ <div id="results">
134
+ <!-- Preview -->
135
+ <div class="section">
136
+ <h3>μž…λ ₯ 악보 미리보기</h3>
137
+ <div class="preview-gallery" id="preview-gallery"></div>
138
+ </div>
139
+
140
+ <!-- Whole Part -->
141
+ <div class="section">
142
+ <h3>Whole Part (μ „μ²΄ν™”μŒ)</h3>
143
+ <textarea id="mml-whole" rows="10" readonly></textarea>
144
+ <div class="btn-row">
145
+ <button class="btn-secondary btn-small" onclick="playMml('mml-whole', 'Whole Part')">β–Ά Whole Part μž¬μƒ</button>
146
+ <button class="btn-small" style="background:#555;color:#fff" onclick="stopMml()">β–  μ •μ§€</button>
147
+ </div>
148
+ <div class="downloads" id="whole-downloads"></div>
149
+ </div>
150
+
151
+ <!-- 3 Part -->
152
+ <div class="section">
153
+ <h3>3 Part (λ©œλ‘œλ”” / ν™”μŒ / 베이슀)</h3>
154
+ <textarea id="mml-three" rows="10" readonly></textarea>
155
+ <div class="btn-row">
156
+ <button class="btn-secondary btn-small" onclick="playMml('mml-three', '3 Part')">β–Ά 3 Part 전체</button>
157
+ <button class="btn-secondary btn-small" onclick="playPart('mml-three', 0, 'λ©œλ‘œλ””')">β–Ά λ©œλ‘œλ””</button>
158
+ <button class="btn-secondary btn-small" onclick="playPart('mml-three', 1, 'ν™”μŒ')">β–Ά ν™”μŒ</button>
159
+ <button class="btn-secondary btn-small" onclick="playPart('mml-three', 2, '베이슀')">β–Ά 베이슀</button>
160
+ <button class="btn-small" style="background:#555;color:#fff" onclick="stopMml()">β–  μ •μ§€</button>
161
+ </div>
162
+ <div class="downloads" id="three-downloads"></div>
163
+ </div>
164
+
165
+ <!-- Tempo -->
166
+ <div class="section">
167
+ <h3>λ°•μž 쑰절</h3>
168
+ <div class="tempo-row">
169
+ <span>32</span>
170
+ <input type="range" id="tempo-slider" min="32" max="255" value="120">
171
+ <span>255</span>
172
+ <span id="tempo-value">120</span>
173
+ <button class="btn-secondary btn-small" onclick="applyTempo()">λ°•μž 적용</button>
174
+ </div>
175
+ </div>
176
+
177
+ <!-- Downloads -->
178
+ <div class="section">
179
+ <h3>파일 λ‹€μš΄λ‘œλ“œ</h3>
180
+ <div class="downloads" id="mxl-downloads"></div>
181
+ <div class="downloads" id="xml-downloads"></div>
182
+ </div>
183
+
184
+ <!-- Warnings -->
185
+ <div class="section">
186
+ <h3>κ²½κ³ </h3>
187
+ <div class="warnings" id="warnings"></div>
188
+ </div>
189
+
190
+ <!-- Corrector -->
191
+ <div class="corrector-section">
192
+ <button class="btn-corrector" onclick="window.location='/corrector'">ꡐ정도ꡬ μ—΄κΈ°</button>
193
+ <p style="color:#888; margin-top:8px; font-size:13px">λ³€ν™˜ κ²°κ³Όκ°€ μžλ™μœΌλ‘œ λ‘œλ“œλ©λ‹ˆλ‹€</p>
194
+ </div>
195
+ </div>
196
+
197
+ <!-- Dashboard -->
198
+ <div>
199
+ <div class="dashboard-toggle" onclick="toggleDashboard()">β–Ά 톡계 λŒ€μ‹œλ³΄λ“œ</div>
200
+ <pre id="dashboard-content"></pre>
201
+ </div>
202
+ </div>
203
+
204
+ <script>
205
+ // ── MML Player (from app_gradio.py _JS_INIT) ──
206
+ var _MP = {actx:null, playing:[], stop:false};
207
+ _MP.parse = function(mml) {
208
+ var notes=[],tempo=120,oct=4,dL=4,vol=8,i=0;
209
+ var s=mml.toUpperCase().replace(/\s+/g,'');
210
+ var nm={C:0,D:2,E:4,F:5,G:7,A:9,B:11};
211
+ function rn(){var n='';while(i<s.length&&s[i]>='0'&&s[i]<='9'){n+=s[i];i++;}return n?parseInt(n):0;}
212
+ function ls(l){var b=60/tempo*4/l,d=0;while(i<s.length&&s[i]==='.'){d++;i++;}var x=b;for(var j=0;j<d;j++){x/=2;b+=x;}return b;}
213
+ while(i<s.length){
214
+ var c=s[i];
215
+ if(c==='T'){i++;tempo=rn()||120;}
216
+ else if(c==='O'){i++;oct=rn()||4;}
217
+ else if(c==='L'){i++;dL=rn()||4;}
218
+ else if(c==='V'){i++;vol=rn();if(vol>15)vol=15;}
219
+ else if(c==='>'){i++;oct=Math.min(oct+1,8);}
220
+ else if(c==='<'){i++;oct=Math.max(oct-1,1);}
221
+ else if(c==='&'){i++;}
222
+ else if(c==='N'){i++;var nn=rn();var ll=rn()||dL;notes.push({m:nn,d:ls(ll),v:vol});}
223
+ else if(nm[c]!==undefined){
224
+ i++;var sm=nm[c];
225
+ if(i<s.length&&s[i]==='+'){sm++;i++;}
226
+ else if(i<s.length&&(s[i]==='-'||s[i]==='#')){if(s[i]==='#')sm++;else sm--;i++;}
227
+ var ll2=rn()||dL;var dur=ls(ll2);notes.push({m:(oct+1)*12+sm,d:dur,v:vol});
228
+ }
229
+ else if(c==='R'){i++;var ll3=rn()||dL;notes.push({m:-1,d:ls(ll3),v:0});}
230
+ else{i++;}
231
+ }
232
+ return notes;
233
+ };
234
+ _MP.freq = function(m){return 440*Math.pow(2,(m-69)/12);};
235
+ _MP.playN = function(nl){
236
+ if(!_MP.actx) _MP.actx = new(window.AudioContext||window.webkitAudioContext)();
237
+ if(_MP.actx.state==='suspended') _MP.actx.resume();
238
+ var t=_MP.actx.currentTime+0.05;
239
+ for(var i=0;i<nl.length;i++){
240
+ var n=nl[i]; if(_MP.stop)break; if(n.m<0){t+=n.d;continue;}
241
+ var o=_MP.actx.createOscillator(),g=_MP.actx.createGain(),v=n.v/15*0.3;
242
+ o.type='triangle';o.frequency.value=_MP.freq(n.m);
243
+ g.gain.setValueAtTime(v,t);g.gain.exponentialRampToValueAtTime(0.001,t+n.d*0.95);
244
+ o.connect(g);g.connect(_MP.actx.destination);o.start(t);o.stop(t+n.d);
245
+ _MP.playing.push(o);t+=n.d;
246
+ }
247
+ return t-_MP.actx.currentTime;
248
+ };
249
+ _MP.extract = function(t){var m=t.match(/MML@[^;]*;/gi)||[];return m.map(function(s){return s.replace(/^MML@/i,'').replace(/;$/,'');});};
250
+ _MP.doStop = function(){_MP.stop=true;_MP.playing.forEach(function(o){try{o.stop();}catch(e){}});_MP.playing=[];};
251
+
252
+ function playMml(elemId, label) {
253
+ var text = document.getElementById(elemId).value;
254
+ if (!text) { alert('MML κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€.'); return; }
255
+ _MP.doStop(); _MP.stop = false;
256
+ var parts = _MP.extract(text);
257
+ if (!parts.length) { alert('νŒŒμ‹±λœ MML이 μ—†μŠ΅λ‹ˆλ‹€.'); return; }
258
+ var total = 0;
259
+ for (var i = 0; i < parts.length; i++) {
260
+ var notes = _MP.parse(parts[i]); total += notes.length; _MP.playN(notes);
261
+ }
262
+ alert(label + ' μž¬μƒ μ‹œμž‘ (' + total + '개 λ…ΈνŠΈ, ' + parts.length + 'νŠΈλž™)');
263
+ }
264
+ function playPart(elemId, idx, label) {
265
+ var text = document.getElementById(elemId).value;
266
+ if (!text) { alert('MML κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€.'); return; }
267
+ _MP.doStop(); _MP.stop = false;
268
+ var parts = _MP.extract(text);
269
+ if (parts.length > idx) {
270
+ var notes = _MP.parse(parts[idx]); _MP.playN(notes);
271
+ alert(label + ' μž¬μƒ μ‹œμž‘ (' + notes.length + '개 λ…ΈνŠΈ)');
272
+ } else { alert(label + ' 파트λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.'); }
273
+ }
274
+ function stopMml() { _MP.doStop(); }
275
+
276
+ // ── Tempo slider ──
277
+ var tempoSlider = document.getElementById('tempo-slider');
278
+ var tempoValue = document.getElementById('tempo-value');
279
+ tempoSlider.addEventListener('input', function() { tempoValue.textContent = this.value; });
280
+
281
+ function applyTempo() {
282
+ var tempo = parseInt(tempoSlider.value);
283
+ var wholeEl = document.getElementById('mml-whole');
284
+ var threeEl = document.getElementById('mml-three');
285
+ function replaceTempo(text) {
286
+ if (!text) return text;
287
+ return text.replace(/(MML@)(T\d+)?/gi, '$1T' + tempo);
288
+ }
289
+ wholeEl.value = replaceTempo(wholeEl.value);
290
+ threeEl.value = replaceTempo(threeEl.value);
291
+ }
292
+
293
+ // ── Convert ──
294
+ var convertForm = document.getElementById('upload-form');
295
+ convertForm.addEventListener('submit', async function(e) {
296
+ e.preventDefault();
297
+ var btn = document.getElementById('convert-btn');
298
+ btn.disabled = true;
299
+ document.getElementById('progress').style.display = 'block';
300
+ document.getElementById('results').style.display = 'none';
301
+
302
+ var formData = new FormData(convertForm);
303
+ try {
304
+ var resp = await fetch('/api/convert', { method: 'POST', body: formData });
305
+ var data = await resp.json();
306
+ if (!data.ok) {
307
+ alert(data.error || 'λ³€ν™˜ μ‹€νŒ¨');
308
+ return;
309
+ }
310
+ renderResults(data);
311
+ } catch (err) {
312
+ alert('였λ₯˜: ' + err.message);
313
+ } finally {
314
+ btn.disabled = false;
315
+ document.getElementById('progress').style.display = 'none';
316
+ }
317
+ });
318
+
319
+ function renderResults(data) {
320
+ // Preview
321
+ var gallery = document.getElementById('preview-gallery');
322
+ gallery.innerHTML = '';
323
+ (data.preview_urls || []).forEach(function(url) {
324
+ var img = document.createElement('img');
325
+ img.src = url;
326
+ gallery.appendChild(img);
327
+ });
328
+
329
+ // MML text
330
+ document.getElementById('mml-whole').value = data.whole_text || '';
331
+ document.getElementById('mml-three').value = data.three_text || '';
332
+
333
+ // Downloads
334
+ renderDownloads(data.whole_files || [], document.getElementById('whole-downloads'), 'Whole Track');
335
+ renderDownloads(data.three_files || [], document.getElementById('three-downloads'), '3Part Track');
336
+ renderDownloads(data.mxl_paths || [], document.getElementById('mxl-downloads'), 'MXL');
337
+ renderDownloads(data.xml_paths || [], document.getElementById('xml-downloads'), 'XML');
338
+
339
+ // Warnings
340
+ document.getElementById('warnings').textContent = data.warnings_text || '';
341
+
342
+ document.getElementById('results').style.display = 'block';
343
+ }
344
+
345
+ function renderDownloads(urls, container, label) {
346
+ container.innerHTML = '';
347
+ urls.forEach(function(url, i) {
348
+ var a = document.createElement('a');
349
+ a.href = url;
350
+ a.download = '';
351
+ a.textContent = label + ' ' + (i + 1);
352
+ container.appendChild(a);
353
+ });
354
+ }
355
+
356
+ // ── Dashboard ──
357
+ async function toggleDashboard() {
358
+ var el = document.getElementById('dashboard-content');
359
+ if (el.style.display === 'block') {
360
+ el.style.display = 'none';
361
+ return;
362
+ }
363
+ try {
364
+ var resp = await fetch('/api/dashboard');
365
+ el.textContent = await resp.text();
366
+ } catch (e) {
367
+ el.textContent = 'Error: ' + e.message;
368
+ }
369
+ el.style.display = 'block';
370
+ }
371
+ </script>
372
+ </body>
373
+ </html>