"""Big Ears on Hugging Face Spaces — the same pipeline, reachable from a phone. The desktop app (``soloscribe/webapp/``) assumes a Mac with the repo checked out. This is the same ``run_pipeline`` behind a Gradio page, so a guitarist can open a URL on whatever device is to hand, drop in a recording, and get back the Guitar Pro file and the audit report. Three things about the free CPU tier shape this file: * TWO vCPUs. Demucs separation on 2 cores runs for minutes on a 90-second clip, so the isolation checkbox DEFAULTS TO OFF and says in its own label what it costs. The desktop app defaults it to "auto" because a laptop can afford it; here that default would read as a hang. * A SHARED, SLEEPING machine. Free Spaces sleep when idle, so the first request after a quiet spell pays a cold start on top of the transcription. Clips are capped at MAX_CLIP_SECONDS for the same reason — one long upload would hold the only worker while everyone else waits. * NOBODY IS WATCHING THE LOGS. Every failure has to come back as a sentence the guitarist can act on, so the handler catches everything and renders an apology rather than letting Gradio surface a traceback. The transcription backend is NOT the same as on the developer's Mac — see ``pin_transcription_backend`` below, which is where that gets decided and said out loud. """ from __future__ import annotations import html import os import sys import tempfile import traceback from pathlib import Path HERE = Path(__file__).resolve().parent if str(HERE) not in sys.path: # The Space runs from its own root with the package vendored alongside this # file (see make_space.py), where `python app.py` already puts this # directory first on sys.path — so this line is only insurance for the case # where something IMPORTS app.py from elsewhere. # # APPEND, do not insert. Prepending would let the vendored snapshot shadow # the real soloscribe/ package for the rest of the process, which in the # repo's own test run means every test after this one silently exercising a # copy that may be a commit or two behind. make_space.py --check is what # guards the copy; sys.path is the wrong place to do it. sys.path.append(str(HERE)) import gradio as gr # Bound as a module global on purpose: the tests monkeypatch # `app.run_pipeline`, exactly as tests/test_webapp.py does for the desktop # server. The handler must therefore call the global, never a captured local. from soloscribe.pipeline import run_pipeline # -------------------------------------------------------------------------- # limits # -------------------------------------------------------------------------- MAX_CLIP_SECONDS = 180.0 # A ceiling on the upload itself, so a mis-clicked album rip is rejected at the # door rather than after it has been buffered. Three minutes of 24-bit stereo # WAV at 96 kHz is about 100 MB; anything larger is not the clip they meant. MAX_UPLOAD = "150mb" # Everything the pipeline writes lands under here, and launch(allowed_paths=...) # is told about it so Gradio will serve the finished files back. OUTPUT_ROOT = Path( os.environ.get("SOLOSCRIBE_OUT", Path(tempfile.gettempdir()) / "soloscribe-space") ) OUTPUT_ROOT.mkdir(parents=True, exist_ok=True) KEYS: list[tuple[str, str]] = [ ("C major", "C"), ("D♭ major", "Db"), ("D major", "D"), ("E♭ major", "Eb"), ("E major", "E"), ("F major", "F"), ("F♯ major", "F#"), ("G major", "G"), ("A♭ major", "Ab"), ("A major", "A"), ("B♭ major", "Bb"), ("B major", "B"), ("A minor", "Am"), ("B♭ minor", "Bbm"), ("B minor", "Bm"), ("C minor", "Cm"), ("C♯ minor", "C#m"), ("D minor", "Dm"), ("E♭ minor", "Ebm"), ("E minor", "Em"), ("F minor", "Fm"), ("F♯ minor", "F#m"), ("G minor", "Gm"), ("G♯ minor", "G#m"), ] KEY_CHOICES = [label for label, _ in KEYS] KEY_VALUES = dict(KEYS) FEEL_CHOICES = ["Let me work it out", "Swing", "Straight"] FEEL_VALUES = {"Let me work it out": "auto", "Swing": "on", "Straight": "off"} MODE_CHOICES = ["A single-note solo", "Chords as well (experimental — single notes are far more accurate)"] MODE_VALUES = {"A single-note solo": "solo", "Chords as well (experimental — single notes are far more accurate)": "poly"} BEATS_CHOICES = ["4/4 — four in a bar", "3/4 — waltz time", "6/8 — counted as six"] BEATS_VALUES = {BEATS_CHOICES[0]: 4, BEATS_CHOICES[1]: 3, BEATS_CHOICES[2]: 6} # Plain readings for failures whose own message would tell a guitarist nothing. # Lifted from soloscribe/webapp/server.py so the two front ends apologise in the # same words; keyed on class name so this layer need not import the audio stack. PLAIN_ERRORS: dict[str, str] = { "NoBackendError": ( "I could not read that recording at all. The file may be damaged, or it " "may not really be audio. Try playing it in another app to check, then " "save it again as a WAV or an MP3." ), "DecodeError": ( "I could not read that recording at all. The file may be damaged, or it " "may not really be audio. Try playing it in another app to check, then " "save it again as a WAV or an MP3." ), "FileNotFoundError": "I lost track of your recording. Please choose it again.", "MemoryError": ( "That recording is too big for me to hold all at once. Try a shorter " "stretch of it." ), } VERDICT_COPY: dict[str, tuple[str, str, str]] = { "high": ( "high", "This one came out well.", "Most of what you played is on the page. Read the report anyway — it " "names the bars I was least sure of, and that is where your ears are " "worth more than mine.", ), "medium": ( "medium", "A fair transcription, with places to check.", "Enough of it is right to be worth working from, but I was guessing in " "places. The report marks which bars; start there rather than at the " "beginning.", ), "low": ( "low", "I struggled with this one.", "I would not learn this tab without the recording in your ears, bar by " "bar. The report says where it went wrong, and usually why.", ), } ACCENT = {"high": "#2f7d46", "medium": "#b0700d", "low": "#b3261e"} # -------------------------------------------------------------------------- # transcription backend # -------------------------------------------------------------------------- # basic-pitch ships the same model in four formats and picks one at import # time, in a fixed if/elif order — TensorFlow, then CoreML, then TFLite, then # ONNX (basic_pitch/__init__.py:81-88) — from whichever runtimes happen to be # importable. Which of those pip installed is decided by markers on the # package's own dependencies rather than by anything in requirements.txt: # coremltools on macOS, tflite-runtime on Linux under Python 3.10, TensorFlow # on Linux under Python 3.11 or newer (basic_pitch-0.4.0.dist-info/METADATA). # So the developer's Mac runs CoreML and the Space runs something else, and the # `[onnx]` extra alone does NOT make it ONNX — the extra installs onnxruntime, # but ONNX is last in that chain and only wins when the other three are absent. # # That is a fragile way to decide what actually does the work, so this pins it: # pick a backend whose runtime is importable AND whose model file is on disk, # and hand that path to basic-pitch explicitly. `Model.__init__` still tries # each present runtime in order (inference.py:77-150), but it only ever gets # the one file, so the choice below is the choice that happens. BACKEND_PREFERENCE = ("onnx", "tflite", "tf", "coreml") _FLAG_FOR = { "onnx": "ONNX_PRESENT", "tflite": "TFLITE_PRESENT", "tf": "TF_PRESENT", "coreml": "CT_PRESENT", } def pin_transcription_backend() -> str: """Fix basic-pitch's model format. Returns a line for the Space logs. Never raises: a Space that will not start is worse than one that starts on whatever basic-pitch would have chosen unaided. """ try: import basic_pitch import basic_pitch.inference as inference from basic_pitch import FilenameSuffix, build_icassp_2022_model_path except Exception as exc: # basic_pitch/__init__.py:95 raises NameError rather than anything # descriptive when no runtime at all is importable, because # _default_model_type is only assigned inside the if/elif chain. return ( f"transcription backend: UNAVAILABLE ({type(exc).__name__}: {exc}). " "No transcription will be possible — check that basic-pitch and " "one of onnxruntime / tflite-runtime / tensorflow installed." ) present = {name: bool(getattr(basic_pitch, flag, False)) for name, flag in _FLAG_FOR.items()} auto = getattr(getattr(basic_pitch, "ICASSP_2022_MODEL_PATH", None), "name", "unknown") for name in BACKEND_PREFERENCE: if not present[name]: continue try: path = build_icassp_2022_model_path(FilenameSuffix[name]) except Exception: continue if not Path(path).exists(): continue original = inference.predict def predict(audio_path, model_or_model_path=path, *args, **kwargs): return original(audio_path, model_or_model_path, *args, **kwargs) # transcribe.py imports `predict` from this module inside the function # body at call time (transcribe.py:562), so rebinding the attribute # here reaches it. Rebinding basic_pitch.ICASSP_2022_MODEL_PATH would # not: it is baked into predict's default argument at import. inference.predict = predict installed = ", ".join(n for n, ok in present.items() if ok) or "none" return ( f"transcription backend: {name} ({Path(path).name}); " f"runtimes present: {installed}; basic-pitch would have chosen {auto}" ) return ( "transcription backend: left to basic-pitch " f"(would choose {auto}); no preferred runtime had a model file on disk" ) # -------------------------------------------------------------------------- # input handling # -------------------------------------------------------------------------- def probe_duration(path: str) -> float: """Seconds of audio, read from the header where possible. soundfile reads the header alone, which is instant and does not decode the file; librosa is the fallback for anything libsndfile will not open. A failure here is not fatal — the caller treats an unknown duration as acceptable and lets the pipeline be the one to complain about the file. """ try: import soundfile as sf info = sf.info(path) if info.frames and info.samplerate: return float(info.frames) / float(info.samplerate) except Exception: pass try: import librosa return float(librosa.get_duration(path=path)) except Exception: return 0.0 def _minutes(seconds: float) -> str: """'3 minutes 12 seconds' — how a person says a duration out loud.""" total = int(round(seconds)) m, s = divmod(total, 60) if m and s: return f"{m} minute{'s' if m != 1 else ''} {s} second{'s' if s != 1 else ''}" if m: return f"{m} minute{'s' if m != 1 else ''}" return f"{s} second{'s' if s != 1 else ''}" def _number(value, label: str, *, minimum: float | None = None, zero_means_blank: bool = False) -> float | None: """A Gradio number box, which hands back None, '', a float or a string. ``zero_means_blank``: deployed Gradio number widgets were observed to hand back 0 for fields the user never touched (the first real user hit this — a phantom tempo of 0 refused the run). For fields where zero carries no meaning (a tempo, an end time), 0 is read as "left blank". """ if value is None: return None text = str(value).strip().replace(",", ".") if not text: return None try: number = float(text) except ValueError: raise ValueError( f"I could not read {label} as a number. Leave it blank if you are not sure." ) if number != number or number in (float("inf"), float("-inf")): raise ValueError( f"I could not read {label} as a number. Leave it blank if you are not sure." ) if zero_means_blank and number == 0: return None if minimum is not None and number < minimum: raise ValueError(f"{label.capitalize()} cannot be less than {minimum:g}.") return number def normalise_chords(raw: str | None) -> str | None: """Textarea (one bar per line, or bars split on |) into the pipeline's format.""" text = (raw or "").strip() if not text: return None bars: list[str] = [] for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"): for bar in line.split("|"): bar = " ".join(bar.split()) if bar: bars.append(bar) return "|".join(bars) or None # -------------------------------------------------------------------------- # the page's replies # -------------------------------------------------------------------------- def _note(body: str, *, heading: str = "", accent: str = "#8a8a8a") -> str: head = f"
{html.escape(chunk.strip())}
" for chunk in body.split("\n\n") if chunk.strip() ) return ( f"Where a call was genuinely close, the other " "reading is attached below, with the same measurements — choose with " "your ears and the numbers together.
{html.escape(blurb)}
", _bullets("Why I say that", reasons), _bullets("Worth knowing before you trust it", warnings), _variants_html(metrics), "" "Both files are above. The report is a single web page — download it " "and open it in any browser; it has the piano roll, the bar-by-bar " "numbers, and your recording next to my reconstruction so you can hear " "the difference." "
", "