Spaces:
Running on Zero
Running on Zero
Register Kiku AI redirect URI for OAuth
Browse files
app.py
CHANGED
|
@@ -1,158 +1,161 @@
|
|
| 1 |
-
"""The dedicated ZeroGPU transcription API for Score Studio."""
|
| 2 |
import logging
|
| 3 |
import os
|
| 4 |
from pathlib import Path
|
| 5 |
-
import shutil
|
| 6 |
-
import subprocess
|
| 7 |
-
import sys
|
| 8 |
-
import tempfile
|
| 9 |
-
import time
|
| 10 |
-
|
| 11 |
-
import gradio as gr
|
| 12 |
-
import spaces
|
| 13 |
import torch
|
| 14 |
from transformers import AutoModel
|
| 15 |
-
|
| 16 |
-
MODEL_ID = "m-a-p/SheetSage2"
|
| 17 |
-
REVISION = "eab522a8168e8b8b8c4856bf8609cd86198f01fe"
|
| 18 |
-
ROOT = Path(tempfile.gettempdir()) / "audio-to-score"
|
| 19 |
-
ROOT.mkdir(exist_ok=True)
|
| 20 |
-
logging.basicConfig(level=logging.INFO)
|
| 21 |
-
|
| 22 |
-
# ZeroGPU's CUDA emulation lets the model be placed during startup; the actual
|
| 23 |
-
# 48 GB GPU is attached only while the decorated conversion function runs.
|
| 24 |
-
MODEL = AutoModel.from_pretrained(
|
| 25 |
-
MODEL_ID, revision=REVISION, trust_remote_code=True,
|
| 26 |
-
torch_dtype=torch.bfloat16,
|
| 27 |
-
).eval().to("cuda")
|
| 28 |
-
|
| 29 |
-
# The upstream renderer is fully offline but needs its matching headless browser.
|
| 30 |
-
subprocess.run([sys.executable, "-m", "playwright", "install", "--only-shell", "chromium"], check=True)
|
| 31 |
-
|
| 32 |
-
|
| 33 |
def gpu_seconds(audio, start, duration, melody_only, piano, progress=None, **kwargs):
|
| 34 |
-
# Short excerpts should fit visitor quotas instead of reserving five minutes.
|
| 35 |
-
return max(45, min(300, int(float(duration) * 0.4) + 30))
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
@spaces.GPU(duration=gpu_seconds)
|
| 39 |
def transcribe(audio, start, duration, melody_only, piano, progress=gr.Progress()):
|
| 40 |
-
if not audio:
|
| 41 |
-
raise gr.Error("Upload an audio file first.")
|
| 42 |
-
if start < 0 or not 5 <= duration <= 600:
|
| 43 |
-
raise gr.Error("Choose a start of 0 or later and a duration from 5 to 600 seconds.")
|
| 44 |
-
# Clear the previous run immediately so failed requests cannot show stale scores.
|
| 45 |
-
yield "Preparing your audio…", [], None, None, [], ""
|
| 46 |
-
# Keep the current result until Gradio has copied it to its managed cache.
|
| 47 |
-
# Remove abandoned job directories from earlier runs after one day.
|
| 48 |
-
cutoff = time.time() - 86400
|
| 49 |
-
for old in ROOT.glob("score-*"):
|
| 50 |
-
if old.is_dir() and not old.is_symlink() and old.stat().st_mtime < cutoff:
|
| 51 |
-
shutil.rmtree(old, ignore_errors=True)
|
| 52 |
-
job = Path(tempfile.mkdtemp(prefix="score-", dir=ROOT))
|
| 53 |
-
clip = job / "input.wav"
|
| 54 |
-
output = job / "results"
|
| 55 |
-
output.mkdir()
|
| 56 |
-
try:
|
| 57 |
-
progress(0.03, desc="Preparing audio excerpt")
|
| 58 |
-
completed = subprocess.run(
|
| 59 |
-
["ffmpeg", "-nostdin", "-v", "error", "-ss", str(start),
|
| 60 |
-
"-i", str(audio), "-t", str(duration), "-ac", "1", "-ar", "24000",
|
| 61 |
-
"-y", str(clip)], capture_output=True, timeout=120,
|
| 62 |
-
)
|
| 63 |
-
if completed.returncode or not clip.exists() or clip.stat().st_size < 2048:
|
| 64 |
-
raise gr.Error("Could not read this excerpt. Check the file and start time.")
|
| 65 |
-
progress(0.06, desc="Loading SheetSage2 — first run takes longer")
|
| 66 |
-
model = MODEL
|
| 67 |
-
started = time.monotonic()
|
| 68 |
-
|
| 69 |
-
def update(event):
|
| 70 |
-
stage = event.get("stage", "working")
|
| 71 |
-
labels = {"audio": "Reading audio", "encoding": "Listening to the music",
|
| 72 |
-
"decoding": "Writing notes and chords", "notation": "Preparing notation",
|
| 73 |
-
"complete": "Rendering printable score"}
|
| 74 |
-
detail = labels.get(stage, "Processing music")
|
| 75 |
-
if "window" in event:
|
| 76 |
-
detail += f" · section {event['window']}/{event['windows']}"
|
| 77 |
-
if "tokens" in event:
|
| 78 |
-
detail += f" · {event['tokens']} symbols"
|
| 79 |
-
progress(None, desc=detail)
|
| 80 |
-
|
| 81 |
-
notice = ""
|
| 82 |
-
try:
|
| 83 |
-
with torch.inference_mode():
|
| 84 |
-
result = model.transcribe(str(clip), output_dir=str(output),
|
| 85 |
-
melody_only=bool(melody_only), render_audio=bool(piano),
|
| 86 |
-
render_score="pdf,png", dtype="bf16", progress=update)
|
| 87 |
-
except Exception as exc:
|
| 88 |
-
# Upstream preserves valid MIDI/ABC when notation or rendering fails.
|
| 89 |
-
result = getattr(exc, "result", None)
|
| 90 |
-
if result is None or not any(output.glob("*.mid")):
|
| 91 |
-
raise
|
| 92 |
-
logging.exception("Partial transcription")
|
| 93 |
-
notice = " Some score rendering failed; available transcription files are included."
|
| 94 |
-
progress(0.97, desc="Preparing downloads")
|
| 95 |
-
files = sorted(p for p in output.iterdir() if p.is_file() and p.suffix != ".partial")
|
| 96 |
-
if not files:
|
| 97 |
-
raise RuntimeError("The model returned no output files.")
|
| 98 |
-
archive = shutil.make_archive(str(job / "score-downloads"), "zip", output)
|
| 99 |
-
pngs = [str(p) for p in sorted(output.glob("score_*.png"))]
|
| 100 |
-
pdf = output / "score.pdf"
|
| 101 |
-
wavs = sorted(output.glob("*.wav"))
|
| 102 |
-
abc_path = output / "score.abc"
|
| 103 |
-
abc = abc_path.read_text(encoding="utf-8") if abc_path.exists() else ""
|
| 104 |
-
elapsed = round(time.monotonic() - started)
|
| 105 |
-
warnings = result.get("warnings", [])
|
| 106 |
-
status = f"Finished in {elapsed // 60}m {elapsed % 60}s. Download your files below.{notice}"
|
| 107 |
-
if warnings:
|
| 108 |
-
status += " The model reported warnings; review result.json in the download."
|
| 109 |
-
yield status, pngs, str(pdf) if pdf.exists() else None, str(wavs[0]) if wavs else None, [archive] + [str(p) for p in files], abc
|
| 110 |
-
except gr.Error:
|
| 111 |
-
raise
|
| 112 |
-
except Exception:
|
| 113 |
-
logging.exception("Transcription failed")
|
| 114 |
raise gr.Error("Conversion failed. Try a shorter excerpt. Details are available in the Space logs.")
|
| 115 |
|
| 116 |
|
| 117 |
with gr.Blocks(title="Score Studio API", delete_cache=(3600, 86400)) as demo:
|
| 118 |
-
gr.Markdown("# Score Studio API\nThe ZeroGPU transcription engine for Score Studio. "
|
| 119 |
-
"Use the API below to turn audio into editable ABC, MIDI, PDF and piano audio.")
|
| 120 |
-
audio = gr.Audio(type="filepath", label="Audio", sources=["upload"])
|
| 121 |
-
start = gr.Number(value=0, label="Start (seconds)", minimum=0)
|
| 122 |
-
duration = gr.Number(value=30, label="Duration (seconds)", minimum=5, maximum=600)
|
| 123 |
-
melody = gr.Checkbox(value=False, label="Melody only")
|
| 124 |
-
piano = gr.Checkbox(value=True, label="Render piano preview")
|
| 125 |
-
run = gr.Button("Transcribe")
|
| 126 |
-
status = gr.Textbox(label="Status")
|
| 127 |
-
gallery = gr.Gallery(label="Score pages")
|
| 128 |
-
pdf = gr.File(label="PDF")
|
| 129 |
-
preview = gr.Audio(label="Piano preview")
|
| 130 |
-
downloads = gr.File(label="Downloads", file_count="multiple")
|
| 131 |
-
abc = gr.Code(label="ABC notation", language=None)
|
| 132 |
run.click(transcribe, [audio, start, duration, melody, piano],
|
| 133 |
-
[status, gallery, pdf, preview, downloads, abc],
|
| 134 |
api_name="transcribe", concurrency_limit=1)
|
| 135 |
-
gr.Markdown("Powered by [SheetSage2](https://huggingface.co/m-a-p/SheetSage2), "
|
| 136 |
-
"CC BY-NC 4.0. Temporary files expire after 24 hours.")
|
| 137 |
-
|
| 138 |
def oauth_metadata():
|
| 139 |
-
return {
|
| 140 |
-
"client_id": "https://reubencf-score-studio-api.hf.space/.well-known/oauth-cimd",
|
| 141 |
-
"client_name": "
|
| 142 |
-
"client_uri": "https://huggingface.co/spaces/Reubencf/Score-Studio-API",
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
"
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
| 153 |
def health():
|
| 154 |
-
return {"status": "ready", "model": MODEL_ID, "revision": REVISION}
|
| 155 |
-
|
| 156 |
if __name__ == "__main__":
|
| 157 |
# The native Gradio launch hook registers the GPU worker with Spaces.
|
| 158 |
app, _, _ = demo.queue(max_size=5).launch(
|
|
|
|
| 1 |
+
"""The dedicated ZeroGPU transcription API for Score Studio."""
|
| 2 |
import logging
|
| 3 |
import os
|
| 4 |
from pathlib import Path
|
| 5 |
+
import shutil
|
| 6 |
+
import subprocess
|
| 7 |
+
import sys
|
| 8 |
+
import tempfile
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
import gradio as gr
|
| 12 |
+
import spaces
|
| 13 |
import torch
|
| 14 |
from transformers import AutoModel
|
| 15 |
+
|
| 16 |
+
MODEL_ID = "m-a-p/SheetSage2"
|
| 17 |
+
REVISION = "eab522a8168e8b8b8c4856bf8609cd86198f01fe"
|
| 18 |
+
ROOT = Path(tempfile.gettempdir()) / "audio-to-score"
|
| 19 |
+
ROOT.mkdir(exist_ok=True)
|
| 20 |
+
logging.basicConfig(level=logging.INFO)
|
| 21 |
+
|
| 22 |
+
# ZeroGPU's CUDA emulation lets the model be placed during startup; the actual
|
| 23 |
+
# 48 GB GPU is attached only while the decorated conversion function runs.
|
| 24 |
+
MODEL = AutoModel.from_pretrained(
|
| 25 |
+
MODEL_ID, revision=REVISION, trust_remote_code=True,
|
| 26 |
+
torch_dtype=torch.bfloat16,
|
| 27 |
+
).eval().to("cuda")
|
| 28 |
+
|
| 29 |
+
# The upstream renderer is fully offline but needs its matching headless browser.
|
| 30 |
+
subprocess.run([sys.executable, "-m", "playwright", "install", "--only-shell", "chromium"], check=True)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
def gpu_seconds(audio, start, duration, melody_only, piano, progress=None, **kwargs):
|
| 34 |
+
# Short excerpts should fit visitor quotas instead of reserving five minutes.
|
| 35 |
+
return max(45, min(300, int(float(duration) * 0.4) + 30))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@spaces.GPU(duration=gpu_seconds)
|
| 39 |
def transcribe(audio, start, duration, melody_only, piano, progress=gr.Progress()):
|
| 40 |
+
if not audio:
|
| 41 |
+
raise gr.Error("Upload an audio file first.")
|
| 42 |
+
if start < 0 or not 5 <= duration <= 600:
|
| 43 |
+
raise gr.Error("Choose a start of 0 or later and a duration from 5 to 600 seconds.")
|
| 44 |
+
# Clear the previous run immediately so failed requests cannot show stale scores.
|
| 45 |
+
yield "Preparing your audio…", [], None, None, [], ""
|
| 46 |
+
# Keep the current result until Gradio has copied it to its managed cache.
|
| 47 |
+
# Remove abandoned job directories from earlier runs after one day.
|
| 48 |
+
cutoff = time.time() - 86400
|
| 49 |
+
for old in ROOT.glob("score-*"):
|
| 50 |
+
if old.is_dir() and not old.is_symlink() and old.stat().st_mtime < cutoff:
|
| 51 |
+
shutil.rmtree(old, ignore_errors=True)
|
| 52 |
+
job = Path(tempfile.mkdtemp(prefix="score-", dir=ROOT))
|
| 53 |
+
clip = job / "input.wav"
|
| 54 |
+
output = job / "results"
|
| 55 |
+
output.mkdir()
|
| 56 |
+
try:
|
| 57 |
+
progress(0.03, desc="Preparing audio excerpt")
|
| 58 |
+
completed = subprocess.run(
|
| 59 |
+
["ffmpeg", "-nostdin", "-v", "error", "-ss", str(start),
|
| 60 |
+
"-i", str(audio), "-t", str(duration), "-ac", "1", "-ar", "24000",
|
| 61 |
+
"-y", str(clip)], capture_output=True, timeout=120,
|
| 62 |
+
)
|
| 63 |
+
if completed.returncode or not clip.exists() or clip.stat().st_size < 2048:
|
| 64 |
+
raise gr.Error("Could not read this excerpt. Check the file and start time.")
|
| 65 |
+
progress(0.06, desc="Loading SheetSage2 — first run takes longer")
|
| 66 |
+
model = MODEL
|
| 67 |
+
started = time.monotonic()
|
| 68 |
+
|
| 69 |
+
def update(event):
|
| 70 |
+
stage = event.get("stage", "working")
|
| 71 |
+
labels = {"audio": "Reading audio", "encoding": "Listening to the music",
|
| 72 |
+
"decoding": "Writing notes and chords", "notation": "Preparing notation",
|
| 73 |
+
"complete": "Rendering printable score"}
|
| 74 |
+
detail = labels.get(stage, "Processing music")
|
| 75 |
+
if "window" in event:
|
| 76 |
+
detail += f" · section {event['window']}/{event['windows']}"
|
| 77 |
+
if "tokens" in event:
|
| 78 |
+
detail += f" · {event['tokens']} symbols"
|
| 79 |
+
progress(None, desc=detail)
|
| 80 |
+
|
| 81 |
+
notice = ""
|
| 82 |
+
try:
|
| 83 |
+
with torch.inference_mode():
|
| 84 |
+
result = model.transcribe(str(clip), output_dir=str(output),
|
| 85 |
+
melody_only=bool(melody_only), render_audio=bool(piano),
|
| 86 |
+
render_score="pdf,png", dtype="bf16", progress=update)
|
| 87 |
+
except Exception as exc:
|
| 88 |
+
# Upstream preserves valid MIDI/ABC when notation or rendering fails.
|
| 89 |
+
result = getattr(exc, "result", None)
|
| 90 |
+
if result is None or not any(output.glob("*.mid")):
|
| 91 |
+
raise
|
| 92 |
+
logging.exception("Partial transcription")
|
| 93 |
+
notice = " Some score rendering failed; available transcription files are included."
|
| 94 |
+
progress(0.97, desc="Preparing downloads")
|
| 95 |
+
files = sorted(p for p in output.iterdir() if p.is_file() and p.suffix != ".partial")
|
| 96 |
+
if not files:
|
| 97 |
+
raise RuntimeError("The model returned no output files.")
|
| 98 |
+
archive = shutil.make_archive(str(job / "score-downloads"), "zip", output)
|
| 99 |
+
pngs = [str(p) for p in sorted(output.glob("score_*.png"))]
|
| 100 |
+
pdf = output / "score.pdf"
|
| 101 |
+
wavs = sorted(output.glob("*.wav"))
|
| 102 |
+
abc_path = output / "score.abc"
|
| 103 |
+
abc = abc_path.read_text(encoding="utf-8") if abc_path.exists() else ""
|
| 104 |
+
elapsed = round(time.monotonic() - started)
|
| 105 |
+
warnings = result.get("warnings", [])
|
| 106 |
+
status = f"Finished in {elapsed // 60}m {elapsed % 60}s. Download your files below.{notice}"
|
| 107 |
+
if warnings:
|
| 108 |
+
status += " The model reported warnings; review result.json in the download."
|
| 109 |
+
yield status, pngs, str(pdf) if pdf.exists() else None, str(wavs[0]) if wavs else None, [archive] + [str(p) for p in files], abc
|
| 110 |
+
except gr.Error:
|
| 111 |
+
raise
|
| 112 |
+
except Exception:
|
| 113 |
+
logging.exception("Transcription failed")
|
| 114 |
raise gr.Error("Conversion failed. Try a shorter excerpt. Details are available in the Space logs.")
|
| 115 |
|
| 116 |
|
| 117 |
with gr.Blocks(title="Score Studio API", delete_cache=(3600, 86400)) as demo:
|
| 118 |
+
gr.Markdown("# Score Studio API\nThe ZeroGPU transcription engine for Score Studio. "
|
| 119 |
+
"Use the API below to turn audio into editable ABC, MIDI, PDF and piano audio.")
|
| 120 |
+
audio = gr.Audio(type="filepath", label="Audio", sources=["upload"])
|
| 121 |
+
start = gr.Number(value=0, label="Start (seconds)", minimum=0)
|
| 122 |
+
duration = gr.Number(value=30, label="Duration (seconds)", minimum=5, maximum=600)
|
| 123 |
+
melody = gr.Checkbox(value=False, label="Melody only")
|
| 124 |
+
piano = gr.Checkbox(value=True, label="Render piano preview")
|
| 125 |
+
run = gr.Button("Transcribe")
|
| 126 |
+
status = gr.Textbox(label="Status")
|
| 127 |
+
gallery = gr.Gallery(label="Score pages")
|
| 128 |
+
pdf = gr.File(label="PDF")
|
| 129 |
+
preview = gr.Audio(label="Piano preview")
|
| 130 |
+
downloads = gr.File(label="Downloads", file_count="multiple")
|
| 131 |
+
abc = gr.Code(label="ABC notation", language=None)
|
| 132 |
run.click(transcribe, [audio, start, duration, melody, piano],
|
| 133 |
+
[status, gallery, pdf, preview, downloads, abc],
|
| 134 |
api_name="transcribe", concurrency_limit=1)
|
| 135 |
+
gr.Markdown("Powered by [SheetSage2](https://huggingface.co/m-a-p/SheetSage2), "
|
| 136 |
+
"CC BY-NC 4.0. Temporary files expire after 24 hours.")
|
| 137 |
+
|
| 138 |
def oauth_metadata():
|
| 139 |
+
return {
|
| 140 |
+
"client_id": "https://reubencf-score-studio-api.hf.space/.well-known/oauth-cimd",
|
| 141 |
+
"client_name": "Kiku AI",
|
| 142 |
+
"client_uri": "https://huggingface.co/spaces/Reubencf/Score-Studio-API",
|
| 143 |
+
# Every origin that signs users in must be listed here, so the rebranded
|
| 144 |
+
# app and the original Score Studio deployment are both kept valid.
|
| 145 |
+
"redirect_uris": [
|
| 146 |
+
"http://localhost/auth/callback", "http://127.0.0.1/auth/callback",
|
| 147 |
+
"https://reubencf-kiku-ai.hf.space/auth/callback",
|
| 148 |
+
"https://reubencf-score-studio.hf.space/auth/callback",
|
| 149 |
+
],
|
| 150 |
+
"token_endpoint_auth_method": "none",
|
| 151 |
+
"grant_types": ["authorization_code"],
|
| 152 |
+
"response_types": ["code"],
|
| 153 |
+
"scope": "openid profile",
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
def health():
|
| 157 |
+
return {"status": "ready", "model": MODEL_ID, "revision": REVISION}
|
| 158 |
+
|
| 159 |
if __name__ == "__main__":
|
| 160 |
# The native Gradio launch hook registers the GPU worker with Spaces.
|
| 161 |
app, _, _ = demo.queue(max_size=5).launch(
|