Spaces:
Running
Running
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 +3 -4
- app.py +614 -0
- requirements-server.txt +1 -1
- static/corrector.html +2 -2
- static/corrector.js +25 -5
- static/index.html +373 -0
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
|
| 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 |
-
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
//
|
| 6205 |
-
|
| 6206 |
-
|
| 6207 |
-
|
| 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>
|