Download plus/plus_app.py from Nokia123543/muscriptor-large-fork: direct link, hf CLI and curl.
- Browser
- Download file 29.6 kB
-
https://huggingface.co/Nokia123543/muscriptor-large-fork/resolve/main/plus/plus_app.py
- Command line
-
hf download hf://Nokia123543/muscriptor-large-fork/plus/plus_app.py
-
curl -L -o plus_app.py https://huggingface.co/Nokia123543/muscriptor-large-fork/resolve/main/plus/plus_app.py
29.6 kB
| """PLUS Studio — веб-интерфейс над MuScriptor Large и пост-обработкой PLUS. | |
| Запуск: | |
| python plus_app.py --port 8901 | |
| python plus_app.py --weights /путь/model.safetensors --device auto | |
| Страница на / даёт загрузку аудио, набор стадий, ползунок силы квантования, | |
| слияние нескольких прогонов и скачивание MIDI, превью WAV и отчёта JSON. | |
| Модель грузится лениво при первом запросе к /transcribe, чтобы интерфейс | |
| поднимался сразу и без GPU. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import io | |
| import json | |
| import os | |
| import shutil | |
| import tempfile | |
| import threading | |
| import time | |
| import traceback | |
| import uuid | |
| import wave | |
| import numpy as np | |
| from fastapi import Form, Request, UploadFile # noqa: E402 (нужны в аннотациях) | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| import sys | |
| if HERE not in sys.path: | |
| sys.path.insert(0, HERE) | |
| from plus_core import (Note, Piece, dump_json, json_default, load_midi, # noqa: E402 | |
| save_midi) | |
| from plus_ensemble import compare, merge # noqa: E402 | |
| from plus_transcribe import STAGES, run_pipeline # noqa: E402 | |
| MODEL_REPO = "Nokia123543/muscriptor-large-fork" | |
| MODEL_FILES = ("model.safetensors", "config.json") | |
| JOBS: dict[str, dict] = {} | |
| MODEL = {"obj": None, "device": None, "dtype": None, "error": None, "loading": False, | |
| "weights": None, "seconds": None} | |
| WORK = os.environ.get("PLUS_WORK", os.path.join(tempfile.gettempdir(), "plus_studio")) | |
| def log(job: dict | None, text: str) -> None: | |
| stamp = time.strftime("%H:%M:%S") | |
| line = "[%s] %s" % (stamp, text) | |
| print(line, flush=True) | |
| if job is not None: | |
| job["log"].append(line) | |
| def plain(value): | |
| """numpy-скаляры и прочее -> обычные типы, иначе JSONResponse падает.""" | |
| return json.loads(json.dumps(value, ensure_ascii=False, default=json_default)) | |
| # --- модель ------------------------------------------------------------------ | |
| def resolve_weights(explicit: str | None) -> str: | |
| if explicit: | |
| return explicit | |
| env = os.environ.get("MUSCRIPTOR_WEIGHTS") | |
| if env: | |
| return env | |
| for candidate in ("muscriptor-large/model.safetensors", | |
| "/kaggle/input/muscriptor-large/model.safetensors", | |
| "/content/muscriptor-large/model.safetensors"): | |
| if os.path.exists(candidate): | |
| return candidate | |
| from huggingface_hub import hf_hub_download | |
| return hf_hub_download(MODEL_REPO, "model.safetensors") | |
| def pick_device(requested: str) -> tuple[str, str]: | |
| import torch | |
| if requested == "auto": | |
| requested = "cuda" if torch.cuda.is_available() else "cpu" | |
| dtype = "float16" if requested == "cuda" else "float32" | |
| return requested, dtype | |
| def load_model(requested: str = "auto") -> dict: | |
| """Ленивая загрузка: второй вызов возвращает уже готовую модель.""" | |
| if MODEL["obj"] is not None: | |
| return MODEL | |
| if MODEL["loading"]: | |
| while MODEL["loading"]: | |
| time.sleep(0.5) | |
| return MODEL | |
| MODEL["loading"] = True | |
| try: | |
| from muscriptor import TranscriptionModel | |
| weights = resolve_weights(MODEL["weights"]) | |
| device, dtype = pick_device(requested) | |
| started = time.time() | |
| log(None, "грузю веса: %s (%s, %s)" % (weights, device, dtype)) | |
| MODEL["obj"] = TranscriptionModel.load_model(weights_path=weights, | |
| device=device, dtype=dtype) | |
| MODEL["device"], MODEL["dtype"] = device, dtype | |
| MODEL["weights"] = weights | |
| MODEL["seconds"] = round(time.time() - started, 1) | |
| MODEL["error"] = None | |
| log(None, "модель готова за %.1f с" % MODEL["seconds"]) | |
| except Exception as exc: | |
| MODEL["error"] = "%s: %s" % (type(exc).__name__, exc) | |
| log(None, "модель не загрузилась: " + MODEL["error"]) | |
| finally: | |
| MODEL["loading"] = False | |
| return MODEL | |
| def events_to_notes(events: list) -> list[Note]: | |
| """NoteStartEvent/NoteEndEvent -> ноты. Громкости у модели нет, ставим 90.""" | |
| open_notes: dict[int, dict] = {} | |
| notes: list[Note] = [] | |
| duration_hint = 0.0 | |
| for event in events: | |
| name = type(event).__name__ | |
| if name == "NoteStartEvent": | |
| open_notes[event.index] = {"pitch": int(event.pitch), | |
| "start": float(event.start_time), | |
| "track": str(getattr(event, "instrument", "") or "main")} | |
| duration_hint = max(duration_hint, float(event.start_time)) | |
| elif name == "NoteEndEvent": | |
| start_index = int(getattr(event, "start_event_index", -1)) | |
| info = open_notes.pop(start_index, None) | |
| if info is None: | |
| continue | |
| end = float(event.end_time) | |
| notes.append(Note(info["start"], max(0.03, end - info["start"]), | |
| info["pitch"], 90, 0, info["track"])) | |
| duration_hint = max(duration_hint, end) | |
| for info in open_notes.values(): | |
| notes.append(Note(info["start"], 0.3, info["pitch"], 90, 0, info["track"])) | |
| notes.sort(key=lambda n: n.start) | |
| return notes | |
| def transcribe_audio(audio_path: str, job: dict, device: str = "auto") -> list[Note]: | |
| info = load_model(device) | |
| if info["obj"] is None: | |
| raise RuntimeError(info["error"] or "модель недоступна") | |
| log(job, "транскрипция: %s на %s" % (os.path.basename(audio_path), info["device"])) | |
| started = time.time() | |
| events = list(info["obj"].transcribe(audio_path)) | |
| notes = events_to_notes(events) | |
| log(job, "модель: %d нот за %.1f с" % (len(notes), time.time() - started)) | |
| if not notes: | |
| raise RuntimeError("модель не вернула ни одной ноты") | |
| return notes | |
| # --- обработка --------------------------------------------------------------- | |
| def stages_from(request: dict) -> list[str]: | |
| raw = request.get("stages") | |
| if not raw: | |
| return [s for s in ("cleanup", "quantize", "hands", "pedal", "dynamics", "report")] | |
| if isinstance(raw, str): | |
| raw = [s.strip() for s in raw.split(",") if s.strip()] | |
| return [s for s in raw if s in STAGES] | |
| def options_from(request: dict) -> dict: | |
| def number(name, default, low, high): | |
| try: | |
| value = float(request.get(name, default)) | |
| except (TypeError, ValueError): | |
| value = default | |
| return max(low, min(high, value)) | |
| return {"strength": number("strength", 0.85, 0.0, 1.0), | |
| "division": int(number("division", 4, 1, 16)), | |
| "swing": number("swing", 0.0, 0.0, 0.5), | |
| "tempo": float(request["tempo"]) if request.get("tempo") else None, | |
| "auto_tempo": str(request.get("auto_tempo", "1")) not in ("0", "false", "off")} | |
| def process_notes(notes: list[Note], request: dict, job: dict, audio: str | None = None) -> dict: | |
| piece = Piece(list(notes), [(0.0, 120.0)]) | |
| stages = stages_from(request) | |
| options = options_from(request) | |
| if audio is None and "dynamics" in stages: | |
| stages = [s for s in stages if s != "dynamics"] | |
| log(job, "динамика пропущена: нет аудио рядом") | |
| log(job, "стадии: " + ", ".join(stages)) | |
| report = run_pipeline(piece, stages, dict(options, audio=audio)) | |
| return {"piece": piece, "report": report, "stages": stages, "options": options} | |
| def job_dir(job_id: str) -> str: | |
| path = os.path.join(WORK, job_id) | |
| os.makedirs(path, exist_ok=True) | |
| return path | |
| def finish_job(job: dict, result: dict) -> None: | |
| path = job["dir"] | |
| piece = result["piece"] | |
| save_midi(piece, os.path.join(path, "result.mid")) | |
| dump_json(result["report"], os.path.join(path, "report.json")) | |
| job["stats"] = plain(piece.stats()) | |
| job["stages"] = plain(result["report"].get("stages", {})) | |
| job["result"] = {"midi": "/jobs/%s/result.mid" % job["id"], | |
| "report": "/jobs/%s/report.json" % job["id"]} | |
| if job.get("render"): | |
| try: | |
| import subprocess | |
| subprocess.run([sys.executable, os.path.join(HERE, "plus_render.py"), | |
| "--midi", os.path.join(path, "result.mid"), | |
| "--out", os.path.join(path, "preview.wav")], | |
| check=True, capture_output=True, timeout=600) | |
| job["result"]["wav"] = "/jobs/%s/preview.wav" % job["id"] | |
| log(job, "превью WAV готово") | |
| except Exception as exc: | |
| log(job, "превью не сделалось: " + str(exc)) | |
| def run_job(job_id: str, payload: dict) -> None: | |
| job = JOBS[job_id] | |
| job["state"] = "running" | |
| try: | |
| if payload["kind"] == "audio": | |
| notes = transcribe_audio(payload["audio"], job, payload.get("device", "auto")) | |
| result = process_notes(notes, payload, job, audio=payload["audio"]) | |
| elif payload["kind"] == "midi": | |
| piece_in = load_midi(payload["midi"]) | |
| log(job, "вход: %d нот" % len(piece_in.notes)) | |
| result = process_notes(piece_in.notes, payload, job, audio=payload.get("audio")) | |
| else: | |
| sources = [] | |
| for path, weight, name in payload["midi_list"]: | |
| loaded = load_midi(path) | |
| sources.append((name, loaded.notes, weight)) | |
| merged, agreement = merge(sources, tolerance=0.06, min_votes=payload.get("min_votes", 2.0), | |
| keep_strong=bool(payload.get("keep_strong")), | |
| tempo=payload.get("tempo")) | |
| log(job, "слияние: %d нот, согласие %.2f" % (len(merged.notes), | |
| agreement["agreement_mean"])) | |
| result = process_notes(merged.notes, payload, job, audio=payload.get("audio")) | |
| result["report"]["agreement"] = agreement | |
| if payload.get("reference"): | |
| result["report"]["quality"] = compare(load_midi(payload["reference"]).notes, | |
| result["piece"].notes) | |
| finish_job(job, result) | |
| job["state"] = "done" | |
| log(job, "готово: %d нот" % job["stats"]["notes"]) | |
| except Exception as exc: | |
| job["state"] = "error" | |
| job["error"] = "%s: %s" % (type(exc).__name__, exc) | |
| job["trace"] = traceback.format_exc()[-1500:] | |
| log(job, "ошибка: " + job["error"]) | |
| # --- HTTP -------------------------------------------------------------------- | |
| def build_app(): | |
| from fastapi import FastAPI | |
| from fastapi.responses import HTMLResponse, JSONResponse, FileResponse | |
| app = FastAPI(title="PLUS Studio") | |
| async def index(): | |
| return HTML | |
| async def health(): | |
| return JSONResponse({"ok": True, "model_loaded": MODEL["obj"] is not None, | |
| "device": MODEL["device"], "error": MODEL["error"], | |
| "weights": MODEL["weights"], "stages": list(STAGES)}) | |
| async def save_upload(upload: UploadFile, folder: str, name: str | None = None) -> str: | |
| os.makedirs(folder, exist_ok=True) | |
| target = os.path.join(folder, name or (upload.filename or "upload.bin")) | |
| with open(target, "wb") as handle: | |
| shutil.copyfileobj(upload.file, handle) | |
| return target | |
| async def new_job(request: Request, kind: str) -> JSONResponse: | |
| form = await request.form() | |
| job_id = uuid.uuid4().hex[:12] | |
| folder = job_dir(job_id) | |
| payload = {"kind": kind, "device": form.get("device", "auto")} | |
| for key in ("stages", "strength", "division", "swing", "tempo", "auto_tempo", | |
| "min_votes", "keep_strong"): | |
| if form.get(key) is not None: | |
| payload[key] = form.get(key) | |
| payload["render"] = str(form.get("render", "1")) not in ("0", "false", "off") | |
| if kind == "audio": | |
| upload = form.get("audio") | |
| if upload is None: | |
| return JSONResponse({"detail": "нет поля audio"}, status_code=422) | |
| payload["audio"] = await save_upload(upload, folder, "input" + os.path.splitext(upload.filename or ".wav")[1]) | |
| elif kind == "midi": | |
| upload = form.get("midi") | |
| if upload is None: | |
| return JSONResponse({"detail": "нет поля midi"}, status_code=422) | |
| payload["midi"] = await save_upload(upload, folder, "input.mid") | |
| audio = form.get("audio") | |
| if audio is not None and getattr(audio, "filename", ""): | |
| payload["audio"] = await save_upload(audio, folder, "input" + os.path.splitext(audio.filename)[1]) | |
| else: | |
| files = form.getlist("midi_list") | |
| if not files: | |
| return JSONResponse({"detail": "нужен хотя бы один MIDI"}, status_code=422) | |
| payload["midi_list"] = [] | |
| for index, upload in enumerate(files): | |
| path = await save_upload(upload, folder, "source_%02d.mid" % index) | |
| payload["midi_list"].append((path, 1.0, upload.filename or ("source%d" % index))) | |
| reference = form.get("reference") | |
| if reference is not None and getattr(reference, "filename", ""): | |
| payload["reference"] = await save_upload(reference, folder, "reference.mid") | |
| JOBS[job_id] = {"id": job_id, "dir": folder, "state": "queued", "log": [], | |
| "created": time.time(), "error": None, | |
| "render": payload["render"]} | |
| threading.Thread(target=run_job, args=(job_id, payload), daemon=True).start() | |
| log(JOBS[job_id], "принято: %s" % kind) | |
| return JSONResponse({"id": job_id}) | |
| async def transcribe(request: Request): | |
| return await new_job(request, "audio") | |
| async def process(request: Request): | |
| return await new_job(request, "midi") | |
| async def merge_route(request: Request): | |
| return await new_job(request, "merge") | |
| async def job_state(job_id: str): | |
| job = JOBS.get(job_id) | |
| if not job: | |
| return JSONResponse({"detail": "нет такого задания"}, status_code=404) | |
| return JSONResponse(plain({"id": job_id, "state": job["state"], "log": job["log"], | |
| "error": job["error"], "trace": job.get("trace"), | |
| "result": job.get("result"), "stats": job.get("stats"), | |
| "stages": job.get("stages")})) | |
| async def job_file(job_id: str, name: str): | |
| job = JOBS.get(job_id) | |
| if not job: | |
| return JSONResponse({"detail": "нет такого задания"}, status_code=404) | |
| path = os.path.join(job["dir"], name) | |
| if not os.path.exists(path): | |
| return JSONResponse({"detail": "нет файла"}, status_code=404) | |
| return FileResponse(path, filename=name) | |
| async def force_load(request: Request): | |
| form = await request.form() if request.headers.get("content-type", "").startswith( | |
| "application/x-www-form-urlencoded") else {} | |
| info = load_model(form.get("device", "auto")) | |
| return JSONResponse({"loaded": info["obj"] is not None, "device": info["device"], | |
| "seconds": info["seconds"], "error": info["error"]}) | |
| return app | |
| HTML = """<!doctype html> | |
| <html lang="ru"><head><meta charset="utf-8"> | |
| <title>PLUS Studio — Nokia1709 LTD</title> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <style> | |
| :root{--bg:#0a0b0d;--panel:#131519;--line:#2a2e35;--text:#e7ecf3;--dim:#8b93a1; | |
| --accent:#e03c1a;--ok:#33c37d;--warn:#ffae2e} | |
| *{box-sizing:border-box;border-radius:0} | |
| body{margin:0;background:var(--bg);color:var(--text); | |
| font:14px/1.45 -apple-system,Segoe UI,Roboto,sans-serif} | |
| header{padding:18px 22px;border-bottom:1px solid var(--line);display:flex; | |
| align-items:baseline;gap:14px;flex-wrap:wrap} | |
| h1{margin:0;font-size:19px;letter-spacing:.14em;text-transform:uppercase} | |
| h1 span{color:var(--accent)} | |
| .sub{color:var(--dim);font-size:12px} | |
| main{display:grid;grid-template-columns:minmax(320px,420px) 1fr;gap:18px;padding:18px 22px} | |
| @media(max-width:900px){main{grid-template-columns:1fr}} | |
| .panel{background:var(--panel);border:1px solid var(--line);padding:16px} | |
| .panel h2{margin:0 0 12px;font-size:12px;letter-spacing:.12em;color:var(--dim); | |
| text-transform:uppercase;font-weight:600} | |
| label.row{display:flex;justify-content:space-between;align-items:center;gap:10px; | |
| padding:6px 0;border-bottom:1px solid rgba(42,46,53,.55);font-size:13px} | |
| label.row:last-child{border-bottom:0} | |
| input[type=range]{width:130px;accent-color:var(--accent)} | |
| select,input[type=number]{background:#0d0f12;color:var(--text);border:1px solid var(--line); | |
| padding:5px 7px;font-size:13px} | |
| .tabs{display:flex;gap:0;margin-bottom:14px;border:1px solid var(--line)} | |
| .tabs button{flex:1;background:transparent;color:var(--dim);border:0;padding:9px 6px; | |
| font:600 11px/1 sans-serif;letter-spacing:.1em;text-transform:uppercase;cursor:pointer} | |
| .tabs button.on{background:var(--accent);color:#fff} | |
| button.go{width:100%;margin-top:14px;padding:12px;background:var(--accent);color:#fff; | |
| border:0;font:700 13px/1 sans-serif;letter-spacing:.1em;text-transform:uppercase;cursor:pointer} | |
| button.go:disabled{opacity:.45;cursor:default} | |
| button.ghost{background:transparent;color:var(--text);border:1px solid var(--line); | |
| padding:8px 10px;cursor:pointer;font-size:12px} | |
| .drop{border:1px dashed var(--line);padding:22px;text-align:center;color:var(--dim); | |
| font-size:12px;cursor:pointer} | |
| .drop.has{border-color:var(--accent);color:var(--text)} | |
| pre{margin:0;white-space:pre-wrap;word-break:break-word;font:12px/1.5 ui-monospace,Menlo,monospace; | |
| color:#c9d3e0;max-height:340px;overflow:auto} | |
| .stat{display:grid;grid-template-columns:repeat(auto-fit,minmax(96px,1fr));gap:8px;margin-bottom:12px} | |
| .stat div{border:1px solid var(--line);padding:8px 10px} | |
| .stat b{display:block;font-size:16px} | |
| .stat s{display:block;color:var(--dim);font-size:10px;letter-spacing:.08em; | |
| text-transform:uppercase;text-decoration:none} | |
| a.dl{display:inline-block;margin:10px 8px 0 0;padding:9px 12px;border:1px solid var(--line); | |
| color:var(--text);text-decoration:none;font-size:12px} | |
| a.dl:hover{border-color:var(--accent)} | |
| .badge{font-size:11px;color:var(--dim)} | |
| .dot{display:inline-block;width:7px;height:7px;background:var(--dim);margin-right:6px} | |
| .dot.on{background:var(--ok)}.dot.err{background:var(--accent)} | |
| </style></head><body> | |
| <header> | |
| <h1>PLUS <span>Studio</span></h1> | |
| <div class="sub">MuScriptor Large + пост-обработка · Nokia1709 LTD</div> | |
| <div class="sub" style="margin-left:auto"><span id="dot" class="dot"></span><span id="mstate">модель не загружена</span></div> | |
| </header> | |
| <main> | |
| <section> | |
| <div class="panel"> | |
| <div class="tabs"> | |
| <button data-tab="audio" class="on">Аудио</button> | |
| <button data-tab="midi">MIDI</button> | |
| <button data-tab="merge">Слияние</button> | |
| </div> | |
| <div id="tab-audio"> | |
| <div class="drop" id="drop-audio">перетащи WAV/MP3 сюда<br>или нажми и выбери</div> | |
| <input type="file" id="file-audio" accept="audio/*" hidden> | |
| </div> | |
| <div id="tab-midi" hidden> | |
| <div class="drop" id="drop-midi">MIDI для обработки<br>или нажми и выбери</div> | |
| <input type="file" id="file-midi" accept=".mid,.midi" hidden> | |
| <div class="badge" style="margin-top:8px">можно приложить аудио для стадии динамики</div> | |
| <div class="drop" id="drop-midi-audio" style="margin-top:8px;padding:12px">аудио (необязательно)</div> | |
| <input type="file" id="file-midi-audio" accept="audio/*" hidden> | |
| </div> | |
| <div id="tab-merge" hidden> | |
| <div class="drop" id="drop-merge">несколько MIDI от разных движков<br>или нажми и выбери</div> | |
| <input type="file" id="file-merge" accept=".mid,.midi" multiple hidden> | |
| <div id="merge-list" class="badge" style="margin-top:8px"></div> | |
| <label class="row">минимум голосов <input type="number" id="min_votes" value="2" min="1" max="9" step="1"></label> | |
| <label class="row">спасать одиночные громкие <input type="checkbox" id="keep_strong"></label> | |
| </div> | |
| </div> | |
| <div class="panel" style="margin-top:14px"> | |
| <h2>Обработка</h2> | |
| <label class="row">очистка <input type="checkbox" data-stage="cleanup" checked></label> | |
| <label class="row">квантование <input type="checkbox" data-stage="quantize" checked></label> | |
| <label class="row">сила квантования <input type="range" id="strength" min="0" max="1" step="0.05" value="0.85"> | |
| <span id="strength-v" class="badge">0.85</span></label> | |
| <label class="row">деление доли <select id="division"> | |
| <option value="1">1</option><option value="2">2</option> | |
| <option value="4" selected>4</option><option value="8">8</option> | |
| <option value="16">16</option></select></label> | |
| <label class="row">свинг <input type="range" id="swing" min="0" max="0.4" step="0.02" value="0"> | |
| <span id="swing-v" class="badge">0</span></label> | |
| <label class="row">подбор темпа <input type="checkbox" id="auto_tempo" checked></label> | |
| <label class="row">руки <input type="checkbox" data-stage="hands" checked></label> | |
| <label class="row">педаль <input type="checkbox" data-stage="pedal" checked></label> | |
| <label class="row">аккорды <input type="checkbox" data-stage="chords" checked></label> | |
| <label class="row">динамика из аудио <input type="checkbox" data-stage="dynamics" checked></label> | |
| <label class="row">отчёт <input type="checkbox" data-stage="report" checked></label> | |
| <label class="row">превью WAV <input type="checkbox" id="render" checked></label> | |
| <label class="row">устройство <select id="device"> | |
| <option value="auto" selected>авто</option><option value="cuda">cuda</option> | |
| <option value="cpu">cpu</option></select></label> | |
| <button class="go" id="go">Обработать</button> | |
| </div> | |
| </section> | |
| <section> | |
| <div class="panel"> | |
| <h2>Результат</h2> | |
| <div class="stat"> | |
| <div><s>ноты</s><b id="s-notes">—</b></div> | |
| <div><s>длительность</s><b id="s-dur">—</b></div> | |
| <div><s>нот/сек</s><b id="s-rate">—</b></div> | |
| <div><s>темп</s><b id="s-bpm">—</b></div> | |
| </div> | |
| <div id="downloads"></div> | |
| <h2 style="margin-top:16px">Стадии</h2> | |
| <pre id="stages">—</pre> | |
| </div> | |
| <div class="panel" style="margin-top:14px"> | |
| <h2>Лог</h2> | |
| <pre id="log">ожидание</pre> | |
| </div> | |
| </section> | |
| </main> | |
| <script> | |
| const $ = (id) => document.getElementById(id); | |
| let tab = "audio", files = {audio:null, midi:null, midiAudio:null, merge:[]}, jobId = null; | |
| document.querySelectorAll(".tabs button").forEach((b) => b.onclick = () => { | |
| tab = b.dataset.tab; | |
| document.querySelectorAll(".tabs button").forEach((x) => x.classList.toggle("on", x === b)); | |
| ["audio", "midi", "merge"].forEach((t) => $("tab-" + t).hidden = t !== tab); | |
| }); | |
| function wire(dropId, inputId, onPick) { | |
| const drop = $(dropId), input = $(inputId); | |
| drop.onclick = () => input.click(); | |
| input.onchange = () => { if (input.files.length) { onPick(input.files); } }; | |
| drop.ondragover = (e) => { e.preventDefault(); drop.classList.add("has"); }; | |
| drop.ondragleave = () => drop.classList.remove("has"); | |
| drop.ondrop = (e) => { e.preventDefault(); drop.classList.remove("has"); onPick(e.dataTransfer.files); }; | |
| } | |
| function mark(dropId, text) { const d = $(dropId); d.textContent = text; d.classList.add("has"); } | |
| wire("drop-audio", "file-audio", (f) => { files.audio = f[0]; mark("drop-audio", f[0].name); }); | |
| wire("drop-midi", "file-midi", (f) => { files.midi = f[0]; mark("drop-midi", f[0].name); }); | |
| wire("drop-midi-audio", "file-midi-audio", (f) => { files.midiAudio = f[0]; mark("drop-midi-audio", f[0].name); }); | |
| wire("drop-merge", "file-merge", (f) => { | |
| files.merge = Array.from(f); | |
| mark("drop-merge", files.merge.length + " файлов"); | |
| $("merge-list").textContent = files.merge.map((x) => x.name).join(" · "); | |
| }); | |
| $("strength").oninput = (e) => $("strength-v").textContent = e.target.value; | |
| $("swing").oninput = (e) => $("swing-v").textContent = e.target.value; | |
| function stages() { | |
| return Array.from(document.querySelectorAll("[data-stage]")) | |
| .filter((c) => c.checked).map((c) => c.dataset.stage).join(","); | |
| } | |
| $("go").onclick = async () => { | |
| const form = new FormData(); | |
| form.append("stages", stages()); | |
| form.append("strength", $("strength").value); | |
| form.append("division", $("division").value); | |
| form.append("swing", $("swing").value); | |
| form.append("auto_tempo", $("auto_tempo").checked ? "1" : "0"); | |
| form.append("render", $("render").checked ? "1" : "0"); | |
| form.append("device", $("device").value); | |
| let url = "/transcribe"; | |
| if (tab === "audio") { | |
| if (!files.audio) { alert("нужно аудио"); return; } | |
| form.append("audio", files.audio); | |
| } else if (tab === "midi") { | |
| if (!files.midi) { alert("нужен MIDI"); return; } | |
| url = "/process"; form.append("midi", files.midi); | |
| if (files.midiAudio) form.append("audio", files.midiAudio); | |
| } else { | |
| if (!files.merge.length) { alert("нужны MIDI для слияния"); return; } | |
| url = "/merge"; | |
| files.merge.forEach((f) => form.append("midi_list", f)); | |
| form.append("min_votes", $("min_votes").value); | |
| form.append("keep_strong", $("keep_strong").checked ? "1" : "0"); | |
| } | |
| $("go").disabled = true; | |
| $("log").textContent = "отправка…"; | |
| try { | |
| const resp = await fetch(url, {method: "POST", body: form}); | |
| const data = await resp.json(); | |
| if (!resp.ok) throw new Error(data.detail || ("HTTP " + resp.status)); | |
| jobId = data.id; | |
| poll(); | |
| } catch (e) { $("log").textContent = "ошибка: " + e.message; $("go").disabled = false; } | |
| }; | |
| async function poll() { | |
| const resp = await fetch("/jobs/" + jobId); | |
| const job = await resp.json(); | |
| $("log").textContent = (job.log || []).join("\\n") || "…"; | |
| $("log").scrollTop = $("log").scrollHeight; | |
| if (job.state === "done") { | |
| $("go").disabled = false; | |
| const st = job.stats || {}; | |
| $("s-notes").textContent = st.notes ?? "—"; | |
| $("s-dur").textContent = st.duration ? st.duration.toFixed(1) + " с" : "—"; | |
| $("s-rate").textContent = st.notes_per_second ?? "—"; | |
| $("s-bpm").textContent = st.bpm ?? "—"; | |
| $("stages").textContent = JSON.stringify(job.stages || {}, null, 1); | |
| const fits = []; | |
| if (job.result?.midi) fits.push(`<a class="dl" href="${job.result.midi}">СКАЧАТЬ MIDI</a>`); | |
| if (job.result?.wav) fits.push(`<a class="dl" href="${job.result.wav}">ПРЕВЬЮ WAV</a>`); | |
| if (job.result?.report) fits.push(`<a class="dl" href="${job.result.report}">ОТЧЁТ JSON</a>`); | |
| $("downloads").innerHTML = fits.join(""); | |
| return; | |
| } | |
| if (job.state === "error") { | |
| $("go").disabled = false; | |
| $("log").textContent += "\\n\\n" + (job.error || "ошибка") + "\\n" + (job.trace || ""); | |
| return; | |
| } | |
| setTimeout(poll, 1200); | |
| } | |
| fetch("/health").then((r) => r.json()).then((h) => { | |
| const dot = $("dot"), text = $("mstate"); | |
| if (h.model_loaded) { dot.classList.add("on"); text.textContent = "модель на " + h.device; } | |
| else if (h.error) { dot.classList.add("err"); text.textContent = "модель: " + h.error; } | |
| else { text.textContent = "модель загрузится при первом запуске"; } | |
| }); | |
| </script> | |
| </body></html> | |
| """ | |
| def main(argv: list[str] | None = None) -> int: | |
| parser = argparse.ArgumentParser(description="PLUS Studio") | |
| parser.add_argument("--port", type=int, default=8901) | |
| parser.add_argument("--host", default="0.0.0.0") | |
| parser.add_argument("--weights", help="путь к model.safetensors") | |
| parser.add_argument("--device", default="auto", help="auto | cuda | cpu") | |
| parser.add_argument("--preload", action="store_true", help="загрузить модель сразу") | |
| args = parser.parse_args(argv) | |
| import uvicorn | |
| MODEL["weights"] = args.weights | |
| global WORK | |
| WORK = os.environ.get("PLUS_WORK", WORK) | |
| os.makedirs(WORK, exist_ok=True) | |
| if args.preload: | |
| threading.Thread(target=load_model, args=(args.device,), daemon=True).start() | |
| log(None, "PLUS Studio на http://%s:%d (рабочая папка %s)" % (args.host, args.port, WORK)) | |
| uvicorn.run(build_app(), host=args.host, port=args.port, log_level="warning") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |