"""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") @app.get("/", response_class=HTMLResponse) async def index(): return HTML @app.get("/health") 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}) @app.post("/transcribe") async def transcribe(request: Request): return await new_job(request, "audio") @app.post("/process") async def process(request: Request): return await new_job(request, "midi") @app.post("/merge") async def merge_route(request: Request): return await new_job(request, "merge") @app.get("/jobs/{job_id}") 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")})) @app.get("/jobs/{job_id}/{name}") 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) @app.post("/model/load") 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 = """ PLUS Studio — Nokia1709 LTD

PLUS Studio

MuScriptor Large + пост-обработка · Nokia1709 LTD
модель не загружена
перетащи WAV/MP3 сюда
или нажми и выбери

Обработка

Результат

ноты—
длительность—
нот/сек—
темп—

Стадии

—

Лог

ожидание
""" 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())