Spaces:
Sleeping
Sleeping
| """audio-brief MVP v1 — Gradio UI. | |
| Run: python app.py | |
| Drop a file → 40-60 s later you get a one-page brief: a client-facing | |
| paragraph, the structured data, and three copy-paste outputs for the next | |
| workflow step (SA3 variation, SA3 match-style, Ableton clip plan). | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import secrets | |
| import sys | |
| import traceback | |
| import urllib.parse | |
| from pathlib import Path | |
| import gradio as gr | |
| ### crate_picker rationale | |
| # Gradio v6's gr.Radio strict-validates the incoming value against the | |
| # component's `choices` attribute on the server side. We use the picker | |
| # as a dynamic chip strip over the on-disk crate — `choices` is updated | |
| # via `gr.update(choices=…)` from handlers — but in v6 those updates | |
| # only flow to the client UI, not to the server-side validator, so | |
| # legitimate tile ids get rejected as "not in choices". | |
| # | |
| # gr.Dropdown(allow_custom_value=True) skips that strict check and lets | |
| # any string through to our handlers (which already look up the tile id | |
| # against the crate dir themselves). We keep the strip look via CSS | |
| # (.dc-crate-strip in theme.py) — the visual is unchanged. | |
| import outputs | |
| import share | |
| import waveform | |
| import wallet | |
| import sa3 | |
| import crate | |
| from narrative import ( | |
| write_brief, write_mix_chain, write_audio_only, | |
| write_sa3_prompt_blended, write_sa3_prompt, write_sa3_prompt_lens, | |
| write_sa3_prompt_upload_lens, | |
| ) | |
| import morpho | |
| from pipeline import BPM_PRIORS, Analysis, analyze, to_json | |
| from theme import ( | |
| THEME, CUSTOM_CSS, | |
| brand_html, pollen_pill_html, metric_grid_html, | |
| session_spend_pill_html, cost_pip_html, | |
| palette_toggle_html, | |
| ) | |
| GENRE_CHOICES = [ | |
| "default", "dnb", "jungle", "trap", "hip-hop", "house", | |
| "deep-house", "techno", "ambient", "downtempo", "pop", "rock", | |
| ] | |
| # Model dropdowns are populated at startup from Pollinations' /v1/models | |
| # catalog (see models.py), filtered by modality. A/B get text→text models; | |
| # C gets audio-input multimodals only. Falls back to a curated list if the | |
| # catalog fetch fails. | |
| import models as _models | |
| TEXT_MODELS = _models.text_models() | |
| AUDIO_MODEL_CHOICES = _models.audio_model_choices() # [(label, value), ...] | |
| DEFAULT_MODEL_A = "claude" if "claude" in TEXT_MODELS else (TEXT_MODELS[0] if TEXT_MODELS else "openai") | |
| DEFAULT_MODEL_B = "openai-large" if "openai-large" in TEXT_MODELS else ( | |
| "openai" if "openai" in TEXT_MODELS else (TEXT_MODELS[1] if len(TEXT_MODELS) > 1 else "openai") | |
| ) | |
| DEFAULT_MODEL_C = "gemini" if "gemini" in [v for _, v in AUDIO_MODEL_CHOICES] else "openai-audio" | |
| def _short_caption(a: Analysis) -> str: | |
| parts = [] | |
| if a.bpm: | |
| parts.append(f"{int(round(a.bpm))} BPM") | |
| if a.key and a.key_mode: | |
| parts.append(f"{a.key} {a.key_mode}") | |
| if a.tags_mood: | |
| parts.append(a.tags_mood[0]["label"]) | |
| if a.tags_genre: | |
| parts.append(a.tags_genre[0]["label"]) | |
| if a.duration_s: | |
| parts.append(f"{int(round(a.duration_s))}s") | |
| return " · ".join(parts) if parts else "(empty)" | |
| def _metrics_html(a: Analysis | None) -> str: | |
| """6-up metric tile grid for the Analysis tab. | |
| Renders the measured numbers the design highlights: BPM, KEY, LUFS, | |
| TRUE PK, LRA, LENGTH. Empty placeholders when a stage hasn't filled | |
| the value yet.""" | |
| if a is None: | |
| return metric_grid_html([ | |
| ("BPM", "—"), ("KEY", "—"), ("LUFS", "—"), | |
| ("TRUE PK", "—"), ("LRA", "—"), ("LENGTH", "—"), | |
| ]) | |
| bpm = "—" if a.bpm is None else f"{a.bpm:.0f}" | |
| if a.key and a.key_mode: | |
| key = f"{a.key} {a.key_mode[:3]}" | |
| elif a.key: | |
| key = a.key | |
| else: | |
| key = "—" | |
| # LUFS-I and true peak are always negative dB; render with a real minus sign. | |
| lufs = "—" if a.lufs_i is None else f"−{abs(a.lufs_i):.1f}" | |
| peak = "—" if a.true_peak_db is None else f"−{abs(a.true_peak_db):.1f}" | |
| lra = "—" if a.lufs_lra is None else f"{a.lufs_lra:.1f}" | |
| if a.duration_s: | |
| secs = int(round(a.duration_s)) | |
| length = f"{secs // 60}:{secs % 60:02d}" | |
| else: | |
| length = "—" | |
| return metric_grid_html([ | |
| ("BPM", bpm), ("KEY", key), ("LUFS", lufs), | |
| ("TRUE PK", peak), ("LRA", lra), ("LENGTH", length), | |
| ]) | |
| def _data_table(a: Analysis) -> list[list[str]]: | |
| rows = [ | |
| ["duration", f"{a.duration_s} s"], | |
| ["bpm", str(a.bpm)], | |
| ["bpm_prior", str(a.bpm_prior)], | |
| ["key", f"{a.key} {a.key_mode}".strip()], | |
| ["key_correlation", str(a.key_correlation)], | |
| ["lufs_i", str(a.lufs_i)], | |
| ["lufs_lra", str(a.lufs_lra)], | |
| ["true_peak_db", str(a.true_peak_db)], | |
| ["voiceover_present", str(a.voiceover_present)], | |
| ["sections", str(len(a.sections))], | |
| ["downbeats", str(len(a.downbeats))], | |
| ["stems", ", ".join(sorted(a.stems)) or "(none)"], | |
| ["bass_midi", a.bass_midi_path or "(none)"], | |
| ] | |
| return rows | |
| def _sections_table(a: Analysis) -> list[list[str]]: | |
| return [ | |
| [s["label"], str(s["start"]), str(s["end"]), str(s["length"])] | |
| for s in a.sections | |
| ] | |
| def _tags_table(a: Analysis) -> list[list[str]]: | |
| out: list[list[str]] = [] | |
| for kind, items in ( | |
| ("genre", a.tags_genre), | |
| ("mood", a.tags_mood), | |
| ("instrument", a.tags_instrument), | |
| ): | |
| for t in items[:5]: | |
| out.append([kind, t.get("label", "?"), f"{t.get('score', 0):.3f}"]) | |
| return out | |
| def _format_errors(a: Analysis) -> str: | |
| if not a.errors: | |
| return "no errors" | |
| return "\n".join(f"• {e['stage']}: {e['error']}" for e in a.errors) | |
| def _format_timings(a: Analysis) -> str: | |
| if not a.timings: | |
| return "no timings" | |
| total = sum(a.timings.values()) | |
| rows = [f"{k:>14} {v:>6.2f} s" for k, v in a.timings.items()] | |
| rows.append(f"{'total':>14} {total:>6.2f} s") | |
| return "\n".join(rows) | |
| _PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] | |
| def _midi_note_name(p: int) -> str: | |
| """MIDI note number → 'A1' / 'G#2' style label.""" | |
| octave = (p // 12) - 1 | |
| return f"{_PITCH_NAMES[p % 12]}{octave}" | |
| def _bass_midi_summary(a: Analysis) -> dict | None: | |
| """Compact one-line description of the basic-pitch bass-stem MIDI for | |
| the brief LLM. Without this, basic-pitch runs every analysis but its | |
| output is silently dropped from the brief surface — only the deterministic | |
| Ableton clip-plan output ever sees it. With this, the brief can say | |
| 'bass walks G1 → C2 at ~1.4 notes/sec' instead of generic flannel. | |
| Returns None if no notes were extracted (no bass stem, MIDI parse | |
| failed, etc.) — keeps the payload tidy.""" | |
| notes = a.bass_notes or [] | |
| if not notes: | |
| return None | |
| pitches = [int(n["pitch"]) for n in notes if "pitch" in n] | |
| if not pitches: | |
| return None | |
| durations = [ | |
| float(n["end"]) - float(n["start"]) | |
| for n in notes | |
| if "start" in n and "end" in n and float(n["end"]) > float(n["start"]) | |
| ] | |
| span_s = max(float(n.get("end", 0.0)) for n in notes) or 1e-6 | |
| return { | |
| "note_count": len(notes), | |
| "pitch_low": _midi_note_name(min(pitches)), | |
| "pitch_high": _midi_note_name(max(pitches)), | |
| "pitch_low_midi": int(min(pitches)), | |
| "pitch_high_midi": int(max(pitches)), | |
| "notes_per_sec": round(len(notes) / span_s, 2), | |
| "mean_note_dur_s": round(sum(durations) / len(durations), 3) if durations else None, | |
| } | |
| def _brief_payload(a: Analysis) -> dict: | |
| """Numeric + categorical analysis data fed to the brief LLM. Keep | |
| this minimal but include EVERY measurement we actually paid for — | |
| silently dropping demucs/basic-pitch output leaves quality on the | |
| table (demucs alone is ~40s of the analysis wall-clock).""" | |
| return { | |
| "bpm": a.bpm, | |
| "key": f"{a.key} {a.key_mode}" if a.key else None, | |
| "duration_s": a.duration_s, | |
| "sections": a.sections, | |
| "lufs_i": a.lufs_i, | |
| "lufs_lra": a.lufs_lra, | |
| "voiceover_present": a.voiceover_present, | |
| "top_genre": a.top_genre(), | |
| "top_mood": a.top_mood(), | |
| "top_instrument": a.top_instrument(), | |
| # Top-3 measured tags (essentia). These carry the vibe the brief | |
| # paragraph used to infer — the SA3 prompt-writer reads them | |
| # directly now, so the brief-writing LLM call can be skipped. | |
| # Empty lists where tagging was skipped (fast mode / no essentia). | |
| "genre_tags": [t["label"] for t in a.tags_genre[:3]], | |
| "mood_tags": [t["label"] for t in a.tags_mood[:3]], | |
| "instrument_tags": [t["label"] for t in a.tags_instrument[:3]], | |
| "stems_found": sorted(a.stems), | |
| # Per-stem RMS / peak / spectral-centroid — demucs already ran; | |
| # without this the brief LLM gets 'yes there are stems' and nothing | |
| # else. With it, the LLM can characterise the mix (heavy bass, | |
| # bright vocals, hi-hat-forward) without guessing. | |
| "stem_stats": a.stem_stats or None, | |
| # basic-pitch ran on the bass stem; surface a compact summary so | |
| # the brief can describe the bassline character. | |
| "bass_midi_summary": _bass_midi_summary(a), | |
| } | |
| def _chain_payload(a: Analysis) -> dict: | |
| return { | |
| **_brief_payload(a), | |
| "true_peak_db": a.true_peak_db, | |
| # stem_stats is already in _brief_payload now (it always should | |
| # have been); keep this for backwards compatibility with bundles | |
| # that look for it explicitly here. | |
| "stem_stats": a.stem_stats, | |
| } | |
| # ── Ingest: video containers + YouTube links ───────────────────────────── | |
| # librosa/soundfile can decode wav/mp3/flac/ogg/aiff natively, but NOT | |
| # video containers (mp4/mov/webm/mkv) or the aac/m4a/opus family. For | |
| # those we shell out to ffmpeg once and hand the pipeline a plain | |
| # 44.1 kHz stereo WAV. Extracted files land in CRATE_DIR/ingest — the | |
| # crate dir is whitelisted via gr.set_static_paths, so the anchor | |
| # player's /gradio_api/file= URL can serve them. | |
| _EXTRACT_EXTS = { | |
| ".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", # video containers | |
| ".m4a", ".aac", ".opus", ".wma", # audio soundfile can't read | |
| } | |
| _INGEST_DIR = crate.CRATE_DIR / "ingest" | |
| def _prepare_source_audio(audio_path: str) -> str: | |
| """Return a path the analysis pipeline can decode. | |
| Pass-through for formats librosa handles natively; ffmpeg-extract to | |
| WAV for video containers and aac-family audio. Raises RuntimeError | |
| with a user-facing message when ffmpeg is missing or the file has no | |
| audio stream (e.g. a screen recording with the mic muted). | |
| """ | |
| import shutil | |
| import subprocess | |
| src = Path(audio_path) | |
| if src.suffix.lower() not in _EXTRACT_EXTS: | |
| return audio_path | |
| if not shutil.which("ffmpeg"): | |
| raise RuntimeError( | |
| f"'{src.suffix}' needs ffmpeg to extract the audio track, and " | |
| "ffmpeg isn't installed on this server. Convert to WAV/MP3 " | |
| "and re-upload." | |
| ) | |
| _INGEST_DIR.mkdir(parents=True, exist_ok=True) | |
| # Keep the original stem so the crate tile / anchor player show a | |
| # recognisable name; secrets suffix avoids collisions between users | |
| # uploading files with the same name. | |
| safe_stem = "".join(c if c.isalnum() or c in "-_ " else "_" for c in src.stem)[:60] | |
| out = _INGEST_DIR / f"{safe_stem}-{secrets.token_hex(4)}.wav" | |
| proc = subprocess.run( | |
| ["ffmpeg", "-y", "-i", str(src), "-vn", | |
| "-ar", "44100", "-ac", "2", "-acodec", "pcm_s16le", str(out)], | |
| capture_output=True, text=True, timeout=300, | |
| ) | |
| if proc.returncode != 0 or not out.exists() or out.stat().st_size < 1024: | |
| tail = (proc.stderr or "").strip().splitlines()[-1:] or ["unknown ffmpeg error"] | |
| raise RuntimeError( | |
| f"couldn't extract audio from '{src.name}' — {tail[0]}. " | |
| "Does the file actually contain an audio track?" | |
| ) | |
| print(f"[ingest] extracted {src.name} → {out.name} " | |
| f"({out.stat().st_size // 1024} KB)", flush=True) | |
| return str(out) | |
| _YT_HOSTS = {"youtube.com", "www.youtube.com", "m.youtube.com", | |
| "music.youtube.com", "youtu.be"} | |
| _YT_MAX_SECONDS = 15 * 60 | |
| def fetch_youtube_audio(url: str): | |
| """Download a YouTube video's audio track as WAV into the ingest dir. | |
| Returns (gr.update for audio_in, status markdown). Never raises — | |
| all failure modes come back as a status message, because on HF | |
| Spaces YouTube frequently blocks datacenter IPs ('Sign in to | |
| confirm you're not a bot') and that shouldn't traceback the UI. | |
| """ | |
| url = (url or "").strip() | |
| if not url: | |
| return gr.update(), "⚠️ paste a YouTube link first" | |
| try: | |
| host = urllib.parse.urlparse(url).netloc.lower() | |
| except ValueError: | |
| host = "" | |
| if host not in _YT_HOSTS: | |
| return gr.update(), f"⚠️ not a YouTube link (host: `{host or '?'}`)" | |
| try: | |
| import yt_dlp | |
| except ImportError: | |
| return gr.update(), ("⚠️ `yt-dlp` isn't installed on this server — " | |
| "`pip install yt-dlp` and restart.") | |
| import shutil | |
| if not shutil.which("ffmpeg"): | |
| return gr.update(), ("⚠️ ffmpeg isn't installed — needed to convert " | |
| "the YouTube audio to WAV.") | |
| _INGEST_DIR.mkdir(parents=True, exist_ok=True) | |
| ydl_opts = { | |
| "format": "bestaudio/best", | |
| "outtmpl": str(_INGEST_DIR / "yt-%(id)s.%(ext)s"), | |
| "noplaylist": True, | |
| "quiet": True, | |
| "noprogress": True, | |
| "no_warnings": True, | |
| "postprocessors": [ | |
| {"key": "FFmpegExtractAudio", "preferredcodec": "wav"}, | |
| ], | |
| } | |
| try: | |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: | |
| info = ydl.extract_info(url, download=False) | |
| duration = int(info.get("duration") or 0) | |
| if duration > _YT_MAX_SECONDS: | |
| return gr.update(), ( | |
| f"⚠️ that video is {duration // 60} min — too long to " | |
| f"analyse (limit {_YT_MAX_SECONDS // 60} min). demucs " | |
| "on a track that size would take a very long time." | |
| ) | |
| info = ydl.extract_info(url, download=True) | |
| except Exception as e: # yt-dlp raises many exception types | |
| msg = str(e) | |
| if "Sign in to confirm" in msg or "bot" in msg.lower(): | |
| return gr.update(), ( | |
| "⚠️ YouTube blocked this server's IP (common on hosted " | |
| "Spaces). Try again later, or download the audio locally " | |
| "and upload the file instead." | |
| ) | |
| return gr.update(), f"⚠️ download failed: {msg[:300]}" | |
| out = _INGEST_DIR / f"yt-{info['id']}.wav" | |
| if not out.exists(): | |
| return gr.update(), "⚠️ download finished but no WAV landed — check server logs" | |
| title = info.get("title") or info["id"] | |
| print(f"[ingest] youtube {info['id']} → {out.name} " | |
| f"({out.stat().st_size // 1024} KB)", flush=True) | |
| return (gr.update(value=str(out)), | |
| f"✅ fetched **{title}** ({duration or '?'}s) — now hit **Analyze audio**") | |
| def run_brief(audio_path: str | None, bpm_mode: str, bpm_prior_choice: str, bpm_prior_num: float, model_choice: str, api_key: str = "", original_prompt: str = ""): | |
| """Streaming generator — yields partial outputs as each stage finishes so | |
| the UI fills in progressively instead of all-at-once after a long wait. | |
| `original_prompt`: the source SA3 prompt that produced this audio (when | |
| the tile came from a Generate-then-Use-for-Analysis flow). When set, | |
| the derived SA3 variation prompt is built via the LLM `write_sa3_prompt_blended` | |
| call which fuses the user's intent vocabulary with the measured arc — | |
| so variants stay in the user's stylistic lane instead of drifting toward | |
| whatever the LLM thought it heard. Empty string for uploaded files. | |
| Output tuple (16 slots): | |
| paragraph, caption, anchor_player, wave, section_seek, | |
| data_tbl, sections_tbl, tags_tbl, | |
| errors, timings, sa3_var, sa3_match, clip_plan, mix_chain, | |
| raw_json, state | |
| """ | |
| # Waveform Image + section-seek strip are hidden by default; revealed | |
| # only once we have a real rendered PNG (stages 2+). On error or | |
| # pre-analysis we keep them hidden so empty bordered boxes don't sit. | |
| wave_hide = gr.update(value=None, visible=False) | |
| seek_hide = gr.update(value="", visible=False) | |
| player_hide = gr.update(value="", visible=False) | |
| if not audio_path: | |
| empty = "(drop a file first)" | |
| yield (empty, empty, player_hide, wave_hide, seek_hide, _metrics_html(None), [], [], [], empty, empty, empty, empty, empty, empty, "{}", None) | |
| return | |
| # Stage 1 — announce; clear stale state. | |
| yield ("_analyzing audio…_", "running pipeline", player_hide, wave_hide, seek_hide, _metrics_html(None), [], [], [], | |
| "", "", "(waiting on analysis)", "(waiting on analysis)", | |
| "(waiting on analysis)", "(waiting on analysis)", "{}", None) | |
| prior: float | str | |
| if bpm_mode and bpm_mode.startswith("Manual") and bpm_prior_num and bpm_prior_num > 0: | |
| prior = float(bpm_prior_num) | |
| else: | |
| prior = bpm_prior_choice or "default" | |
| try: | |
| # Video containers / aac-family uploads: extract the audio track | |
| # to WAV first — librosa can't decode them directly. No-op for | |
| # wav/mp3/flac/ogg. audio_path is rebound so every downstream | |
| # consumer (anchor player, crate tile, share bundle) uses the WAV. | |
| audio_path = _prepare_source_audio(audio_path) | |
| # run_tags=True → measured genre/mood/instrument via essentia when | |
| # it's available (Linux/Spaces). Degrades to a skipped stage on | |
| # platforms without the wheels, so this is safe to leave on. | |
| a = analyze(audio_path, bpm_prior=prior, run_tags=True, run_embedding=False) | |
| except Exception as e: | |
| traceback.print_exc() | |
| err = f"pipeline failed: {type(e).__name__}: {e}" | |
| yield (err, err, player_hide, wave_hide, seek_hide, _metrics_html(None), [], [], [], err, err, err, err, err, err, "{}", None) | |
| return | |
| # Stage 2 — analysis done; render every deterministic output. LLM still pending. | |
| caption = _short_caption(a) | |
| wave_png = waveform.render(a) | |
| # Reveal the anchor player + waveform + section-seek strip now that | |
| # we have the data. Anchor player uses Gradio's `/gradio_api/file=...` | |
| # proxy to serve from the crate dir (whitelisted via set_static_paths). | |
| player_show = gr.update(value=_anchor_player_html(audio_path), visible=True) | |
| wave_show = gr.update(value=wave_png, visible=True) | |
| seek_show = gr.update(value=_section_seek_html(a), visible=True) | |
| sa3_var_partial = outputs.sa3_variation_prompt(a, "") | |
| sa3_match_partial = outputs.sa3_match_style_prompt(a, "") | |
| clip_plan = outputs.ableton_clip_plan(a) | |
| raw = to_json(a) | |
| state = {"analysis": a, "model": model_choice, "audio_path": audio_path, | |
| "brief_payload": _brief_payload(a), "chain_payload": _chain_payload(a)} | |
| yield (f"_generating narrative via **{model_choice}**…_", caption, | |
| player_show, wave_show, seek_show, _metrics_html(a), | |
| _data_table(a), _sections_table(a), _tags_table(a), | |
| _format_errors(a), _format_timings(a), | |
| sa3_var_partial, sa3_match_partial, clip_plan, | |
| f"_generating mix chain via **{model_choice}**…_", raw, state) | |
| # Stage 3 — fire both LLM calls in parallel, yield whichever finishes first. | |
| from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED | |
| # Short-circuit when there's no wallet: the LLM calls would just 401 | |
| # and surface raw LLMError text in the brief / mix-chain panels, which | |
| # looks like the whole analysis broke. Render a friendly inline nudge | |
| # instead, and skip the round-trips. Measurement-only insights are | |
| # still complete — section table, BPM, key, waveform, derived prompt | |
| # (basic compression) are all already yielded above. | |
| if not wallet.get_key(session_key=api_key): | |
| wallet_nudge = ("_📌 Pollinations not connected — connect the pollen pill (top right) " | |
| "to write the brief + mix chain. Measurement and the derived " | |
| "SA3 prompt are ready below._") | |
| yield (wallet_nudge, caption, | |
| player_show, wave_show, seek_show, _metrics_html(a), | |
| _data_table(a), _sections_table(a), _tags_table(a), | |
| _format_errors(a), _format_timings(a), | |
| sa3_var_partial, sa3_match_partial, clip_plan, | |
| wallet_nudge, raw, state) | |
| return | |
| paragraph = "(narrative pending)" | |
| llm_chain = "" | |
| sa3_var_final = sa3_var_partial | |
| sa3_match_final = sa3_match_partial | |
| mix_chain_final = f"_generating mix chain via **{model_choice}**…_" | |
| with ThreadPoolExecutor(max_workers=2) as pool: | |
| fut_brief = pool.submit(write_brief, _brief_payload(a), model=model_choice, api_key=api_key) | |
| fut_chain = pool.submit(write_mix_chain, _chain_payload(a), model=model_choice, api_key=api_key) | |
| pending = {fut_brief, fut_chain} | |
| while pending: | |
| done, pending = wait(pending, return_when=FIRST_COMPLETED) | |
| for fut in done: | |
| try: | |
| res = fut.result() | |
| # write_brief / write_mix_chain now return (text, resolved_model) | |
| text = res[0] if isinstance(res, tuple) else res | |
| except Exception as e: | |
| text = f"(failed — {type(e).__name__}: {e})" | |
| if fut is fut_brief: | |
| paragraph = text | |
| safe = text if not text.startswith("(") else "" | |
| sa3_match_final = outputs.sa3_match_style_prompt(a, safe) | |
| # Derived prompt — fan out THREE lens variants in | |
| # parallel (MATCH/LOOSE/FREE). Default-selected is | |
| # LOOSE — the middle ground that keeps feel but doesn't | |
| # inherit the anchor's exact section timings (which is | |
| # what makes variants come back sparse when the anchor | |
| # has a long quiet intro). All three results land in | |
| # state["sa3_lenses"] so the chip-row in the UI can | |
| # swap between them instantly without re-calling. | |
| sa3_var_final = outputs.sa3_variation_prompt(a, safe) | |
| state["sa3_lenses"] = {"match": "", "loose": "", "free": ""} | |
| if safe: | |
| try: | |
| if original_prompt: | |
| # Run all 3 lenses concurrently — one wall-time cost. | |
| with ThreadPoolExecutor(max_workers=3) as lens_pool: | |
| lens_futs = { | |
| lens_pool.submit( | |
| write_sa3_prompt_lens, | |
| original_prompt=original_prompt, | |
| brief_payload=_brief_payload(a), | |
| brief_text=safe, | |
| lens=lens_name, | |
| model=model_choice, | |
| api_key=api_key, | |
| ): lens_name | |
| for lens_name in ("match", "loose", "free") | |
| } | |
| for lf, lens_name in lens_futs.items(): | |
| try: | |
| lp, _ = lf.result() | |
| if lp and not lp.startswith("("): | |
| state["sa3_lenses"][lens_name] = lp | |
| except Exception as e: | |
| print(f"[lens {lens_name}] {type(e).__name__}: {e}", | |
| file=sys.stderr, flush=True) | |
| # Default-display: LOOSE (middle ground). | |
| if state["sa3_lenses"]["loose"]: | |
| sa3_var_final = state["sa3_lenses"]["loose"] | |
| elif state["sa3_lenses"]["match"]: | |
| sa3_var_final = state["sa3_lenses"]["match"] | |
| else: | |
| # Uploaded file — no original prompt to blend | |
| # against. Previously only one compression | |
| # was run + stored as 'loose', so clicking | |
| # MATCH or FREE chips no-op'd. Fan out all | |
| # three lenses concurrently against the | |
| # upload-only system prompts in narrative.py. | |
| with ThreadPoolExecutor(max_workers=3) as upload_lens_pool: | |
| upload_lens_futs = { | |
| upload_lens_pool.submit( | |
| write_sa3_prompt_upload_lens, | |
| brief_payload=_brief_payload(a), | |
| brief_text=safe, | |
| lens=lens_name, | |
| model=model_choice, | |
| api_key=api_key, | |
| ): lens_name | |
| for lens_name in ("match", "loose", "free") | |
| } | |
| for lf, lens_name in upload_lens_futs.items(): | |
| try: | |
| lp, _ = lf.result() | |
| if lp and not lp.startswith("("): | |
| state["sa3_lenses"][lens_name] = lp | |
| except Exception as e: | |
| print(f"[upload-lens {lens_name}] " | |
| f"{type(e).__name__}: {e}", | |
| file=sys.stderr, flush=True) | |
| # Default-display: LOOSE (middle ground). | |
| if state["sa3_lenses"]["loose"]: | |
| sa3_var_final = state["sa3_lenses"]["loose"] | |
| elif state["sa3_lenses"]["match"]: | |
| sa3_var_final = state["sa3_lenses"]["match"] | |
| elif state["sa3_lenses"]["free"]: | |
| sa3_var_final = state["sa3_lenses"]["free"] | |
| except Exception as e: | |
| print(f"[run_brief] sa3-prompt LLM failed: {type(e).__name__}: {e}", | |
| file=sys.stderr, flush=True) | |
| else: | |
| llm_chain = text | |
| mix_chain_final = outputs.mix_chain_text(a, llm_chain) | |
| yield (paragraph, caption, | |
| player_show, wave_show, seek_show, _metrics_html(a), | |
| _data_table(a), _sections_table(a), _tags_table(a), | |
| _format_errors(a), _format_timings(a), | |
| sa3_var_final, sa3_match_final, clip_plan, | |
| mix_chain_final, raw, state) | |
| def load_brief_from_bundle(file_path: str | None, session_id: str | None = ""): | |
| """Import a .abv1 bundle and populate the ANALYSE view directly. | |
| The bundle already contains the analysis JSON + brief text + mix chain | |
| + derived SA3 lenses. We deserialize and render every UI surface the | |
| same way run_brief would — without a second LLM round-trip and | |
| without forcing the user to click Analyze. This is the import-as- | |
| load semantics Codex called out: the card says 'Import shared | |
| analysis', so it should load the analysis. | |
| Returns the same 17-slot tuple shape as run_brief so we can wire | |
| this handler into the same set of output components, plus three | |
| trailing slots for the import-card status + crate refresh. | |
| """ | |
| # Hidden / empty placeholders for the failure path — same shape as | |
| # run_brief's no-audio yield so Gradio's output binding matches. | |
| wave_hide = gr.update(value=None, visible=False) | |
| seek_hide = gr.update(value="", visible=False) | |
| player_hide = gr.update(value="", visible=False) | |
| empty_run_outputs = ( | |
| "", "", player_hide, wave_hide, seek_hide, _metrics_html(None), | |
| [], [], [], "", "", | |
| "", "", "", "", "{}", None, | |
| ) | |
| if not file_path: | |
| return (*empty_run_outputs, "", gr.update(), _crate_header_html(session_id=session_id)) | |
| bundle = share.import_bundle(file_path) | |
| if not bundle: | |
| return (*empty_run_outputs, | |
| "_❌ Not a valid .abv1 bundle._", | |
| gr.update(), _crate_header_html(session_id=session_id)) | |
| payload = bundle.get("analysis", {}) or {} | |
| analysis_dict = payload.get("analysis", {}) or {} | |
| anchor_path = bundle["anchor_path"] | |
| # Reconstruct Analysis dataclass. Field-by-field setattr so a bundle | |
| # written by a future-version Space with extra fields doesn't crash. | |
| try: | |
| a = Analysis(source_path=anchor_path) | |
| for k, v in analysis_dict.items(): | |
| if hasattr(a, k) and k != "source_path": | |
| setattr(a, k, v) | |
| except Exception: | |
| return (*empty_run_outputs, | |
| "_❌ Bundle analysis JSON is malformed._", | |
| gr.update(), _crate_header_html(session_id=session_id)) | |
| # Add anchor + variants to crate so the user can regenerate / replay. | |
| src_prompt = payload.get("source_prompt", "") | |
| model_used = payload.get("model", "shared-import") | |
| try: | |
| anchor_tile = crate.add_tile( | |
| audio_path=anchor_path, | |
| source_prompt=src_prompt, | |
| model=model_used, | |
| duration_s=float(a.duration_s or 0.0), | |
| session_id=session_id, | |
| ) | |
| for vp in bundle.get("variant_paths", []) or []: | |
| try: | |
| crate.add_tile( | |
| audio_path=vp, | |
| source_prompt=src_prompt, | |
| parent_id=anchor_tile.id, | |
| model=model_used, | |
| session_id=session_id, | |
| ) | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass | |
| # Build the same last_run state shape run_brief produces. | |
| state = { | |
| "analysis": a, | |
| "model": model_used, | |
| "audio_path": anchor_path, | |
| "source_prompt": src_prompt, | |
| "sa3_lenses": payload.get("sa3_lenses", {}) or {}, | |
| "brief_payload": _brief_payload(a), | |
| "chain_payload": _chain_payload(a), | |
| } | |
| # Render every deterministic output from Analysis (same helpers | |
| # run_brief calls in stage 2). | |
| caption = _short_caption(a) | |
| paragraph = payload.get("brief") or "_imported analysis (brief text not bundled)_" | |
| mix_chain_md = payload.get("mix_chain") or "_imported analysis (mix chain not bundled)_" | |
| try: | |
| wave_png = waveform.render(a) | |
| wave_out = gr.update(value=wave_png, visible=True) | |
| except Exception: | |
| wave_out = wave_hide | |
| try: | |
| seek_out = gr.update(value=_section_seek_html(a), visible=True) | |
| except Exception: | |
| seek_out = seek_hide | |
| player_out = gr.update(value=_anchor_player_html(anchor_path), visible=True) | |
| # Derived SA3 prompt — prefer the lens cached in the bundle; fall | |
| # back to the local outputs helper if the bundle predates the lens | |
| # feature. brief text needs to be non-empty for the helper to work. | |
| safe_brief = paragraph if not paragraph.startswith("(") and not paragraph.startswith("_") else "" | |
| lenses = state["sa3_lenses"] | |
| sa3_var_out = (lenses.get("loose") or lenses.get("match") | |
| or outputs.sa3_variation_prompt(a, safe_brief)) | |
| sa3_match_out = outputs.sa3_match_style_prompt(a, safe_brief) | |
| clip_plan_out = outputs.ableton_clip_plan(a) | |
| raw_out = to_json(a) | |
| status = (f"📥 Shared analysis loaded · `[{anchor_tile.id}]` **{anchor_tile.label}** " | |
| f"({len(bundle.get('variant_paths') or [])} bundled variants).") | |
| return ( | |
| paragraph, caption, player_out, wave_out, seek_out, | |
| _metrics_html(a), | |
| _data_table(a), _sections_table(a), _tags_table(a), | |
| _format_errors(a), _format_timings(a), | |
| sa3_var_out, sa3_match_out, clip_plan_out, mix_chain_md, | |
| raw_out, state, | |
| # 3 trailing: import-card status, crate radio, crate header. | |
| status, | |
| gr.update(choices=crate.tile_choices(session_id=session_id), value=anchor_tile.id), | |
| _crate_header_html(session_id=session_id), | |
| ) | |
| def _status_badge(state: str, t_s: float | None, ok_count: int, fail_count: int) -> str: | |
| """One-line status: ⏳ running / ✓ done / ⚠ partial / ✗ failed.""" | |
| if state == "running": | |
| return "⏳ _running…_" | |
| if state == "done": | |
| if fail_count == 0: | |
| return f"✅ **done in {t_s:.1f}s**" | |
| if ok_count == 0: | |
| return f"❌ **both calls failed** · {t_s:.1f}s" | |
| return f"⚠️ **partial** · {ok_count}/{ok_count + fail_count} ok · {t_s:.1f}s" | |
| return "" | |
| def _compare_header(label: str, model: str, badge: str, resolved: str | None = None) -> str: | |
| # When Pollinations resolves an alias (`gemini` → `gemini-3.5-flash`), | |
| # show the resolved name in parens so the user sees what actually ran. | |
| if resolved and resolved != model: | |
| model_str = f"`{model}` → `{resolved}`" | |
| else: | |
| model_str = f"`{model}`" | |
| return f"### {label} · {model_str}\n\n{badge}" | |
| def _fail_cell(label: str, reason: str) -> str: | |
| """Distinct visual for a failed cell — blockquote + ❌ icon.""" | |
| return f"> ❌ **{label} failed**\n>\n> `{reason}`" | |
| def export_scorecard(compare_state: dict | None): | |
| # Guard: compare_state is populated on the FIRST yield (before any LLM | |
| # call returns), so without the done check a click during a running | |
| # Compare would export a card full of "_running…_" cells. | |
| if not compare_state or not compare_state.get("done"): | |
| gr.Warning("Run side-by-side hasn't finished yet — wait for all three columns to show ✅ before exporting.") | |
| return None | |
| return share.render_scorecard_png(compare_state["analysis"], compare_state["columns"]) | |
| def export_report(compare_state: dict | None): | |
| if not compare_state or not compare_state.get("done"): | |
| gr.Warning("Run side-by-side hasn't finished yet — wait for all three columns to show ✅ before exporting.") | |
| return None | |
| return share.render_full_report_md(compare_state["analysis"], compare_state["columns"]) | |
| def _columns_from_local(local: dict) -> list[dict]: | |
| """Flatten the streaming `local` dict into the column structure that | |
| share.render_scorecard_png / render_full_report_md expects.""" | |
| out = [] | |
| for col in ("a", "b", "c"): | |
| s = local[col] | |
| out.append({ | |
| "col": s["label"], | |
| "model": s["model"], | |
| "brief": s.get("brief", ""), | |
| "chain": s.get("raw_chain", ""), | |
| "mode": "audio-only" if col == "c" else "measured", | |
| "elapsed_s": s.get("t"), | |
| }) | |
| return out | |
| def run_compare(state: dict | None, model_a: str, model_b: str, model_c: str, api_key: str = ""): | |
| """Three-column comparison. | |
| Column A: measured analysis + LLM brief+chain (model_a) | |
| Column B: measured analysis + LLM brief+chain (model_b) | |
| Column C: AUDIO-ONLY — model_c receives the raw audio file with NO | |
| measurements and has to guess BPM/key/loudness by ear. This is | |
| the demo of what the audio-brief wedge buys. | |
| Outputs (11 slots): | |
| a_header, a_brief, | |
| b_header, b_brief, | |
| c_header, c_brief, | |
| comparison_table, | |
| a_timing, b_timing, c_timing, | |
| compare_state (gr.State with analysis + columns for export) | |
| """ | |
| if not state or not state.get("brief_payload"): | |
| msg = "_Run **analyze** first — Compare reuses the most recent analysis._" | |
| yield ( | |
| _compare_header("A · measured", model_a, "_no analysis yet_"), msg, | |
| _compare_header("B · measured", model_b, "_no analysis yet_"), msg, | |
| _compare_header("C · audio-only", model_c, "_no analysis yet_"), msg, | |
| "_Run analyze, then click **Run side-by-side**._", | |
| "", "", "", | |
| None, | |
| ) | |
| return | |
| from narrative import LLMError | |
| from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED | |
| import time | |
| a = state["analysis"] | |
| brief_payload = state["brief_payload"] | |
| chain_payload = state["chain_payload"] | |
| audio_path = state.get("audio_path") | |
| t0 = time.perf_counter() | |
| local: dict[str, dict] = { | |
| "a": {"model": model_a, "resolved": None, "label": "A · measured", "brief": "_running…_", | |
| "chain": "_running…_", "raw_chain": "", "brief_ok": None, "chain_ok": None, "t": None}, | |
| "b": {"model": model_b, "resolved": None, "label": "B · measured", "brief": "_running…_", | |
| "chain": "_running…_", "raw_chain": "", "brief_ok": None, "chain_ok": None, "t": None}, | |
| "c": {"model": model_c, "resolved": None, "label": "C · audio-only", "brief": "_running… (sending raw audio)_", | |
| "chain": "_running…_", "raw_chain": "", "brief_ok": None, "chain_ok": None, "t": None}, | |
| } | |
| def status_for(side: str) -> str: | |
| s = local[side] | |
| if s["brief_ok"] is None or s["chain_ok"] is None: | |
| return _status_badge("running", None, 0, 0) | |
| ok = int(bool(s["brief_ok"])) + int(bool(s["chain_ok"])) | |
| fail = 2 - ok | |
| return _status_badge("done", s["t"], ok, fail) | |
| def _briefs_for_table() -> dict: | |
| return { | |
| col: (local[col]["brief"] if local[col]["brief_ok"] else None) | |
| for col in ("a", "b", "c") | |
| } | |
| def _chains_for_table() -> dict: | |
| # Pass the raw LLM chain (not the formatted one with the measured | |
| # header prepended) so the parser can split on `## ` headings. | |
| return { | |
| col: (local[col].get("raw_chain") if local[col]["chain_ok"] else None) | |
| for col in ("a", "b", "c") | |
| } | |
| def _labels_for_table() -> dict: | |
| return { | |
| "a": f"A · {local['a']['model']}", | |
| "b": f"B · {local['b']['model']}", | |
| "c": f"C · {local['c']['model']} (audio-only)", | |
| } | |
| def render(*, done: bool = False): | |
| # The `done` flag gates the export buttons — until the final yield, | |
| # compare_state.done is False and clicking Download… returns nothing | |
| # rather than exporting a partial scorecard with "_running…_" cells. | |
| return ( | |
| _compare_header(local["a"]["label"], local["a"]["model"], status_for("a"), local["a"].get("resolved")), | |
| local["a"]["brief"], | |
| _compare_header(local["b"]["label"], local["b"]["model"], status_for("b"), local["b"].get("resolved")), | |
| local["b"]["brief"], | |
| _compare_header(local["c"]["label"], local["c"]["model"], status_for("c"), local["c"].get("resolved")), | |
| local["c"]["brief"], | |
| outputs.compare_table_markdown(_briefs_for_table(), _chains_for_table(), _labels_for_table()), | |
| "", "", "", | |
| {"analysis": a, "columns": _columns_from_local(local), "done": done}, | |
| ) | |
| yield render() | |
| def _audio_only_call(): | |
| """Wrap write_audio_only so the pool sees a single dict-returning task.""" | |
| if not audio_path: | |
| raise RuntimeError("no audio_path in state — re-run analyze") | |
| return write_audio_only(audio_path, model=model_c, no_cache=True, api_key=api_key) | |
| with ThreadPoolExecutor(max_workers=5) as pool: | |
| futs: dict = { | |
| pool.submit(write_brief, brief_payload, model=model_a, no_cache=True, api_key=api_key): ("a", "brief"), | |
| pool.submit(write_mix_chain, chain_payload, model=model_a, no_cache=True, api_key=api_key): ("a", "chain"), | |
| pool.submit(write_brief, brief_payload, model=model_b, no_cache=True, api_key=api_key): ("b", "brief"), | |
| pool.submit(write_mix_chain, chain_payload, model=model_b, no_cache=True, api_key=api_key): ("b", "chain"), | |
| pool.submit(_audio_only_call): ("c", "both"), # single call → both brief+chain | |
| } | |
| pending = set(futs) | |
| while pending: | |
| done, pending = wait(pending, return_when=FIRST_COMPLETED) | |
| for fut in done: | |
| side, kind = futs[fut] | |
| try: | |
| res = fut.result() | |
| ok = True | |
| except LLMError as e: | |
| res = _fail_cell(kind if kind != "both" else "audio-only", str(e)) | |
| ok = False | |
| except Exception as e: # noqa: BLE001 | |
| res = _fail_cell(kind if kind != "both" else "audio-only", f"{type(e).__name__}: {e}") | |
| ok = False | |
| if kind == "both": | |
| # Audio-only returns {"brief", "chain", "model_resolved"} on success. | |
| if ok and isinstance(res, dict): | |
| local[side]["brief"] = res.get("brief", "_(no brief)_") | |
| raw = res.get("chain", "") | |
| local[side]["raw_chain"] = raw | |
| local[side]["chain"] = outputs.normalize_chain_markdown(raw) | |
| local[side]["brief_ok"] = True | |
| local[side]["chain_ok"] = True | |
| local[side]["resolved"] = res.get("model_resolved") | |
| else: | |
| local[side]["brief"] = res | |
| local[side]["chain"] = "_(audio-only call failed — see brief cell)_" | |
| local[side]["raw_chain"] = "" | |
| local[side]["brief_ok"] = False | |
| local[side]["chain_ok"] = False | |
| else: | |
| # write_brief / write_mix_chain now return (text, resolved_model). | |
| if ok and isinstance(res, tuple): | |
| text, resolved = res | |
| if not local[side].get("resolved"): | |
| local[side]["resolved"] = resolved | |
| res = text | |
| if kind == "chain" and ok: | |
| local[side]["raw_chain"] = res | |
| res = outputs.mix_chain_text(a, res) | |
| elif kind == "chain": | |
| local[side]["raw_chain"] = "" | |
| local[side][kind] = res | |
| local[side][f"{kind}_ok"] = ok | |
| # Stamp final time when both calls (or the single audio-only call) | |
| # for this side have landed. | |
| if (local[side]["brief_ok"] is not None | |
| and local[side]["chain_ok"] is not None | |
| and local[side]["t"] is None): | |
| local[side]["t"] = time.perf_counter() - t0 | |
| yield render(done=not pending) | |
| def _wallet_status_md() -> str: | |
| key = wallet.get_key() | |
| if not key: | |
| return "**Pollinations wallet:** not connected — narrative will fall back to (or fail). Click *Connect* to authorise abv1 with your Pollinations balance." | |
| info = wallet.load_wallet().get("user", {}) or {} | |
| who = info.get("preferred_username") or info.get("name") or "connected" | |
| masked = key[:6] + "…" + key[-4:] if len(key) > 12 else "•••" | |
| return f"**Pollinations wallet:** connected as **{who}** (`{masked}`)." | |
| def _extract_balance(info: dict) -> float | None: | |
| """Try every common path Pollinations might surface a balance on. | |
| The userinfo response shape isn't documented publicly, so we | |
| probe top-level keys first, then a couple of nested containers | |
| we've seen on similar wallet APIs. Returns None if nothing | |
| numeric is found — caller renders a graceful fallback then.""" | |
| if not isinstance(info, dict): | |
| return None | |
| # Top-level keys we've already tried + common variants. | |
| candidates = [ | |
| info.get("balance"), | |
| info.get("pollen"), | |
| info.get("credits"), | |
| info.get("pollen_balance"), | |
| info.get("remaining_pollen"), | |
| info.get("available"), | |
| ] | |
| # Nested under .account / .wallet / .usage if Pollinations groups them. | |
| for nest_key in ("account", "wallet", "usage", "user"): | |
| sub = info.get(nest_key) | |
| if isinstance(sub, dict): | |
| candidates.extend([ | |
| sub.get("balance"), | |
| sub.get("pollen"), | |
| sub.get("credits"), | |
| sub.get("remaining"), | |
| sub.get("available"), | |
| ]) | |
| for c in candidates: | |
| if c is None: | |
| continue | |
| try: | |
| return float(c) | |
| except (TypeError, ValueError): | |
| continue | |
| return None | |
| def _topbar_html(session_key: str = "", session_spend: float = 0.0) -> str: | |
| """Top bar HTML: brand wordmark left, pollen pill (clickable wallet) right. | |
| The pill IS the wallet button: | |
| - disconnected → click triggers OAuth redirect via inline JS | |
| - connected → click triggers the hidden #wallet-disconnect-trigger | |
| Pollen balance: Pollinations userinfo doesn't always include it; we | |
| show the balance if present, otherwise a connected dot. | |
| `session_key` is the per-visitor key from gr.State (HF Spaces mode). | |
| Desktop falls back to env vars and the on-disk wallet. | |
| `session_spend` renders a small `0.84 ◆ session` pill left of the | |
| wallet pill when > 0 (per v2 design — cost transparency).""" | |
| key = wallet.get_key(session_key=session_key) | |
| if not key: | |
| return ( | |
| '<div class="dc-topbar">' | |
| f'<div>{brand_html()}</div>' | |
| '<div style="display:flex;align-items:center;gap:10px;">' | |
| f'{palette_toggle_html()}' | |
| f'{pollen_pill_html(balance=None, connected=False)}' | |
| '<div class="dc-avatar"></div>' | |
| '</div>' | |
| '</div>' | |
| ) | |
| # On HF Spaces we don't have userinfo cached; on desktop, wallet file | |
| # may have userinfo + account from the connect flow. Prefer the | |
| # account dict (whole-wallet total) over the userinfo dict (per-key | |
| # scope) — matches what the Pollinations consent screen shows. | |
| w = wallet.load_wallet() | |
| account = w.get("account", {}) or {} | |
| info = w.get("user", {}) or {} | |
| balance = _extract_balance(account) or _extract_balance(info) | |
| return ( | |
| '<div class="dc-topbar">' | |
| f'<div>{brand_html()}</div>' | |
| '<div style="display:flex;align-items:center;gap:10px;">' | |
| f'{palette_toggle_html()}' | |
| f'{session_spend_pill_html(session_spend, connected=True)}' | |
| f'{pollen_pill_html(balance=balance, connected=True)}' | |
| '<div class="dc-avatar"></div>' | |
| '</div>' | |
| '</div>' | |
| ) | |
| def save_key_from_fragment(api_key: str): | |
| """Page-load handler: if Pollinations redirected back to us with | |
| #api_key=sk_… in the URL fragment, JS strips it and passes the key | |
| here. We persist it to the wallet file and refresh the status pill. | |
| Matches the abv1 OAuth pattern (standalone/public/index.html:4959).""" | |
| import time, json as _json, sys as _sys | |
| if api_key and api_key.startswith("sk_"): | |
| try: | |
| info = wallet.userinfo(api_key) | |
| except Exception as e: | |
| print(f"[wallet] userinfo failed: {type(e).__name__}: {e}", | |
| file=_sys.stderr, flush=True) | |
| info = {} | |
| # Log only the response SHAPE — keys + value types — never raw | |
| # values. The Pollinations userinfo response may include account | |
| # identifiers (email, user id, plan name) we don't want winding | |
| # up in HF's stderr aggregate. Knowing which key holds the | |
| # balance is enough to narrow _extract_balance. | |
| try: | |
| shape = {k: type(v).__name__ for k, v in info.items()} if isinstance(info, dict) else type(info).__name__ | |
| print(f"[wallet] userinfo shape: {shape}", | |
| file=_sys.stderr, flush=True) | |
| except Exception: | |
| pass | |
| try: | |
| account = wallet.account_info(api_key) | |
| except Exception as e: | |
| print(f"[wallet] account_info failed: {type(e).__name__}: {e}", | |
| file=_sys.stderr, flush=True) | |
| account = {} | |
| # save_wallet is a no-op on HF Spaces; persists to disk on desktop. | |
| wallet.save_wallet({ | |
| "api_key": api_key, | |
| "user": info, | |
| "account": account, | |
| "scope": "generate account:usage", | |
| "saved_at": int(time.time()), | |
| }) | |
| # Render the connected pill regardless of where the key is stored. | |
| # session_spend resets to 0 here — a fresh connect starts the | |
| # session-spend counter from zero. | |
| return _topbar_html(session_key=api_key, session_spend=0.0), api_key, 0.0 | |
| return _topbar_html(), "", 0.0 | |
| def disconnect_wallet(): | |
| wallet.clear_wallet() | |
| # Disconnect also clears the session-spend pill — it's per-visit. | |
| return _topbar_html(), "", 0.0 | |
| # Pollinations SA3 cost: flat per call regardless of duration (verified | |
| # 2026-06-23, see sa3.py). Each /audio/{text} hit on stable-audio-3-medium | |
| # is 0.04 pollen. Used to tick the session-spend pill and render cost pips. | |
| SA3_COST_PER_CALL = 0.04 | |
| def tick_session_spend(curr: float, delta: float, api_key: str = ""): | |
| """Increment the session-spend counter and refresh the top bar so the | |
| `0.84 ◆ session` pill updates after a gen lands. | |
| Returns (topbar_html, new_spend) — both used as outputs in a `.then(...)` | |
| chain after each Pollinations-billing handler.""" | |
| try: | |
| new = float(curr or 0.0) + float(delta or 0.0) | |
| except (TypeError, ValueError): | |
| new = 0.0 | |
| return _topbar_html(session_key=api_key, session_spend=new), new | |
| # ── MVP2 · Generate-tab handlers ──────────────────────────────────────────── | |
| def _tile_meta_md(tile: crate.Tile | None, session_id: str | None = "") -> str: | |
| if not tile: | |
| return "_no tile selected_" | |
| parent_line = "" | |
| if tile.parent_id: | |
| parent_tile = crate.get_tile(tile.parent_id, session_id=session_id) | |
| if parent_tile: | |
| parent_line = f"\n- **descended from**: `[{tile.parent_id}]` {parent_tile.label}" | |
| return ( | |
| f"**`[{tile.id}]` {tile.label}**\n\n" | |
| f"- model: `{tile.model}`\n" | |
| f"- duration: {tile.duration_s:.1f}s\n" | |
| f"- prompt: _{tile.source_prompt}_" | |
| f"{parent_line}" | |
| ) | |
| _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "[::1]"} | |
| def _is_loopback_url(url: str) -> bool: | |
| """True if `url` points at a loopback host (localhost, 127.0.0.1, [::1]). | |
| Hardening: the local-gen flow fetches whatever URL the user pastes | |
| into the textbox. Without this check, a saved-in-localStorage URL | |
| pointing at any internal/private endpoint would be POSTed to from | |
| the browser. Restricting to loopback keeps the feature scoped to | |
| 'a server on this machine' as documented.""" | |
| if not url: | |
| return False | |
| try: | |
| from urllib.parse import urlparse | |
| p = urlparse(url.strip()) | |
| except Exception: | |
| return False | |
| if p.scheme not in ("http", "https"): | |
| return False | |
| return (p.hostname or "").lower() in _LOOPBACK_HOSTS | |
| def generate_sa3(prompt: str, model: str, duration: float, | |
| api_key: str = "", local_gen_url: str = "", local_gen_b64: str = "", | |
| local_gen_error: str = "", | |
| session_id: str | None = "", | |
| duration_custom: float = 0): | |
| """Click handler for the Generate button. | |
| Two paths: | |
| - `model == "local-server"`: a JS prelude on the click already fetched | |
| the user's localhost gen server and base64-encoded the audio bytes | |
| in `local_gen_b64`. We just decode and write to the crate. No | |
| Pollinations call. | |
| - else: existing Pollinations SA3 flow via sa3.generate(api_key=...). | |
| """ | |
| import sys as _sys, traceback as _tb, base64 as _b64 | |
| print(f"[gen] click: model={model} dur={duration} prompt={prompt[:80]!r}", | |
| file=_sys.stderr, flush=True) | |
| # Hidden placeholders (no value, not visible) — used in every failure | |
| # path so the latest-gen + selected-tile + meta stay collapsed when | |
| # nothing valid landed. Keeps the layout clean on error (v2 spec). | |
| # gen_audio slot was dropped (see comment near crate_preview definition); | |
| # the return tuple shape went 6 → 5: (status, crate_picker, crate_preview, | |
| # crate_meta, crate_header). | |
| _audio_hide = gr.update(value=None, visible=False) | |
| _meta_hide = gr.update(value="", visible=False) | |
| _sess_dir = crate.crate_dir(session_id) | |
| if not (prompt or "").strip(): | |
| return ("_❌ Enter a prompt first._", | |
| gr.update(choices=crate.tile_choices(session_id=session_id)), | |
| _audio_hide, _meta_hide, _crate_header_html(session_id=session_id)) | |
| # Custom chip → sentinel 0 in the Radio slot; the real seconds ride | |
| # in duration_custom (a gr.Number, so no choices validation). The | |
| # radio value can't be rewritten client-side — Gradio v6 validates | |
| # it against `choices` on preprocess. | |
| duration = int(duration or 0) | |
| if duration == 0: | |
| try: | |
| duration = int(duration_custom or 0) | |
| except (TypeError, ValueError): | |
| duration = 0 | |
| duration = max(1, min(sa3.ABSOLUTE_MAX_S, duration)) if duration else 10 | |
| # ── Local server path ──────────────────────────────────────────────── | |
| if model == "local-server": | |
| url_raw = (local_gen_url or "http://localhost:7864").rstrip("/") | |
| if not _is_loopback_url(url_raw): | |
| # Defense-in-depth — the JS prelude refuses to fetch a | |
| # non-loopback URL, so if we got here with a populated b64, | |
| # the prelude was bypassed. Reject the bytes too. | |
| return ("_❌ Local gen URL must be a loopback address (localhost / 127.0.0.1 / [::1]). The local-server path only fetches from this machine._", | |
| gr.update(choices=crate.tile_choices(session_id=session_id)), | |
| _audio_hide, _meta_hide, _crate_header_html(session_id=session_id)) | |
| if local_gen_error: | |
| # Browser-side error — TypeError (CORS / mixed-content / | |
| # PNA), HTTP 4xx/5xx, empty body, etc. The JS prelude | |
| # surfaces the actual reason via the local_gen_error slot | |
| # so we can show it instead of the misleading generic. | |
| return (f"_❌ Local gen browser fetch failed: {local_gen_error}_", | |
| gr.update(choices=crate.tile_choices(session_id=session_id)), | |
| _audio_hide, _meta_hide, _crate_header_html(session_id=session_id)) | |
| if not local_gen_b64: | |
| return (f"_❌ Local gen server didn't return audio. Is it running at `{url_raw}/generate`?_", | |
| gr.update(choices=crate.tile_choices(session_id=session_id)), | |
| _audio_hide, _meta_hide, _crate_header_html(session_id=session_id)) | |
| try: | |
| raw = _b64.b64decode(local_gen_b64) | |
| except Exception as e: | |
| return (f"_❌ Local audio decode failed: {e}_", | |
| gr.update(choices=crate.tile_choices(session_id=session_id)), | |
| _audio_hide, _meta_hide, _crate_header_html(session_id=session_id)) | |
| # Guess extension — wav vs mp3 by magic bytes; default .wav for raw PCM containers. | |
| ext = "wav" if raw[:4] == b"RIFF" else ("mp3" if raw[:3] == b"ID3" or (raw and raw[0] == 0xFF) else "wav") | |
| out_path = _sess_dir / f"local-{crate.new_id()}.{ext}" | |
| out_path.write_bytes(raw) | |
| tile = crate.add_tile( | |
| audio_path=str(out_path), | |
| source_prompt=prompt, | |
| model="local-server", | |
| duration_s=float(duration), | |
| session_id=session_id, | |
| ) | |
| new_audio = _sess_dir / f"{tile.id}.{ext}" | |
| try: | |
| out_path.rename(new_audio) | |
| tile.audio_path = str(new_audio) | |
| tile.save() | |
| except Exception: | |
| pass | |
| status = (f"✅ Local gen `[{tile.id}]` **{tile.label}** " | |
| f"({len(raw)/1024:.0f} KB) — model `local-server`") | |
| return (status, | |
| gr.update(choices=crate.tile_choices(session_id=session_id), value=tile.id), | |
| gr.update(value=tile.audio_path, visible=True), | |
| gr.update(value=_tile_meta_md(tile, session_id=session_id), visible=True), | |
| _crate_header_html(session_id=session_id)) | |
| # ── Pollinations path (default) ────────────────────────────────────── | |
| if not wallet.get_key(session_key=api_key): | |
| return ("_❌ Click the pollen pill (top right) to connect a Pollinations wallet first._", | |
| gr.update(choices=crate.tile_choices(session_id=session_id)), | |
| _audio_hide, _meta_hide, _crate_header_html(session_id=session_id)) | |
| out_path = _sess_dir / f"{crate.new_id()}.mp3" | |
| print(f"[gen] calling sa3.generate → {out_path}", file=_sys.stderr, flush=True) | |
| try: | |
| info = sa3.generate(prompt, model=model, duration=duration, out_path=out_path, | |
| api_key=api_key) | |
| except sa3.SA3Error as e: | |
| print(f"[gen] SA3Error: {e}", file=_sys.stderr, flush=True) | |
| return (f"_❌ {e}_", | |
| gr.update(choices=crate.tile_choices(session_id=session_id)), | |
| _audio_hide, _meta_hide, _crate_header_html(session_id=session_id)) | |
| except Exception as e: | |
| print(f"[gen] {type(e).__name__}: {e}", file=_sys.stderr, flush=True) | |
| _tb.print_exc(file=_sys.stderr) | |
| return (f"_❌ unexpected {type(e).__name__}: {e}_", | |
| gr.update(choices=crate.tile_choices(session_id=session_id)), | |
| _audio_hide, _meta_hide, _crate_header_html(session_id=session_id)) | |
| print(f"[gen] sa3 ok: {info}", file=_sys.stderr, flush=True) | |
| tile = crate.add_tile( | |
| audio_path=info["path"], | |
| source_prompt=prompt, | |
| model=model, | |
| duration_s=float(duration), | |
| session_id=session_id, | |
| ) | |
| # Move the audio file to a name that matches the tile id for tidiness. | |
| new_audio = _sess_dir / f"{tile.id}.mp3" | |
| try: | |
| Path(info["path"]).rename(new_audio) | |
| tile.audio_path = str(new_audio) | |
| tile.save() | |
| except Exception: | |
| pass | |
| status = (f"✅ Generated `[{tile.id}]` **{tile.label}** " | |
| f"({info['bytes']/1024:.0f} KB, {info['wall_s']:.1f}s wall, model `{info['model']}`)") | |
| choices = crate.tile_choices(session_id=session_id) | |
| return (status, | |
| gr.update(choices=choices, value=tile.id), | |
| gr.update(value=tile.audio_path, visible=True), | |
| gr.update(value=_tile_meta_md(tile, session_id=session_id), visible=True), | |
| _crate_header_html(session_id=session_id)) | |
| def _read_var_path(update_or_value) -> str | None: | |
| """Audio updates can come as either raw filepaths (legacy) or | |
| gr.update dicts. Extract the underlying path string either way.""" | |
| if update_or_value is None: | |
| return None | |
| if isinstance(update_or_value, dict): | |
| return update_or_value.get("value") or None | |
| if isinstance(update_or_value, str): | |
| return update_or_value or None | |
| return None | |
| def commit_regen_to_history(history, lens, prompt_text, | |
| v1, v2, v3, v4, v5, matches): | |
| """Post-regen handler — appends the just-completed batch to history, | |
| rebuilds the tab-strip choices, auto-selects the new tab. | |
| Var paths come in as the current values of var1..5 (already populated | |
| by the streaming yields of regenerate_variants). `matches` is the | |
| BPM/Harm/Rhyt breakdown list snapshotted by regenerate_variants into | |
| current_matches_state — persisting it here means tab replay restores | |
| the mint-fill bars instead of resetting to the placeholder state.""" | |
| paths = [_read_var_path(v) for v in (v1, v2, v3, v4, v5)] | |
| if not any(paths): | |
| # No successful gens — don't pollute history. | |
| return history, gr.update(), gr.update() | |
| safe_matches = list(matches or [None] * 5) | |
| # Pad/truncate defensively in case the state shape drifts. | |
| safe_matches = (safe_matches + [None] * 5)[:5] | |
| new_history, new_label = _append_regen_history( | |
| history, lens or "loose", prompt_text or "", | |
| paths, safe_matches, | |
| ) | |
| choices = [(e["label"], i) for i, e in enumerate(new_history)] | |
| return ( | |
| new_history, | |
| gr.update(choices=choices, value=len(new_history) - 1, visible=True), | |
| gr.update(value=_variants_prompt_card_html(prompt_text), visible=True), | |
| ) | |
| def replay_regen_from_history(history, idx): | |
| """Tab click handler — replays a saved regen batch into the variants | |
| row + prompt card. Returns 11 updates: 5 audios, 5 titles, prompt card.""" | |
| if not isinstance(history, list) or idx is None: | |
| return [gr.update()] * 11 | |
| try: | |
| idx = int(idx) | |
| entry = history[idx] | |
| except (ValueError, IndexError, TypeError): | |
| return [gr.update()] * 11 | |
| audio_updates = [] | |
| title_updates = [] | |
| for i in range(5): | |
| p = (entry["paths"][i] if i < len(entry["paths"]) else None) | |
| m = (entry["matches"][i] if i < len(entry["matches"]) else None) | |
| if p: | |
| audio_updates.append(gr.update(value=p, visible=True)) | |
| else: | |
| audio_updates.append(gr.update(value=None, visible=True)) | |
| title_updates.append(gr.update(value=_variant_title_html(i, m))) | |
| prompt_card = gr.update( | |
| value=_variants_prompt_card_html(entry.get("prompt", "")), | |
| visible=True, | |
| ) | |
| return [*audio_updates, *title_updates, prompt_card] | |
| def _append_regen_history(history: list | None, lens: str, prompt_text: str, | |
| var_paths: list, var_matches: list) -> tuple[list, str]: | |
| """Append a completed regen run to the history list. Returns | |
| (new_history, new_label_for_just-added_entry). | |
| Label format: `{LENS}·{N}` where N is the nth time this lens has been | |
| used in the current session. Stable across re-renders because it's | |
| computed at append time and stored on the entry.""" | |
| history = list(history or []) | |
| same_lens_count = sum(1 for e in history if e.get("lens") == lens) | |
| label = f"{lens.upper()}·{same_lens_count + 1}" | |
| entry = { | |
| "lens": lens, | |
| "label": label, | |
| "prompt": prompt_text or "", | |
| "paths": list(var_paths or [None] * 5), | |
| "matches": list(var_matches or [None] * 5), | |
| } | |
| history.append(entry) | |
| return history, label | |
| def regenerate_variants(prompt_text: str, last_run_state, duration: int = 15, | |
| api_key: str = "", n_variants: int = 5, | |
| session_id: str | None = "", | |
| gen_model: str = "", | |
| local_gen_url: str = "", | |
| local_regen_b64_json: str = "", | |
| local_regen_error: str = "", | |
| duration_custom: float = 0): | |
| """Spawn `n_variants` SA3 variants from the (possibly edited) derived prompt. | |
| Two paths, selected by `gen_model`: | |
| - `local-server`: the JS prelude on regen_btn already fetched N WAVs | |
| from the user's loopback bridge and base64-encoded them into a JSON | |
| array in `local_regen_b64_json`. We decode + write tiles. No wallet, | |
| no session-spend tick. | |
| - else: existing Pollinations SA3 flow (requires wallet key). | |
| Streams output: each variant fills its audio slot as it finishes. Slots | |
| beyond `n_variants` are explicitly cleared so the previous batch's audio | |
| doesn't linger in the unused cards. | |
| Variants are added to the crate with parent_id set to the source tile | |
| when one is known (i.e. the user came from Generate → Use for analysis). | |
| `api_key` is the session-scoped Pollinations token from gr.State. | |
| Output tuple: var1..5, var1_title..5_title, status_md, prompt_card, | |
| current_matches_state | |
| """ | |
| # Clamp to the 1–8 chip range. Anything outside that is a caller bug. | |
| try: | |
| n_variants = int(n_variants or 5) | |
| except (TypeError, ValueError): | |
| n_variants = 5 | |
| # UI only has 5 audio slots; cap defensively. | |
| n_variants = max(1, min(5, n_variants)) | |
| import sys as _sys, traceback as _tb | |
| # Audio outputs use gr.update wrappers so the file-proxy properly | |
| # registers each path as a servable static asset. Yielding raw | |
| # filepath strings in a streaming generator was leaving v1..v5 with | |
| # paths the browser couldn't fetch (Audio rendered the slot but no | |
| # file URL was wired up). | |
| paths: list[str | None] = [None, None, None, None, None] | |
| matches: list[dict | None] = [None, None, None, None, None] | |
| def _audio_slot(idx: int): | |
| """gr.update for the Audio component at slot idx. Slots beyond | |
| n_variants are hidden so a Preview (1) doesn't leave four blank | |
| cards in the row; a Trio (3) hides the last two. The label is | |
| empty because the visible title is now the gr.HTML above it.""" | |
| if idx >= n_variants: | |
| return gr.update(value=None, visible=False) | |
| if paths[idx]: | |
| return gr.update(value=paths[idx], visible=True) | |
| return gr.update(value=None, visible=True) | |
| def _title_slot(idx: int): | |
| """gr.update for the gr.HTML title above slot idx. Matches the | |
| audio slot's visibility so the title disappears with its card.""" | |
| if idx >= n_variants: | |
| return gr.update(value=_variant_title_html(idx, None), visible=False) | |
| return gr.update(value=_variant_title_html(idx, matches[idx]), visible=True) | |
| def _emit(status: str, prompt_card: str | None = None): | |
| # Output order matches the regen_btn.click outputs binding: | |
| # (var1..5, var1_title..5_title, regen_status, variants_prompt_used, | |
| # current_matches_state) | |
| # The matches snapshot at the end lets commit_regen_to_history | |
| # persist per-axis bars into the regen tab — otherwise tab auto- | |
| # select after streaming would replay the entry with None matches | |
| # and wipe the mint fill the user just saw. | |
| if prompt_card is None: | |
| # Default — render the current prompt_text as the card body. | |
| prompt_card = _variants_prompt_card_html(prompt_text) | |
| return ( | |
| *(_audio_slot(i) for i in range(5)), | |
| *(_title_slot(i) for i in range(5)), | |
| status, | |
| gr.update(value=prompt_card, visible=True), | |
| list(matches), | |
| ) | |
| if not (prompt_text or "").strip(): | |
| yield _emit("_❌ Enter a prompt to regenerate from._") | |
| return | |
| # Source-of-truth for path selection: the GEN-tab dropdown. If the user | |
| # chose local-server there, they shouldn't be blocked by Pollinations | |
| # state on Analyse-tab Regenerate. The JS prelude on regen_btn will | |
| # have already done the N browser fetches by the time we're here. | |
| use_local = (gen_model == "local-server") | |
| if not use_local: | |
| if not wallet.get_key(session_key=api_key): | |
| yield _emit("_❌ Connect a Pollinations wallet first (click the pollen pill, top right)._") | |
| return | |
| else: | |
| # Local path — surface JS-side errors before doing any decode work. | |
| if local_regen_error: | |
| yield _emit(f"_❌ Local regen browser fetch failed: {local_regen_error}_") | |
| return | |
| if not local_regen_b64_json: | |
| yield _emit("_❌ Local regen returned no audio. Is the bridge running at the URL in the Local server panel?_") | |
| return | |
| # Try to thread parent lineage + anchor measurements from last_run. | |
| # parent_id sets the crate-tile lineage; anchor_bpm/anchor_key/anchor_path | |
| # power the per-variant match % readout (BPM + chroma + onset-rate | |
| # comparison). last_run_state is populated by run_brief on | |
| # analysis-complete. | |
| parent_id: str | None = None | |
| anchor_bpm: float | None = None | |
| anchor_feats: dict | None = None | |
| _sess_dir = crate.crate_dir(session_id) | |
| if isinstance(last_run_state, dict): | |
| src_path = last_run_state.get("audio_path", "") | |
| for t in crate.list_tiles(session_id=session_id): | |
| if t.audio_path == src_path: | |
| parent_id = t.id | |
| break | |
| an = last_run_state.get("analysis") | |
| if an is not None: | |
| anchor_bpm = getattr(an, "bpm", None) | |
| # Compute the anchor's chroma + onset fingerprint ONCE per batch | |
| # so each of the 5 variant scorings reuses it (saves ~1s × 5). | |
| if src_path: | |
| anchor_feats = _anchor_features(src_path) | |
| # Coerce duration — Radio choices yield int, but Gradio may pass a | |
| # str on cold-start race conditions; default to 15 if anything's off. | |
| # The Custom chip carries the sentinel 0 in the Radio slot with the | |
| # real seconds in duration_custom (a gr.Number — no choices | |
| # validation). Floor is 1 so short reference clips can be matched | |
| # exactly (a 4s ident → 4s variants). | |
| try: | |
| duration = int(duration or 0) | |
| except (TypeError, ValueError): | |
| duration = 0 | |
| if duration == 0: | |
| try: | |
| duration = int(duration_custom or 0) | |
| except (TypeError, ValueError): | |
| duration = 0 | |
| duration = max(1, min(sa3.ABSOLUTE_MAX_S, duration)) if duration else 15 | |
| # ── Local-server path ──────────────────────────────────────────────── | |
| if use_local: | |
| import json as _json, base64 as _b64 | |
| try: | |
| b64_arr = _json.loads(local_regen_b64_json) | |
| except Exception as e: | |
| yield _emit(f"_❌ Could not parse local regen payload: {e}_") | |
| return | |
| if not isinstance(b64_arr, list) or not b64_arr: | |
| yield _emit("_❌ Local regen returned an empty payload._") | |
| return | |
| # JS may have fetched fewer than n_variants if a mid-batch failure | |
| # was caught; honour the actual array length. | |
| n_variants = min(n_variants, len(b64_arr)) | |
| for i in range(n_variants): | |
| yield _emit(f"_writing local variant {i+1}/{n_variants}…_") | |
| try: | |
| raw = _b64.b64decode(b64_arr[i]) | |
| except Exception as e: | |
| print(f"[regen v{i+1}] local decode failed: {e}", | |
| file=_sys.stderr, flush=True) | |
| yield _emit(f"_❌ variant {i+1}/{n_variants}: decode failed_") | |
| continue | |
| ext = "wav" if raw[:4] == b"RIFF" else ( | |
| "mp3" if (raw[:3] == b"ID3" or (raw and raw[0] == 0xFF)) else "wav") | |
| out_path = _sess_dir / f"local-{crate.new_id()}.{ext}" | |
| out_path.write_bytes(raw) | |
| tile = crate.add_tile( | |
| audio_path=str(out_path), | |
| source_prompt=prompt_text, | |
| parent_id=parent_id, | |
| model="local-server", | |
| duration_s=float(duration), | |
| session_id=session_id, | |
| ) | |
| new_audio = _sess_dir / f"{tile.id}.{ext}" | |
| try: | |
| out_path.rename(new_audio) | |
| tile.audio_path = str(new_audio) | |
| tile.save() | |
| except Exception: | |
| pass | |
| paths[i] = tile.audio_path | |
| matches[i] = _quick_match(tile.audio_path, anchor_bpm, anchor_feats) | |
| print(f"[regen v{i+1}] local tile={tile.id} bytes={len(raw)} " | |
| f"match={matches[i]} path={tile.audio_path}", | |
| file=_sys.stderr, flush=True) | |
| parent_note = f" (descended from `[{parent_id}]`)" if parent_id else "" | |
| yield _emit(f"✅ {n_variants} local variant{'s' if n_variants != 1 else ''} ready — added to the crate{parent_note}.") | |
| return | |
| model = sa3.DEFAULT_MODEL | |
| for i in range(n_variants): | |
| yield _emit(f"_generating variant {i+1}/{n_variants} via `{model}` ({duration}s)…_") | |
| out_path = _sess_dir / f"{crate.new_id()}.mp3" | |
| try: | |
| info = sa3.generate(prompt_text, model=model, duration=duration, | |
| out_path=out_path, api_key=api_key) | |
| except sa3.SA3Error as e: | |
| print(f"[regen v{i+1}] SA3Error: {e}", file=_sys.stderr, flush=True) | |
| yield _emit(f"_❌ variant {i+1}/5: {e}_") | |
| continue | |
| except Exception as e: | |
| print(f"[regen v{i+1}] {type(e).__name__}: {e}", file=_sys.stderr, flush=True) | |
| _tb.print_exc(file=_sys.stderr) | |
| yield _emit(f"_❌ variant {i+1}/5: unexpected {type(e).__name__}_") | |
| continue | |
| tile = crate.add_tile( | |
| audio_path=info["path"], | |
| source_prompt=prompt_text, | |
| parent_id=parent_id, | |
| model=model, | |
| duration_s=float(duration), | |
| session_id=session_id, | |
| ) | |
| # Rename file to match tile id, same as generate_sa3. | |
| new_audio = _sess_dir / f"{tile.id}.mp3" | |
| try: | |
| Path(info["path"]).rename(new_audio) | |
| tile.audio_path = str(new_audio) | |
| tile.save() | |
| except Exception: | |
| pass | |
| paths[i] = tile.audio_path | |
| # Quick match — librosa BPM + chroma + onset-rate similarity vs | |
| # cached anchor fingerprint. ~1.5s per variant. Stores the dict | |
| # so the slot label can render per-axis bars instead of a scalar %. | |
| matches[i] = _quick_match(tile.audio_path, anchor_bpm, anchor_feats) | |
| print(f"[regen v{i+1}] tile={tile.id} bytes={info.get('bytes')} " | |
| f"match={matches[i]} path={tile.audio_path}", | |
| file=_sys.stderr, flush=True) | |
| parent_note = "" | |
| if parent_id: | |
| parent_note = f" (descended from `[{parent_id}]`)" | |
| yield _emit(f"✅ 5 variants ready — added to the crate{parent_note}.") | |
| def _crate_header_html(session_id: str | None = "") -> str: | |
| """Header above the crate chip strip: 'CRATE · N takes', or NOTHING | |
| when empty. The previous dashed empty-state card duplicated what the | |
| Generate button two lines above already invites — a designer-level | |
| cleanup pass removed it. When the user has tiles, the header is a | |
| tight one-line label. When empty, the entire crate row is silent.""" | |
| tiles = crate.list_tiles(session_id=session_id) | |
| n = len(tiles) | |
| if n == 0: | |
| return "" | |
| return ( | |
| '<div style="display:flex;align-items:baseline;gap:9px;margin-top:10px;">' | |
| '<div class="dc-label">CRATE</div>' | |
| '<div style="font-family:JetBrains Mono;font-size:11px;color:var(--label);">' | |
| f'{n} take{"s" if n != 1 else ""}' | |
| '</div></div>' | |
| ) | |
| def refresh_crate(session_id: str | None = ""): | |
| """Re-scan the crate dir and refresh the chip strip + header.""" | |
| return (gr.update(choices=crate.tile_choices(session_id=session_id)), | |
| _crate_header_html(session_id=session_id)) | |
| def select_tile(tile_id: str | None, session_id: str | None = ""): | |
| """Selecting a tile from the dropdown previews it + shows metadata + | |
| reveals the Use-for-analysis / delete action row. Returns gr.update | |
| wrappers so the preview/meta/actions show only when a tile is actually | |
| selected (otherwise stay hidden — designer cleanup pass: no dead controls).""" | |
| if not tile_id: | |
| return (gr.update(value=None, visible=False), | |
| gr.update(value="", visible=False), | |
| gr.update(visible=False)) | |
| tile = crate.get_tile(tile_id, session_id=session_id) | |
| if not tile: | |
| return (gr.update(value=None, visible=False), | |
| gr.update(value="_tile not found (was it deleted elsewhere?)_", visible=True), | |
| gr.update(visible=False)) | |
| return (gr.update(value=tile.audio_path, visible=True), | |
| gr.update(value=_tile_meta_md(tile, session_id=session_id), visible=True), | |
| gr.update(visible=True)) | |
| def delete_tile(tile_id: str | None, session_id: str | None = ""): | |
| if not tile_id: | |
| return (gr.update(choices=crate.tile_choices(session_id=session_id)), | |
| gr.update(value=None, visible=False), | |
| gr.update(value="", visible=False), | |
| _crate_header_html(session_id=session_id), | |
| gr.update(visible=False)) | |
| crate.delete_tile(tile_id, session_id=session_id) | |
| return (gr.update(choices=crate.tile_choices(session_id=session_id), value=None), | |
| gr.update(value=None, visible=False), | |
| gr.update(value="", visible=False), | |
| _crate_header_html(session_id=session_id), | |
| gr.update(visible=False)) | |
| def _anchor_features(anchor_path: str) -> dict | None: | |
| """Pre-compute chroma + onset-rate fingerprint of the anchor so we don't | |
| re-decode + re-FFT it once per variant. Returns dict or None on failure. | |
| Called ONCE per regen batch; cached results reused 5× in _quick_match.""" | |
| try: | |
| import librosa | |
| import numpy as np | |
| y, sr = librosa.load(anchor_path, sr=22050, mono=True, duration=15.0) | |
| chroma = np.mean(librosa.feature.chroma_cqt(y=y, sr=sr), axis=1) | |
| norm = float(np.linalg.norm(chroma)) | |
| dur = max(len(y) / sr, 0.1) | |
| onsets_per_s = len(librosa.onset.onset_detect(y=y, sr=sr)) / dur | |
| return { | |
| "chroma": chroma, | |
| "chroma_norm": norm, | |
| "onsets_per_s": onsets_per_s, | |
| } | |
| except Exception as e: | |
| print(f"[_anchor_features] {type(e).__name__}: {e}", | |
| file=sys.stderr, flush=True) | |
| return None | |
| def _quick_match(variant_path: str, anchor_bpm: float | None, | |
| anchor_feats: dict | None) -> dict | None: | |
| """Per-axis similarity scores (0-100 each) for a regen variant vs anchor. | |
| Returns `{"bpm": int, "harm": int, "rhyt": int}` or None on failure. | |
| Why three axes (not one scalar): the previous match % was a single | |
| weighted sum that compressed too much information and didn't track | |
| what the listener actually heard. Two variants in the same key at the | |
| same tempo could sound nothing alike (different timbres, different | |
| instrumentation density) yet show 100%. Surfacing the axes lets the | |
| user see WHICH dimension is matching/diverging — and feed that intuition | |
| back into the prompt. | |
| - bpm: tempo proximity (tolerates half/double-time confusion) | |
| - harm: chroma-vector cosine similarity (12-pitch harmonic profile) | |
| - rhyt: onset-rate similarity (rhythm density: busy vs sparse) | |
| `anchor_feats` is the cached output of `_anchor_features` so we don't | |
| reload the anchor audio 5x per regen batch.""" | |
| if not (anchor_bpm and anchor_feats): | |
| return None | |
| try: | |
| import librosa | |
| import numpy as np | |
| y_v, sr = librosa.load(variant_path, sr=22050, mono=True, duration=15.0) | |
| # Honest BPM — neutral prior so detector isn't biased to anchor BPM. | |
| tempo_v, _ = librosa.beat.beat_track(y=y_v, sr=sr, start_bpm=120, tightness=100) | |
| v_bpm = float(np.asarray(tempo_v).item()) | |
| ratios = [ | |
| abs(v_bpm - anchor_bpm), | |
| abs(v_bpm * 2 - anchor_bpm), | |
| abs(v_bpm / 2 - anchor_bpm), | |
| ] | |
| best_bpm_err = min(ratios) / max(float(anchor_bpm), 1.0) | |
| bpm = int(round(max(0.0, 100.0 - best_bpm_err * 500.0))) | |
| chroma_v = np.mean(librosa.feature.chroma_cqt(y=y_v, sr=sr), axis=1) | |
| denom = float(np.linalg.norm(chroma_v) * anchor_feats["chroma_norm"]) | |
| cos = float(np.dot(chroma_v, anchor_feats["chroma"]) / denom) if denom > 0 else 0.0 | |
| harm = int(round(max(0.0, min(100.0, (cos - 0.4) / 0.6 * 100.0)))) | |
| v_dur = max(len(y_v) / sr, 0.1) | |
| v_onsets = len(librosa.onset.onset_detect(y=y_v, sr=sr)) / v_dur | |
| denom = max(anchor_feats["onsets_per_s"], 0.5) | |
| density_err = abs(v_onsets - anchor_feats["onsets_per_s"]) / denom | |
| rhyt = int(round(max(0.0, 100.0 - density_err * 200.0))) | |
| return {"bpm": bpm, "harm": harm, "rhyt": rhyt} | |
| except Exception as e: | |
| print(f"[_quick_match] {type(e).__name__}: {e}", | |
| file=sys.stderr, flush=True) | |
| return None | |
| def _variants_prompt_card_html(prompt_text: str) -> str: | |
| """Compact card shown above the ANCHOR + 5 VARIANTS title, echoing the | |
| derived prompt that produced the current batch. Lets the user see at a | |
| glance which phrasing led to which 5 gens (especially useful when | |
| iterating across MATCH / LOOSE / FREE lenses).""" | |
| import html as _html | |
| text = _html.escape((prompt_text or "").strip()) or "<em>(empty)</em>" | |
| return ( | |
| '<div class="dc-variants-prompt-card">' | |
| '<div class="head">PROMPT USED</div>' | |
| f'<div class="body">{text}</div>' | |
| '</div>' | |
| ) | |
| def _bars(v: int) -> str: | |
| """Map a 0–100 score to a 4-block bar HTML string. Quartiles at 12/38/63/88. | |
| Returns inline HTML with a `.fill` span on the filled portion so the | |
| measured axis (mint) reads as colour-coded fill against the muted | |
| `▯` track behind it — that's the metering colour the variant titles | |
| rely on for at-a-glance match strength.""" | |
| if v >= 88: n = 4 | |
| elif v >= 63: n = 3 | |
| elif v >= 38: n = 2 | |
| elif v >= 12: n = 1 | |
| else: n = 0 | |
| return f'<span class="fill">{"▮" * n}</span>{"▯" * (4 - n)}' | |
| def _variant_title_html(idx: int, breakdown: dict | None) -> str: | |
| """Render a per-variant title bar above the Audio component. | |
| Layout: BPM/Harm/Rhyt bars on ONE horizontal line; v-name on the right. | |
| Compact (~30px tall) so the variant cards stay tight. Pre-regen state | |
| shows faded placeholder bars.""" | |
| name = f"v{idx + 1}" | |
| if not breakdown: | |
| bars = 'BPM ▯▯▯▯ · Harm ▯▯▯▯ · Rhyt ▯▯▯▯' | |
| cls = 'bars placeholder' | |
| else: | |
| bars = ( | |
| f'BPM {_bars(breakdown["bpm"])} · ' | |
| f'Harm {_bars(breakdown["harm"])} · ' | |
| f'Rhyt {_bars(breakdown["rhyt"])}' | |
| ) | |
| cls = 'bars' | |
| return ( | |
| '<div class="dc-variant-title">' | |
| f'<div class="{cls}">{bars}</div>' | |
| f'<div class="name">{name}</div>' | |
| '</div>' | |
| ) | |
| def _anchor_player_html(audio_path: str | None) -> str: | |
| """Inline HTML5 `<audio>` element with a stable id (`abv1-anchor`) the | |
| section-seek chips and any future timeline-click handlers can target. | |
| We use a raw <audio> tag instead of gr.Audio for the anchor on the | |
| Analysis tab because Gradio v6's gr.Audio is wavesurfer-based with no | |
| queryable <audio> element in the DOM — so external JS can't seek it. | |
| The file is served via `/gradio_api/file=<path>` (Gradio's file proxy; | |
| `gr.set_static_paths` at module load whitelists the crate dir).""" | |
| if not audio_path: | |
| return "" | |
| encoded = urllib.parse.quote(audio_path, safe="") | |
| return ( | |
| '<div class="dc-anchor-player">' | |
| '<div class="head">' | |
| '<span class="badge">★ ANCHOR</span>' | |
| '<span class="hint">use the section chips below to jump within the clip</span>' | |
| '</div>' | |
| f'<audio id="abv1-anchor" controls preload="metadata" ' | |
| f'src="/gradio_api/file={encoded}" style="width:100%;"></audio>' | |
| '</div>' | |
| ) | |
| def _section_seek_html(a) -> str: | |
| """Render clickable section buttons below the waveform. Each button | |
| seeks the `#abv1-anchor` HTML5 audio element to that section's start | |
| and plays. | |
| Role assignment mirrors waveform.py's `_assign_roles` so colours + | |
| labels line up exactly with the waveform image above. Chip text is | |
| just the role name — the time range is already in the waveform PNG | |
| above so repeating it here is redundant noise.""" | |
| from waveform import _assign_roles, SECTION_COLORS_BY_ROLE | |
| sections = list(a.sections or []) | |
| if not sections: | |
| return "" | |
| roles = _assign_roles(len(sections)) | |
| chips = [] | |
| for s, role in zip(sections, roles): | |
| start = float(s["start"]) | |
| colour = SECTION_COLORS_BY_ROLE[role] | |
| on_click = ( | |
| "(function(t){" | |
| "var a=document.getElementById('abv1-anchor');" | |
| "if(a){a.currentTime=t;a.play();}" | |
| f"}})({start})" | |
| ) | |
| chips.append( | |
| f'<button class="dc-section-chip" onclick="{on_click}" ' | |
| f'style="border-color:{colour};color:{colour};">' | |
| f'{role.upper()}</button>' | |
| ) | |
| return ( | |
| '<div class="dc-section-seek-strip" ' | |
| 'title="Click a section to play from that point">' | |
| f'{"".join(chips)}' | |
| '</div>' | |
| ) | |
| _BPM_RE = __import__("re").compile(r"(\d{2,3})\s*BPM", __import__("re").IGNORECASE) | |
| def _hint_from_prompt(prompt: str) -> tuple[str, str, float]: | |
| """Pull a BPM / genre hint out of a SA3 source_prompt. | |
| Returns a triple ready to slot into (bpm_mode, genre, bpm_num): | |
| - if the prompt names an explicit `<N> BPM`: ("Manual BPM", "default", N) | |
| - else if it mentions a known genre keyword: ("Auto (genre)", G, 120) | |
| - else: ("Auto (genre)", "default", 120) | |
| Without this the analyzer locks half-tempo on dnb/jungle prompts and | |
| the brief misreports the SA3 output as ~half its real BPM. Per the | |
| user's standing rule: always pass start_bpm matched to source genre. | |
| """ | |
| txt = (prompt or "").lower() | |
| m = _BPM_RE.search(txt) | |
| if m: | |
| try: | |
| bpm = float(m.group(1)) | |
| if 40 <= bpm <= 220: | |
| return ("Manual BPM", "default", bpm) | |
| except ValueError: | |
| pass | |
| # Genre keyword scan — first hit wins. The GENRE_CHOICES list is | |
| # the source of truth for what BPM_PRIORS will recognise. | |
| for g in GENRE_CHOICES: | |
| if g != "default" and g in txt: | |
| return ("Auto (genre)", g, 120.0) | |
| # Also catch common synonyms not already in GENRE_CHOICES: | |
| if "drum and bass" in txt or "drum'n'bass" in txt or "dnb" in txt: | |
| return ("Auto (genre)", "dnb", 120.0) | |
| return ("Auto (genre)", "default", 120.0) | |
| def send_tile_to_analysis(tile_id: str | None, session_id: str | None = ""): | |
| """Bridge from Generate → Analysis-flow upload box. | |
| Returns: (audio_path, status_md, bpm_mode, genre, bpm_num, source_prompt) | |
| The trailing three selectors feed the BPM-prior so the analyzer doesn't | |
| lock half-tempo on dnb/jungle SA3 gens. `source_prompt` is stashed in a | |
| gr.State so run_brief can blend it back into the derived SA3 prompt — | |
| without this the variants drift away from the user's original intent.""" | |
| if not tile_id: | |
| return (None, "_❌ Pick a tile from the dropdown first._", | |
| gr.update(), gr.update(), gr.update(), "") | |
| tile = crate.get_tile(tile_id, session_id=session_id) | |
| if not tile: | |
| return (None, "_❌ Tile not found — try Refresh._", | |
| gr.update(), gr.update(), gr.update(), "") | |
| bpm_mode_v, genre_v, bpm_num_v = _hint_from_prompt(tile.source_prompt) | |
| hint_note = "" | |
| if bpm_mode_v == "Manual BPM": | |
| hint_note = f" · BPM prior **{int(bpm_num_v)}** from prompt" | |
| elif genre_v != "default": | |
| hint_note = f" · genre prior **{genre_v}** from prompt" | |
| return ( | |
| tile.audio_path, | |
| f"🔬 Analysing `[{tile.id}]` **{tile.label}**{hint_note} — switch to " | |
| f"the **ANALYSE** tab to see the brief, or **LAB** for the " | |
| f"side-by-side with Gemini.", | |
| gr.update(value=bpm_mode_v), | |
| gr.update(value=genre_v), | |
| gr.update(value=bpm_num_v), | |
| tile.source_prompt, | |
| ) | |
| # ── Headless API: audio → derived SA3 prompt ───────────────────────────── | |
| # Exposed via a hidden button with api_name="audio_to_prompt" (registered | |
| # in build_ui). Deliberately wallet-free: the returned prompt is the | |
| # DETERMINISTIC structural prompt (outputs.sa3_variation_prompt) built | |
| # from measurements alone — no Pollinations LLM call, no pollen cost, so | |
| # the endpoint works for any caller. LLM-polished prose stays a UI-only | |
| # feature (needs the per-session wallet). | |
| # | |
| # Gate: if the Space secret AUDIO_BRIEF_API_TOKEN is set, callers must | |
| # pass a matching `token`. Unset → open (fine for local/dev). This keeps | |
| # casual abuse of the CPU-heavy demucs stage out on the public Space. | |
| def _api_token_ok(token: str) -> bool: | |
| import os | |
| required = os.environ.get("AUDIO_BRIEF_API_TOKEN", "").strip() | |
| if not required: | |
| return True # no gate configured — open endpoint | |
| # Constant-time compare so the token can't be guessed byte-by-byte. | |
| import hmac | |
| return hmac.compare_digest(str(token or "").strip(), required) | |
| def audio_to_prompt_api(audio_path: str | None, | |
| token: str = "", | |
| fast: bool = False, | |
| bpm_prior: str = "default", | |
| llm: bool = False, | |
| lens: str = "loose", | |
| original_prompt: str = "", | |
| embedding: bool = False) -> dict: | |
| """Headless endpoint: an audio (or video) file in → a ready-to-use SA3 | |
| prompt + the measured analysis JSON out. | |
| Params (positional order for gradio_client.predict): | |
| audio_path : uploaded file path (wav/mp3/flac/ogg + mp4/mov/m4a/… — | |
| video/aac are ffmpeg-extracted, same as the UI upload). | |
| token : shared secret; required only when AUDIO_BRIEF_API_TOKEN | |
| is set on the Space. | |
| fast : skip demucs stems + basic-pitch bass-MIDI (~2 s instead | |
| of ~30 s). Prompt omits stem/bassline detail. | |
| bpm_prior : genre slug or numeric string to seed the beat tracker | |
| (see BPM_PRIORS) — "default" is fine for most. | |
| llm : when True, run the Pollinations LLM to write a | |
| natural-language SA3 prompt (analyse → brief → lens | |
| compressor). Requires POLLINATIONS_API_KEY on the Space. | |
| Falls back to the deterministic prompt if the LLM errors | |
| (no key / out of pollen / timeout) — the call never fails | |
| *because of* the LLM. | |
| lens : LLM constraint — "match" (tightest, keeps section | |
| timings), "loose" (feel + BPM/key, free arrangement), | |
| "free" (mood + BPM only). Ignored when llm is False. | |
| original_prompt : the caller's CURRENT gen prompt, when this audio | |
| came from it. Switches both paths to ANCHORED STEERING: | |
| the deterministic floor becomes sa3_steer_prompt (the | |
| original's vocabulary + measured BPM/key merged, half- | |
| time aware), and the LLM path uses the blended lens | |
| family (user vocabulary authoritative, measured tempo | |
| locked) instead of the upload lens. Empty = old | |
| describe-from-scratch behavior, fully compatible. | |
| embedding : when True, also run the CLAP similarity stage (a | |
| subprocess island — torch/checkpoint never resident next | |
| to a gen) and return the L2-normalized vector inline as | |
| `embedding` (float list) + `embedding_dim`. Off by default | |
| (heavy, CPU-bound); null + an `errors` entry if it fails. | |
| Returns a JSON-able dict: | |
| { prompt, prompt_source, brief, match_style_prompt, embedding, | |
| embedding_dim, measured{…}, fast_mode, stages_ok, errors } | |
| prompt_source is "llm:<model>" on the LLM path, else "deterministic" | |
| (with the failure reason appended when an LLM run fell back). | |
| """ | |
| if not _api_token_ok(token): | |
| raise gr.Error("invalid or missing API token") | |
| if not audio_path: | |
| raise gr.Error("no audio file provided") | |
| # Same ingest path as the UI: video containers + aac-family → WAV. | |
| src = _prepare_source_audio(audio_path) | |
| a = analyze( | |
| src, | |
| bpm_prior=(bpm_prior or "default"), | |
| run_stems=not fast, | |
| run_midi=not fast, | |
| # Tags: skip in fast mode to save the EffNet load/inference — BUT | |
| # always run them when llm=True, because the one-call prompt-writer | |
| # sources its genre/mood/instrument vocabulary from these measured | |
| # tags (there's no separate brief-writing call to infer vibe any | |
| # more). Without them a fast+llm call leaves the writer with only | |
| # BPM/key and it produces a weak prompt. Tags are ~2s; fast mode | |
| # still skips the ~30s demucs/midi stages. | |
| run_tags=(llm or not fast), | |
| # CLAP similarity vector — off by default (heavy, CPU-bound). Runs as | |
| # a subprocess island so it can't sit resident next to an SA3 gen. | |
| run_embedding=bool(embedding), | |
| ) | |
| # Decode is the only stage whose failure makes the output meaningless | |
| # (no BPM/key/anything). Surface that as an error rather than a prompt | |
| # full of '?'. Individual heavy-stage failures (demucs/basic-pitch) | |
| # are non-fatal — the structural prompt still stands on librosa data. | |
| if a.bpm is None and a.errors: | |
| raise gr.Error(f"analysis failed: {a.errors}") | |
| # Deterministic prompt is always computed — it's the guaranteed floor | |
| # and the fallback if the LLM path is off or errors. With an | |
| # original_prompt it's the anchored merge (one line, gen-ready). | |
| original_prompt = str(original_prompt or "").strip() | |
| prompt = (outputs.sa3_steer_prompt(a, original_prompt) if original_prompt | |
| else outputs.sa3_variation_prompt(a)) | |
| prompt_source = "deterministic-steer" if original_prompt else "deterministic" | |
| brief_text = "" | |
| if llm: | |
| # ONE Pollinations call — analyse → lensed SA3 prompt directly. | |
| # The separate write_brief() step was dropped: essentia now puts | |
| # measured genre/mood/instrument tags in the payload, so the | |
| # prompt-writer reads the vibe from those tags instead of from a | |
| # brief paragraph it used to have to write first. Halves latency | |
| # (~20s vs ~40s) and pollen, and keeps the call under predict()'s | |
| # read timeout. api_key="" → falls through to the Space secret | |
| # POLLINATIONS_API_KEY. Any LLMError degrades to the deterministic | |
| # prompt rather than failing the call. (The human-readable brief is | |
| # a UI feature; this endpoint returns the prompt, so brief=None.) | |
| lens = (lens or "loose").lower().strip() | |
| if lens not in ("match", "loose", "free"): | |
| lens = "loose" | |
| try: | |
| payload = _brief_payload(a) | |
| if original_prompt: | |
| # Anchored steering: blended lens family — the caller's | |
| # vocabulary is authoritative for vibe, measured BPM/key | |
| # locked, arrangement freedom set by the lens. | |
| from narrative import write_sa3_prompt_lens | |
| polished, model_used = write_sa3_prompt_lens( | |
| original_prompt, payload, "", lens=lens, api_key="", | |
| ) | |
| else: | |
| polished, model_used = write_sa3_prompt_upload_lens( | |
| payload, "", lens=lens, api_key="", | |
| ) | |
| if polished and polished.strip(): | |
| prompt = polished.strip() | |
| prompt_source = f"llm:{model_used}" | |
| else: | |
| prompt_source = ("deterministic-steer" if original_prompt else "deterministic") + " (llm returned empty)" | |
| except Exception as e: | |
| # LLMError etc — keep the deterministic prompt, report why. | |
| prompt_source = ("deterministic-steer" if original_prompt else "deterministic") + f" (llm failed: {type(e).__name__}: {str(e)[:120]})" | |
| # CLAP vector — read the worker's L2-normalized .npy and return it inline | |
| # as a plain float list (most useful for a distance-computing consumer). | |
| # None when embedding wasn't requested or the stage failed (see `errors`). | |
| _emb_list = None | |
| if a.embedding_path: | |
| try: | |
| import numpy as _np | |
| _emb_list = [round(float(x), 6) for x in _np.load(a.embedding_path).tolist()] | |
| except Exception as _e: # noqa: BLE001 | |
| a.errors.append({"stage": "embedding_read", "error": str(_e)}) | |
| return { | |
| "prompt": prompt, | |
| "prompt_source": prompt_source, | |
| "brief": brief_text or None, | |
| "match_style_prompt": outputs.sa3_match_style_prompt(a), | |
| "embedding": _emb_list, | |
| "embedding_dim": (len(_emb_list) if _emb_list else None), | |
| # Which CLAP checkpoint made the vector — refuse to compare across | |
| # differing values (silent incomparability is the worst failure mode). | |
| "embedding_ckpt": (a.embedding_ckpt if _emb_list else None), | |
| "measured": { | |
| "bpm": a.bpm, | |
| "key": a.key, | |
| "key_mode": a.key_mode, | |
| "key_confidence": a.key_correlation, | |
| "duration_s": a.duration_s, | |
| "lufs_i": a.lufs_i, | |
| "lufs_lra": a.lufs_lra, | |
| "true_peak_db": a.true_peak_db, | |
| "sections": a.sections, | |
| "stems": sorted(a.stems) if a.stems else [], | |
| "stem_stats": a.stem_stats or None, | |
| "voiceover_present": a.voiceover_present, | |
| "bass_midi": _bass_midi_summary(a), | |
| # Measured tags (essentia) — empty when tagging skipped (fast | |
| # mode, or host without the wheels). Each entry {label, score}. | |
| "genre": a.tags_genre, | |
| "mood": a.tags_mood, | |
| "instrument": a.tags_instrument, | |
| }, | |
| "fast_mode": bool(fast), | |
| "stages_ok": [k for k, v in { | |
| "bpm_key": a.bpm is not None, | |
| "sections": bool(a.sections), | |
| "loudness": a.lufs_i is not None, | |
| "stems": bool(a.stems), | |
| "bass_midi": _bass_midi_summary(a) is not None, | |
| "tags": bool(a.tags_genre or a.tags_mood or a.tags_instrument), | |
| "embedding": _emb_list is not None, | |
| }.items() if v], | |
| "errors": a.errors or [], | |
| } | |
| def build_ui() -> gr.Blocks: | |
| # Theme + CSS must live on the Blocks instance (not on .launch()) so | |
| # HF Spaces — which auto-launches `demo` without our launch args — | |
| # still picks them up. Gradio v6 supports both call sites; we just | |
| # have to pick the one that works in both deploy paths. | |
| with gr.Blocks(title="audio·brief", theme=THEME, css=CUSTOM_CSS) as demo: | |
| # Force a sane viewport meta tag. Gradio v6 doesn't always emit | |
| # one, and we've seen Brave inherit a 50%-zoomed viewport from | |
| # the Pollinations OAuth subdomain after the redirect tab opens | |
| # — the whole tree renders half-size. Asserting initial-scale=1 | |
| # on mount fixes that. | |
| gr.HTML( | |
| '<script>' | |
| '(function(){' | |
| 'var v=document.querySelector("meta[name=viewport]");' | |
| 'if(!v){v=document.createElement("meta");v.name="viewport";document.head.appendChild(v);}' | |
| 'v.content="width=device-width, initial-scale=1, maximum-scale=2";' | |
| '})();' | |
| '</script>', | |
| elem_classes=["dc-script-only"], | |
| ) | |
| # Palette restore — runs before any other UI mounts so the page | |
| # comes up in the saved palette (Studio / Console / Dub) without | |
| # a Console flash. Reads localStorage["abv1.palette"] and applies | |
| # the class to <body>. Defaults to console when unset. Toggle | |
| # buttons in the top bar will re-save + re-apply on click. | |
| gr.HTML( | |
| '<script>' | |
| '(function(){' | |
| 'var pal="console";' | |
| # Studio dropped pending a dedicated legibility pass; fall back | |
| # to console if any user still has "studio" persisted. | |
| 'try{var s=localStorage.getItem("abv1.palette");' | |
| 'if(s==="console"||s==="dub")pal=s;' | |
| '}catch(e){}' | |
| 'function apply(){' | |
| 'var b=document.body;if(!b)return false;' | |
| 'b.classList.remove("pal-studio","pal-console","pal-dub");' | |
| 'b.classList.add("pal-"+pal);' | |
| # Also sync the toggle buttons (they render in the top bar | |
| # before the user has clicked anything). | |
| 'document.querySelectorAll(".dc-palette-toggle button").forEach(function(btn){' | |
| 'btn.classList.toggle("active",btn.dataset.pal===pal);' | |
| '});' | |
| 'return true;' | |
| '}' | |
| 'if(!apply()){' | |
| 'var iv=setInterval(function(){if(apply())clearInterval(iv);},80);' | |
| '}' | |
| '})();' | |
| '</script>', | |
| elem_classes=["dc-script-only"], | |
| ) | |
| # Wallet popup bridge — listens for messages from the OAuth tab. | |
| # The popup lands on this same Space with #api_key=… in the | |
| # fragment; its demo.load JS posts the key back to window.opener | |
| # and self-closes. This main tab catches the message, drops the | |
| # key into the hidden #wallet-bridge-key textbox, and the .change | |
| # handler below routes to save_key_from_fragment — adopting the | |
| # wallet WITHOUT a reload. | |
| # | |
| # The bootstrap itself runs via demo.load(js=WALLET_BRIDGE_BOOTSTRAP_JS) | |
| # at the bottom of build_ui. Inline `<script>` inside gr.HTML on | |
| # Gradio v6 / HF Spaces does NOT reliably execute (memory note: | |
| # gradio-v6-upload-zones.md). Past pattern that worked: notepad | |
| # bootstrap moved to demo.load(js=…) for the same reason. | |
| # Top bar — brand + palette toggle + pollen pill + avatar. Dynamic | |
| # on connect via the wallet handlers below (they output to `topbar` | |
| # to refresh the pill). | |
| topbar = gr.HTML(_topbar_html(), elem_id="dc-topbar-wrap") | |
| # Journey strip — one line that makes the core loop legible on | |
| # arrival. Product audit (2026-07-02): the four peer tabs read as | |
| # a toolbox; a stranger had to reverse-engineer that GEN → ANALYSE | |
| # → REGENERATE is the product and the rest is satellite. This strip | |
| # names the loop and carries the wallet nudge inline so the | |
| # cold-start gate is explained before the user hits it. | |
| gr.HTML( | |
| '<div class="dc-journey-strip">' | |
| '<span class="step"><span class="num">1</span> GENERATE <span class="sub">— or upload your own</span></span>' | |
| '<span class="arrow">→</span>' | |
| '<span class="step"><span class="num">2</span> ANALYSE <span class="sub">— measured BPM · key · sections</span></span>' | |
| '<span class="arrow">→</span>' | |
| '<span class="step"><span class="num">3</span> REGENERATE <span class="sub">— variants that stay close</span></span>' | |
| '<span class="nudge">◇ connect Pollinations (top right) to generate & write briefs</span>' | |
| '</div>' | |
| ) | |
| # ── Notepad — right-edge drawer for saved prompts ──────────────── | |
| # Self-contained, client-only. Persists to localStorage so prompts | |
| # survive across visits + the OAuth redirect tab. UX: | |
| # - Notes button injected INSIDE the Generate prompt textbox | |
| # (top-right corner), matching pj-battle's prompt-save pattern | |
| # - "Save current" reads #gen-prompt-box and appends a row | |
| # - Rows: timestamp, first 80 chars, ↑ use, ✕ delete | |
| # - Click ↑ → drops the saved text into the Generate prompt | |
| # and fires an input event so Gradio's server state syncs | |
| # - 50-entry cap (FIFO drop) so localStorage stays light | |
| # The drawer itself is appended to <body> on first open so | |
| # Gradio's block-level overflow:hidden can't clip the slide-in. | |
| # See HANDOFF_DESIGN_BRIEF_v3.md — notepad recommendation section. | |
| gr.HTML( | |
| '<style>' | |
| # Notepad trigger button — solid dark fill against the | |
| # coral-tinted prompt bg from the designer pass so it stays | |
| # visible (previous coral-on-coral version blended away). | |
| # Reads as a "tool" not a CTA — neutral border, ink2 glyph, | |
| # coral on hover so the user knows it does something. | |
| '.dc-notepad-btn{position:absolute !important;top:10px !important;right:10px !important;z-index:50 !important;' | |
| 'width:34px !important;height:34px !important;border-radius:8px !important;' | |
| 'background:var(--paper) !important;color:var(--ink2) !important;' | |
| 'border:1.5px solid var(--chipline) !important;' | |
| 'cursor:pointer !important;' | |
| 'display:flex !important;align-items:center !important;justify-content:center !important;' | |
| 'transition:color 120ms ease, border-color 120ms ease, background 120ms ease, transform 120ms ease !important;' | |
| 'padding:0 !important;box-shadow:0 1px 4px rgba(0,0,0,0.30) !important;}' | |
| '.dc-notepad-btn:hover{background:var(--paper2) !important;color:var(--coral) !important;border-color:var(--coral) !important;transform:translateY(-1px) !important;}' | |
| '.dc-notepad-btn svg{display:block !important;width:18px !important;height:18px !important;}' | |
| '.dc-notepad-drawer{position:fixed;top:0;right:-360px;width:360px;' | |
| 'height:100vh;z-index:9997;background:var(--paper2);' | |
| 'border-left:1px solid var(--line);box-shadow:var(--drop);' | |
| 'transition:right 220ms ease;display:flex;flex-direction:column;}' | |
| '.dc-notepad-drawer.open{right:0;}' | |
| '.dc-notepad-head{padding:14px 16px;border-bottom:1px solid var(--line);' | |
| 'display:flex;align-items:center;justify-content:space-between;}' | |
| '.dc-notepad-head .ttl{font-family:JetBrains Mono,monospace;' | |
| 'font-size:11px;letter-spacing:0.12em;color:var(--label);' | |
| 'text-transform:uppercase;font-weight:600;}' | |
| '.dc-notepad-head .close{background:transparent;border:0;' | |
| 'color:var(--ink3);font-size:18px;cursor:pointer;padding:4px 8px;}' | |
| '.dc-notepad-actions{padding:10px 16px;border-bottom:1px solid var(--line);}' | |
| '.dc-notepad-actions button{background:var(--coral);' | |
| 'color:var(--onCoral);border:0;padding:8px 12px;border-radius:8px;' | |
| 'font-family:Space Grotesk,sans-serif;font-weight:600;font-size:12px;' | |
| 'cursor:pointer;width:100%;}' | |
| '.dc-notepad-actions button:hover{background:var(--coralb);}' | |
| '.dc-notepad-list{flex:1;overflow-y:auto;padding:8px 0;}' | |
| '.dc-notepad-row{padding:10px 16px;border-bottom:1px solid var(--line2);' | |
| 'display:flex;flex-direction:column;gap:6px;}' | |
| '.dc-notepad-row:hover{background:var(--paper3);}' | |
| '.dc-notepad-row .meta{font-family:JetBrains Mono,monospace;' | |
| 'font-size:10px;color:var(--ink3);}' | |
| '.dc-notepad-row .text{font-family:Space Grotesk,sans-serif;' | |
| 'font-size:12px;color:var(--ink2);line-height:1.4;' | |
| 'display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;' | |
| 'overflow:hidden;}' | |
| '.dc-notepad-row .row-actions{display:flex;gap:6px;margin-top:4px;}' | |
| '.dc-notepad-row .row-actions button{flex:1;background:transparent;' | |
| 'border:1px solid var(--chipline);color:var(--ink2);' | |
| 'padding:4px 8px;border-radius:6px;font-family:JetBrains Mono,monospace;' | |
| 'font-size:10px;cursor:pointer;}' | |
| '.dc-notepad-row .row-actions button.use{color:var(--coral);' | |
| 'border-color:color-mix(in srgb,var(--coral) 40%,transparent);}' | |
| '.dc-notepad-row .row-actions button.use:hover{background:var(--coralTint);}' | |
| '.dc-notepad-row .row-actions button.del:hover{color:var(--ink);' | |
| 'border-color:var(--ink3);}' | |
| '.dc-notepad-empty{padding:20px 16px;color:var(--ink3);' | |
| 'font-size:12px;text-align:center;font-family:Space Grotesk,sans-serif;}' | |
| '</style>', | |
| elem_classes=["dc-script-only"], | |
| ) | |
| # Wallet UI is the pollen pill in the top bar. We keep two hidden | |
| # Gradio elements that the JS in the pill targets: | |
| # - #wallet-disconnect-trigger : button the pill clicks on disconnect | |
| # - _fragment_key : hidden textbox demo.load reads to | |
| # capture the api_key after the OAuth | |
| # redirect returns with #api_key=... | |
| disconnect_btn = gr.Button("", elem_id="wallet-disconnect-trigger") | |
| # Crate chip lineage tagger — watches `.dc-crate-strip label` and | |
| # adds `.variant` to chips whose text starts with `↳` so the CSS | |
| # can tint them amber instead of coral. A MutationObserver picks | |
| # up new chips Gradio appends after each gen/regen without us | |
| # having to wire a change handler from every mutating path. | |
| gr.HTML( | |
| '<script>' | |
| '(function(){' | |
| 'function tagCrateChips(){' | |
| 'document.querySelectorAll(".dc-crate-strip label").forEach(function(l){' | |
| 'var t=(l.innerText||"").trim();' | |
| 'if(t.indexOf("\\u21B3")===0)l.classList.add("variant");' | |
| 'else l.classList.remove("variant");' | |
| '});' | |
| '}' | |
| 'tagCrateChips();' | |
| 'var obs=new MutationObserver(tagCrateChips);' | |
| 'document.querySelectorAll(".dc-crate-strip").forEach(function(strip){' | |
| 'obs.observe(strip,{childList:true,subtree:true,characterData:true});' | |
| '});' | |
| # Belt-and-braces — periodic scan in case the strip subtree | |
| # is fully replaced by Gradio rather than mutated in place. | |
| 'setInterval(tagCrateChips,2000);' | |
| '})();' | |
| '</script>', | |
| elem_classes=["dc-script-only"], | |
| ) | |
| _fragment_key = gr.Textbox(visible=False, value="") | |
| # Wallet popup bridge — when the OAuth popup posts its api_key | |
| # back to this opener tab, the message listener (installed in the | |
| # fragment-capture demo.load below) drops the key here, which | |
| # fires _wallet_bridge_key.change → save_key_from_fragment. | |
| # | |
| # Crucial: this textbox is CSS-hidden, NOT visible=False. Gradio | |
| # treats visible=False as "do not mount in the DOM", which makes | |
| # the JS document.getElementById('wallet-bridge-key') return null | |
| # and silently no-ops the bridge (Codex diagnosis 2026-06-25). | |
| # Off-screen positioning keeps the element present so the JS can | |
| # find + write to it. | |
| _wallet_bridge_key = gr.Textbox( | |
| value="", | |
| show_label=False, | |
| container=False, | |
| elem_id="wallet-bridge-key", | |
| elem_classes=["dc-wallet-bridge-hidden"], | |
| ) | |
| # Per-visitor crate isolation — random opaque id used to scope every | |
| # crate read/write to /tmp/abv1-crate/{session_id}/. Initialized by | |
| # a demo.load handler below (secrets.token_hex(8)). Without this, | |
| # on HF Spaces every visitor shared a single flat /tmp dir and saw | |
| # each other's tiles in the strip. | |
| session_id = gr.State("") | |
| # Per-session Pollinations wallet key — populated by save_key_from_fragment | |
| # after OAuth redirect. On HF Spaces this is the ONLY place the key | |
| # lives (never written to disk). Every Pollinations-calling handler | |
| # takes it as an input and threads it down through narrative.py / sa3.py. | |
| api_key_state = gr.State("") | |
| # Running session-spend in pollen. Bumped after every Pollinations | |
| # gen lands; rendered as a small pill in the top bar (`0.84 ◆ session`). | |
| # Cleared on disconnect/connect so each visit starts fresh. | |
| session_spend_state = gr.State(0.0) | |
| # The SA3 prompt that produced the currently-analyzed tile. Set by | |
| # send_tile_to_analysis from tile.source_prompt; threaded into | |
| # run_brief so the LLM can fuse user intent with measured arc. | |
| # Empty string for tiles uploaded from disk (no source prompt known). | |
| current_source_prompt = gr.State("") | |
| # History of regen runs — list of {lens, prompt, paths[5], | |
| # matches[5], label}. Each successful regen appends one entry; | |
| # the user can replay any past batch by clicking its tab in the | |
| # regen_tabs strip above the variants row. Persists for the | |
| # browser session (cleared on reload). | |
| regens_history_state = gr.State([]) | |
| # `current_lens_state` mirrors which lens was clicked when the | |
| # user last selected one — needed so regenerate_variants can | |
| # label the next history entry MATCH·N / LOOSE·N / FREE·N. | |
| current_lens_state = gr.State("loose") | |
| # Live per-axis match breakdown for the current regen batch | |
| # (BPM/Harm/Rhyt scores 0-100 per variant). regenerate_variants | |
| # snapshots this list as its final output slot; commit_regen_to_history | |
| # then writes it into the regens_history entry so tab replay can | |
| # restore the mint-fill bars rather than rendering placeholders. | |
| current_matches_state = gr.State([None, None, None, None, None]) | |
| # Shared state — holds the most recent Analysis + payloads so the | |
| # Compare tab can re-run the same brief through different models. | |
| last_run = gr.State(None) | |
| # Upload-from-disk path moved into the Analysis tab — see ~L1719. | |
| # It was previously above all tabs which read as Generate clutter and | |
| # mis-cued users into thinking the Generate page needed an upload to | |
| # start. Analysis is the consumer, so the accordion lives there now. | |
| # ── MVP2 · Generate tab ────────────────────────────────────────────── | |
| # Prompt → Pollinations SA3 → crate tile → optionally Analyse to | |
| # feed the Compare tab. The crate is a session-scoped file store | |
| # at /tmp/audio-brief-crate/; tiles persist across UI refreshes | |
| # but not across reboots. Lineage tag on each tile shows which | |
| # earlier tile it descended from (when re-generated from a blend). | |
| with gr.Tab("GEN"): | |
| # Row 1 — Prompt textbox full width (the primary input). | |
| # | |
| # `elem_id="gen-prompt-box"` lets the localStorage script below | |
| # find the textarea after Gradio mounts. We persist the prompt | |
| # across tabs because the Pollinations OAuth pill opens a NEW | |
| # tab (window.open(_, '_blank')) — auth lands on the new tab | |
| # and the user's typed prompt is otherwise stuck on the old | |
| # one. Saving to localStorage on input lets the new tab | |
| # restore it on demo.load. | |
| gen_prompt = gr.Textbox( | |
| label="Prompt", | |
| lines=3, | |
| placeholder="drum and bass, dark atmospheric jungle, 174 BPM, heavy bass…", | |
| elem_id="gen-prompt-box", | |
| ) | |
| gr.HTML( | |
| '<script>' | |
| '(function(){' | |
| 'var KEY="abv1.genPrompt";' | |
| 'function ta(){' | |
| 'var box=document.getElementById("gen-prompt-box");' | |
| 'return box?box.querySelector("textarea"):null;' | |
| '}' | |
| 'function fire(el){' | |
| # Gradio listens on input events to sync server state, so | |
| # programmatically setting .value alone won't update the | |
| # server-side value. Dispatch a real input event. | |
| 'el.dispatchEvent(new Event("input",{bubbles:true}));' | |
| 'el.dispatchEvent(new Event("change",{bubbles:true}));' | |
| '}' | |
| 'function restore(){' | |
| 'var el=ta();if(!el)return false;' | |
| 'try{var saved=localStorage.getItem(KEY)||"";' | |
| 'if(saved && !el.value){el.value=saved;fire(el);}' | |
| '}catch(e){}' | |
| 'return true;' | |
| '}' | |
| 'function attach(){' | |
| 'var el=ta();if(!el)return false;' | |
| 'if(el.dataset.abv1Bound)return true;' | |
| 'el.dataset.abv1Bound="1";' | |
| 'el.addEventListener("input",function(){' | |
| 'try{localStorage.setItem(KEY,el.value||"");}catch(e){}' | |
| '});' | |
| 'return true;' | |
| '}' | |
| # The textarea mounts late in Gradio's render cycle; poll | |
| # until it appears, then restore + attach the listener. | |
| 'var tries=0;' | |
| 'var iv=setInterval(function(){' | |
| 'tries++;' | |
| 'if(attach() && restore()){clearInterval(iv);}' | |
| 'else if(tries>40){clearInterval(iv);}' | |
| '},150);' | |
| '})();' | |
| '</script>' | |
| ) | |
| # Row 2 — Model dropdown + Duration chips share the row at equal | |
| # widths. Each is its own column so they line up flush with the | |
| # prompt textbox above and the Generate button below. | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| # SA3 is the only enabled Pollinations gen model right now. | |
| # "Local server" routes through the browser bridge (JS | |
| # prelude on the gen button fetches the user's localhost | |
| # gen server — see docs/local-gen-server-example.py). | |
| # Greyed-out "(soon)" choices snap back to SA3. | |
| gen_model = gr.Dropdown( | |
| choices=[ | |
| ("stable-audio-3-medium", "stable-audio-3-medium"), | |
| ("Local server (your machine)", "local-server"), | |
| ("stable-audio-3-large (soon)", "stable-audio-3-large"), | |
| ("AceStep (soon)", "acestep"), | |
| ("ElevenMusic (soon)", "elevenmusic"), | |
| ], | |
| value=sa3.DEFAULT_MODEL, | |
| label="Model", | |
| info="~0.04 pollen per gen", | |
| ) | |
| with gr.Column(scale=1): | |
| # Duration as chip-row — thumb-friendly on phones/iPads. | |
| # Values cover the common cases: short cue → full track. | |
| # "Custom" uses the sentinel 0 — a static choice so we | |
| # never have to gr.update(choices=…) a Radio (v6 only | |
| # updates the client, not the server-side validator; | |
| # see crate_picker rationale at top of file). The real | |
| # seconds live in gen_duration_custom below, revealed | |
| # when the chip is picked; the click JS prelude | |
| # resolves 0 → custom before anything uses it. | |
| gen_duration = gr.Radio( | |
| choices=[ | |
| ("Cue · 15s", 15), | |
| ("Loop · 30s", 30), | |
| ("Track · 90s", 90), | |
| ("Long · 180s", 180), | |
| ("Custom", 0), | |
| ], | |
| value=15, | |
| label="Duration", | |
| info="Flat cost per gen — pick what you need.", | |
| ) | |
| gen_duration_custom = gr.Number( | |
| value=8, minimum=1, maximum=sa3.ABSOLUTE_MAX_S, | |
| step=1, precision=0, | |
| label="Custom seconds (1–180)", | |
| visible=False, | |
| ) | |
| gen_duration.change( | |
| fn=lambda v: gr.update(visible=(v == 0)), | |
| inputs=[gen_duration], | |
| outputs=[gen_duration_custom], | |
| ) | |
| # VARIATION SPREAD chips removed pending CFG API support on | |
| # Pollinations SA3 (see sa3.py header). Bring back as a real | |
| # gr.Radio when the API exposes a variation/cfg param. A | |
| # greyed-out preview reads as "things here are broken" — kept | |
| # the UI cleaner without it. | |
| # Generate button — single big CTA on the page. The cost | |
| # readout sits under the MODEL dropdown ("~0.04 pollen per | |
| # gen"); duplicating it here was double-noise. | |
| gen_btn = gr.Button("Generate", variant="primary", size="lg", | |
| elem_classes=["dc-full-button"]) | |
| gen_status = gr.Markdown("") | |
| # gen_audio was previously rendered here as "Latest gen" but it | |
| # duplicated crate_preview (since a fresh gen auto-selects its | |
| # tile in the crate, both showed identical audio). Dropping it | |
| # removes the side-by-side confusion. Generate now writes | |
| # straight into the crate preview + meta below. | |
| # Local-server settings — collapsed and hidden by default. Only | |
| # surfaces when the Model dropdown is set to "Local server" (the | |
| # gen_model.change handler below flips visibility). Keeps the | |
| # Generate tab uncluttered for the 95% case of Pollinations SA3. | |
| # Wrapped in an Accordion (instead of a plain Group) so once the | |
| # user has the server up and the URL working, they can collapse | |
| # the whole panel to a single header row. Starts open the first | |
| # time the user picks Local server so they see the status pill + | |
| # install card; click the chevron to collapse. The outer visibility | |
| # toggle (gen_model.change handler below) still hides the entire | |
| # accordion when the model is switched back to Pollinations. | |
| with gr.Accordion("Local server settings", | |
| open=True, visible=False) as local_gen_group: | |
| # Status pill — auto-detected on mount via the JS block | |
| # below. Reflects whether the URL in local_gen_url is | |
| # responding to `GET /` with the audio-brief local-gen | |
| # service signature. Green ✓ when a healthy server is up; | |
| # grey when not detected. | |
| # Wrapper banner — revealed by the probe script below when | |
| # the Space is loaded inside huggingface.co/spaces/* iframe. | |
| # That wrapper silently blocks all fetches to http://localhost, | |
| # so Local-server gen can never work from there. The direct | |
| # .hf.space URL has no such block. JS fills the href with the | |
| # current origin so this works for any forked Space too. | |
| gr.HTML( | |
| '<div id="dc-local-iframe-warn" class="dc-local-iframe-warn" style="display:none">' | |
| '<strong>⚠ Local-server gen needs this Space in its own tab.</strong> ' | |
| 'The huggingface.co wrapper blocks browser fetches to localhost. ' | |
| '<a id="dc-local-direct-link" href="#" target="_top" rel="noopener">Open the direct .hf.space URL →</a>' | |
| '</div>' | |
| ) | |
| gr.HTML( | |
| '<div id="dc-local-status" class="dc-local-status not-detected">' | |
| '<span class="dot">●</span>' | |
| '<span class="msg">Checking your machine for a local gen server…</span>' | |
| '<button id="dc-local-redetect" type="button">Re-detect</button>' | |
| '</div>' | |
| ) | |
| local_gen_url = gr.Textbox( | |
| value="http://localhost:7864", | |
| label="Local server URL", | |
| info="Base URL of your local gen server. The browser POSTs to <URL>/generate.", | |
| elem_id="dc-local-url", | |
| ) | |
| # Hidden slot — JS prelude on gen_btn writes the b64 audio | |
| # from the localhost fetch here before the Python handler runs. | |
| local_gen_b64 = gr.Textbox(visible=False, value="") | |
| # Second hidden slot — JS surfaces the actual browser-side | |
| # error (TypeError, HTTP 500, CORS, mixed-content, etc.) | |
| # so Python can show the real message instead of the | |
| # generic "didn't return audio" line. | |
| local_gen_error = gr.Textbox(visible=False, value="") | |
| # Same pair, for the Analysis-tab Regenerate button when | |
| # gen_model == "local-server". JS prelude on regen_btn | |
| # fetches N variants sequentially, base64-encodes each, | |
| # and writes a JSON array string here. Python parses, | |
| # decodes, and writes the files into the session crate. | |
| local_regen_b64_json = gr.Textbox(visible=False, value="") | |
| local_regen_error = gr.Textbox(visible=False, value="") | |
| # Setup help — collapsed by default. Apple Silicon only for | |
| # now (the bootstrap.sh from Stability is MLX-native). | |
| with gr.Accordion("Don't have it yet? · Install local SA3 (Apple Silicon)", | |
| open=False): | |
| gr.HTML( | |
| '<div class="dc-local-setup">' | |
| '<div class="step"><span class="num">1</span>' | |
| '<span class="hdr">Install SA3 · paste in Terminal</span></div>' | |
| '<div class="code-row">' | |
| '<code id="dc-install-cmd">curl -LsSf https://raw.githubusercontent.com/Stability-AI/stable-audio-3/main/optimized/mlx/bootstrap.sh | bash</code>' | |
| '<button class="copy" data-tgt="dc-install-cmd" type="button">Copy</button>' | |
| '</div>' | |
| '<div class="hint">Pulls the MLX-optimised Stable Audio 3 build to ~/sa3_mlx. ~5 min on a fresh machine.</div>' | |
| '<div class="step"><span class="num">2</span>' | |
| '<span class="hdr">Run the bridge · paste in Terminal</span></div>' | |
| '<div class="code-row">' | |
| '<code id="dc-run-cmd">pip install fastapi uvicorn && curl -fsSL https://huggingface.co/spaces/kalamishere/audio-brief/raw/main/docs/local-gen-server-example.py | LOCAL_GEN_BACKEND=mlx-sa3 python3 -</code>' | |
| '<button class="copy" data-tgt="dc-run-cmd" type="button">Copy</button>' | |
| '</div>' | |
| '<div class="hint">Streams the 40-line FastAPI bridge straight from this Space into Python — no file saved. Server boots at http://localhost:7864. First gen takes ~15s for MLX warmup; subsequent gens ~5-8s each. <strong>$0 per call.</strong> Stop with Ctrl+C.</div>' | |
| '</div>' | |
| ) | |
| # Probe script. Runs on mount, on Local-server selection, | |
| # on Re-detect click, and when the URL field changes. Pure | |
| # client-side — the Space backend never sees the result. | |
| gr.HTML( | |
| '<script>' | |
| '(function(){' | |
| # Wrapper detection — if we're inside an iframe (the | |
| # huggingface.co/spaces/* embed), reveal the banner with | |
| # a link to the direct .hf.space URL. Polled because the | |
| # banner element may not be in the DOM yet when this | |
| # script first runs (Gradio re-renders aggressively on | |
| # tab switches). | |
| 'function bindIframeWarn(){' | |
| 'try{if(window.top===window.self)return true;}catch(e){}' | |
| 'var w=document.getElementById("dc-local-iframe-warn");' | |
| 'var a=document.getElementById("dc-local-direct-link");' | |
| 'if(!w||!a)return false;' | |
| 'a.href=window.location.origin+"/";' | |
| 'a.textContent="Open "+window.location.origin+" →";' | |
| 'w.style.display="block";' | |
| 'return true;' | |
| '}' | |
| 'var fw=0;var fwIv=setInterval(function(){fw++;if(bindIframeWarn() || fw>60)clearInterval(fwIv);},200);' | |
| 'var lastUrl=null;var probing=false;' | |
| 'function getUrl(){' | |
| 'var box=document.getElementById("dc-local-url");' | |
| 'var ta=box?box.querySelector("input,textarea"):null;' | |
| 'return ta?ta.value.trim().replace(/\\/$/,""):"";' | |
| '}' | |
| 'function setStatus(state,msg){' | |
| 'var s=document.getElementById("dc-local-status");' | |
| 'if(!s)return;' | |
| 's.className="dc-local-status "+state;' | |
| 'var m=s.querySelector(".msg");if(m)m.textContent=msg;' | |
| '}' | |
| # Fallback ports we silently sweep on the FIRST probe of a | |
| # session, if the user's URL doesn't answer. Covers common | |
| # dev defaults so a user who already has a bridge on 7860 | |
| # / 8000 / etc. gets auto-discovered. After the first | |
| # successful (or fully-exhausted) sweep, we stop sweeping | |
| # — re-probes only hit whatever's currently in the URL | |
| # field (the user has had a chance to edit it). | |
| 'var FALLBACK_PORTS=[7864,7860,8000,8080,5000];' | |
| 'var sweptOnce=false;' | |
| 'function setUrlField(newUrl){' | |
| 'var box=document.getElementById("dc-local-url");' | |
| 'var ta=box?box.querySelector("input,textarea"):null;' | |
| 'if(!ta)return;' | |
| 'ta.value=newUrl;' | |
| 'ta.dispatchEvent(new Event("input",{bubbles:true}));' | |
| 'ta.dispatchEvent(new Event("change",{bubbles:true}));' | |
| '}' | |
| # Loopback-only — same rule the gen-click prelude enforces. | |
| # The probe fetches arbitrary URLs from the textbox; without | |
| # this check, a malicious / stale URL could be GET'd from | |
| # the browser. | |
| 'function isLoopback(u){' | |
| 'try{var p=new URL(u);' | |
| 'var hosts=new Set(["localhost","127.0.0.1","[::1]","::1"]);' | |
| 'return (p.protocol==="http:"||p.protocol==="https:") && hosts.has((p.hostname||"").toLowerCase());' | |
| '}catch(e){return false;}' | |
| '}' | |
| 'async function probeOne(url){' | |
| 'if(!isLoopback(url)){return null;}' | |
| 'var ac=new AbortController();' | |
| 'var t=setTimeout(function(){ac.abort();},2000);' | |
| 'try{' | |
| 'var r=await fetch(url+"/",{method:"GET",signal:ac.signal});' | |
| 'clearTimeout(t);' | |
| 'if(!r.ok)return null;' | |
| 'var j=await r.json();' | |
| 'if(j && typeof j.service==="string" && j.service.indexOf("audio-brief local gen")>=0){' | |
| 'return {url:url,backend:j.backend||"unknown",service:j.service};' | |
| '}' | |
| 'return {url:url,backend:null,partial:true};' | |
| '}catch(e){clearTimeout(t);return null;}' | |
| '}' | |
| 'function announce(hit){' | |
| 'var be=hit.backend||"unknown";' | |
| 'var label=be==="mlx-sa3"?"Local SA3 ready":be==="stub"?"Local stub ready (sine tone)":"Local server ready";' | |
| 'setStatus("detected","✓ "+label+" — "+hit.url+" · "+be+" · $0/call");' | |
| '}' | |
| 'async function probe(){' | |
| 'if(probing)return;probing=true;' | |
| 'var url=getUrl();if(!url){probing=false;return;}' | |
| 'lastUrl=url;' | |
| 'setStatus("not-detected","Checking "+url+"…");' | |
| 'var hit=await probeOne(url);' | |
| 'if(hit && !hit.partial){announce(hit);probing=false;return;}' | |
| 'if(hit && hit.partial){' | |
| 'setStatus("not-detected","Reachable at "+url+" but doesn\\u2019t look like the audio-brief bridge.");' | |
| 'probing=false;return;' | |
| '}' | |
| # Primary URL didn't answer. If this is the first probe | |
| # of the session, sweep the fallback port list silently | |
| # — any hit auto-fills the URL field. | |
| 'if(!sweptOnce){' | |
| 'sweptOnce=true;' | |
| 'setStatus("not-detected","Scanning common ports (7864 · 7860 · 8000 · 8080 · 5000)…");' | |
| 'for(var i=0;i<FALLBACK_PORTS.length;i++){' | |
| 'var port=FALLBACK_PORTS[i];' | |
| 'var tryUrl="http://localhost:"+port;' | |
| 'if(tryUrl===url)continue;' | |
| 'var swept=await probeOne(tryUrl);' | |
| 'if(swept && !swept.partial){' | |
| 'setUrlField(tryUrl);' | |
| 'announce(swept);' | |
| 'probing=false;return;' | |
| '}' | |
| '}' | |
| '}' | |
| # Nothing found. Be explicit that the URL is editable | |
| # for users running on a non-default port. | |
| 'setStatus("not-detected","Not reachable at "+url+" · edit the URL above if you run on a different port, or install + start the server below.");' | |
| 'probing=false;' | |
| '}' | |
| # The Download-bridge-file step + inline SERVER_PY blob | |
| # were removed when the setup card collapsed to two steps: | |
| # the run command now curls the bridge straight from | |
| # huggingface.co/spaces/kalamishere/audio-brief/raw/main/ | |
| # docs/local-gen-server-example.py and pipes into python3. | |
| # No local file saved. | |
| 'function bindAll(){' | |
| 'var redetect=document.getElementById("dc-local-redetect");' | |
| 'if(!redetect)return false;' | |
| 'if(redetect.dataset.bound)return true;' | |
| 'redetect.dataset.bound="1";' | |
| 'redetect.addEventListener("click",function(ev){ev.preventDefault();probe();});' | |
| 'document.querySelectorAll(".dc-local-setup .copy").forEach(function(b){' | |
| 'if(b.dataset.bound)return;b.dataset.bound="1";' | |
| 'b.addEventListener("click",function(ev){' | |
| 'ev.preventDefault();' | |
| 'var tgt=document.getElementById(b.dataset.tgt);' | |
| 'if(!tgt)return;' | |
| 'try{navigator.clipboard.writeText(tgt.textContent);' | |
| 'var prev=b.textContent;b.textContent="Copied ✓";' | |
| 'setTimeout(function(){b.textContent=prev;},1200);' | |
| '}catch(e){}' | |
| '});' | |
| '});' | |
| 'return true;' | |
| '}' | |
| 'var tries=0;' | |
| 'var iv=setInterval(function(){tries++;if(bindAll() || tries>60)clearInterval(iv);},200);' | |
| # Initial probe + re-probe whenever URL field changes (debounced). | |
| 'var probeTimer=null;' | |
| 'function schedule(){clearTimeout(probeTimer);probeTimer=setTimeout(probe,400);}' | |
| 'function watchUrl(){' | |
| 'var box=document.getElementById("dc-local-url");' | |
| 'var ta=box?box.querySelector("input,textarea"):null;' | |
| 'if(!ta||ta.dataset.abv1Watching)return false;' | |
| 'ta.dataset.abv1Watching="1";' | |
| 'ta.addEventListener("input",schedule);' | |
| 'return true;' | |
| '}' | |
| 'var w=0;var wIv=setInterval(function(){w++;if(watchUrl() || w>60)clearInterval(wIv);},200);' | |
| # Defer first probe until after the local_gen_group becomes | |
| # visible — otherwise it runs on every mount even when the | |
| # user is on Pollinations. | |
| 'function watchVisibility(){' | |
| 'var g=document.querySelector("[id^=local_gen_group]");' | |
| 'if(!g)return false;' | |
| # Probe whenever the group transitions visible. | |
| 'var o=new MutationObserver(function(){' | |
| 'var hidden=g.classList.contains("hidden")||g.style.display==="none";' | |
| 'if(!hidden && getUrl())schedule();' | |
| '});' | |
| 'o.observe(g,{attributes:true,attributeFilter:["class","style"]});' | |
| 'if(!g.classList.contains("hidden") && getUrl())schedule();' | |
| 'return true;' | |
| '}' | |
| 'var v=0;var vIv=setInterval(function(){v++;if(watchVisibility() || v>60)clearInterval(vIv);},200);' | |
| '})();' | |
| '</script>' | |
| ) | |
| # Crate header — count + empty-state hint. Updated by every | |
| # handler that touches the crate (gen, delete, regen, use-for- | |
| # analysis) so the user always sees current size. | |
| crate_header = gr.HTML(_crate_header_html()) | |
| # Chip strip — horizontal scrolling Radio. Each chip = one tile. | |
| # The demo.load(refresh_crate) handler at the bottom of build_ui | |
| # populates the server-side choices on session start, dodging | |
| # Gradio v6's strict-validator-on-empty-choices behaviour that | |
| # used to break "Use for analysis" on first interaction. | |
| crate_picker = gr.Radio( | |
| choices=crate.tile_choices(), | |
| show_label=False, | |
| container=False, | |
| interactive=True, | |
| elem_classes=["dc-crate-strip"], | |
| ) | |
| # Action row — only meaningful once a tile is selected. Starts | |
| # hidden so a first-time visitor with an empty crate doesn't see | |
| # dead controls; select_tile reveals it on chip click. | |
| with gr.Row(elem_classes=["dc-crate-row"], visible=False) as crate_actions_row: | |
| use_for_analysis_btn = gr.Button("Use for analysis →", variant="primary", scale=4) | |
| delete_tile_btn = gr.Button("✕", elem_classes=["dc-icon-btn"], scale=0, min_width=42) | |
| # Refresh button hidden — gen/delete/regen auto-update via outputs. | |
| refresh_crate_btn = gr.Button("", visible=False) | |
| # Selected tile audio — the one place a clip plays from on this | |
| # tab. Hidden until something's selected; flipped to visible by | |
| # select_tile / delete_tile via gr.update. Generates auto-select | |
| # their new tile, so this also serves as "Latest gen" post-click. | |
| crate_preview = gr.Audio(label="★ Now playing", interactive=False, | |
| type="filepath", | |
| visible=False) | |
| crate_meta = gr.Markdown("", visible=False) | |
| # ── Analysis tab — visible so the user sees the brief + waveform + ─ | |
| # measurements after clicking "Use for analysis" on a tile or the top | |
| # analyze button. The Data / Use / Raw tabs stay hidden; this is the | |
| # one MVP1-derived view kept in MVP2 because it's where the user | |
| # confirms what the LLM heard. | |
| with gr.Tab("ANALYSE"): | |
| # Start band — two equal cards. Left card runs analysis | |
| # (upload + settings + Analyze button). Right card LOADS an | |
| # already-computed analysis from a shared .abv1 bundle. | |
| # Two distinct verbs (analyze vs load); the layout makes | |
| # that distinction visible at a glance. Codex layout pass. | |
| with gr.Row(equal_height=True, elem_classes=["dc-analyse-ingest-row"]): | |
| # Left: Upload + settings + Analyze, all in one card. | |
| with gr.Column(elem_classes=["dc-ingest-card", "dc-ingest-primary"]): | |
| gr.HTML('<div class="dc-ingest-title">Upload audio</div>' | |
| '<div class="dc-ingest-subtitle">start with your own file</div>' | |
| # Expectations line. As of the essentia stage, | |
| # genre/mood/instrument are MEASURED (Discogs-EffNet | |
| # + MTG-Jamendo), not guessed from the numbers — so | |
| # the old "vibe is LLM-inferred, can miss" caveat no | |
| # longer applies. The LLM only writes prose around | |
| # the measured tags now. | |
| '<div class="dc-ingest-note">measures BPM · key ·' | |
| ' sections · loudness · stems, plus genre · mood ·' | |
| ' instrument (measured, not guessed). The brief just' | |
| ' writes prose around the numbers.</div>') | |
| audio_in = gr.File( | |
| type="filepath", | |
| # "audio" alone maps to accept="audio/*" which the | |
| # browser reads strictly — video containers (and on | |
| # some platforms even m4a) get greyed out. List the | |
| # extensions explicitly; _prepare_source_audio | |
| # ffmpeg-extracts anything librosa can't decode. | |
| file_types=[ | |
| "audio", | |
| ".mp3", ".wav", ".flac", ".ogg", ".aiff", ".aif", | |
| ".m4a", ".aac", ".opus", ".wma", | |
| ".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", | |
| ], | |
| label="reference audio · or video (audio is extracted)", | |
| height=120, | |
| elem_classes=["dc-upload-audio"], | |
| ) | |
| # YouTube ingest — paste a link, we yt-dlp the audio | |
| # track to WAV and drop it into the upload slot above. | |
| # Fetch is separate from Analyze so BPM-prior settings | |
| # can still be adjusted before the pipeline runs. | |
| with gr.Row(elem_classes=["dc-yt-row"]): | |
| yt_url = gr.Textbox( | |
| value="", show_label=False, container=False, | |
| placeholder="…or paste a YouTube link", | |
| scale=4, | |
| ) | |
| yt_fetch_btn = gr.Button("Fetch", size="sm", scale=1) | |
| yt_status = gr.Markdown("", elem_classes=["dc-yt-status"]) | |
| # Settings live INSIDE the Upload card — they only | |
| # apply to the upload path. Stacked vertically inside | |
| # the card so the card stays narrow but readable. | |
| bpm_mode = gr.Radio( | |
| choices=["Auto (genre)", "Manual BPM"], | |
| value="Auto (genre)", | |
| label="BPM prior", | |
| info="Auto seeds the beat tracker from genre; Manual locks it to a number.", | |
| ) | |
| with gr.Group(visible=True) as genre_grp: | |
| genre = gr.Dropdown( | |
| GENRE_CHOICES, value="default", label="Genre", | |
| info="Hint so the beat tracker doesn't lock onto half-tempo.", | |
| ) | |
| with gr.Group(visible=False) as manual_grp: | |
| bpm_num = gr.Number( | |
| value=120, label="BPM", | |
| info="Used as the start_bpm prior — final BPM may still differ.", | |
| ) | |
| model_dd = gr.Dropdown( | |
| TEXT_MODELS, value=DEFAULT_MODEL_A, label="Narrative model", | |
| info="Pollinations text model.", | |
| ) | |
| run_btn = gr.Button("Analyze audio", variant="primary") | |
| # Right: Import card — drop a .abv1 and the analysis loads | |
| # directly into the same UI surfaces. No "click Analyze | |
| # after" — the bundle already contains the analysis. | |
| with gr.Column(elem_classes=["dc-ingest-card", "dc-ingest-secondary"]): | |
| gr.HTML('<div class="dc-ingest-title">Import shared analysis</div>' | |
| '<div class="dc-ingest-subtitle">open a .abv1 bundle</div>') | |
| import_file = gr.File( | |
| # No `label=` — the title above already says what | |
| # this is; Gradio's File label renders as a chip | |
| # that overlapped the "Click to Upload" text. | |
| file_types=[".abv1", ".zip"], | |
| type="filepath", | |
| height=120, | |
| elem_classes=["dc-import-file"], | |
| show_label=False, | |
| ) | |
| import_status = gr.Markdown("") | |
| caption = gr.Markdown() | |
| # Post-result action row — Validate-vs-Gemini + Share analysis. | |
| # Both stay visible-but-disabled in empty state (matches the | |
| # 'always-visible, gated by interactivity' pattern Codex | |
| # called out). Activate via last_run.change. | |
| with gr.Row(): | |
| validate_btn = gr.Button( | |
| "Validate vs Gemini ▸", | |
| size="sm", interactive=False, | |
| elem_classes=["dc-validate-btn"], | |
| ) | |
| share_btn = gr.Button( | |
| "Share analysis ▸", | |
| size="sm", interactive=False, | |
| elem_classes=["dc-validate-btn"], | |
| ) | |
| # Share-bundle options — meaningful only AFTER analysis lands. | |
| with gr.Group(visible=False) as share_options_group: | |
| share_include_variants = gr.Checkbox( | |
| label="Include the current variants (~+14 MB for 5 × 30s mp3)", | |
| value=False, | |
| ) | |
| share_file = gr.File( | |
| label="audio-brief bundle (.abv1)", | |
| visible=False, | |
| interactive=False, | |
| ) | |
| # 6-up metric tile grid: BPM / KEY / LUFS / TRUE PK / LRA / LENGTH. | |
| # Empty placeholder shown until the pipeline fills it in. | |
| metrics_html = gr.HTML(_metrics_html(None)) | |
| paragraph = gr.Markdown() | |
| # Anchor audio player — a raw HTML5 <audio> tag (NOT gr.Audio) | |
| # so the section-seek chips can grab it by id and call | |
| # currentTime + play(). Gradio v6's gr.Audio is wavesurfer-based | |
| # and exposes no <audio> element to external JS. | |
| anchor_player = gr.HTML(visible=False) | |
| # Waveform image — hidden until analysis runs. | |
| wave = gr.Image(label="waveform + sections", show_label=True, | |
| height=240, visible=False) | |
| # Clickable section seek strip — INTRO/BUILD/CORE/OUTRO buttons | |
| # that find `#abv1-anchor` (the raw <audio> above) and seek it | |
| # to the section's start time. Hidden until analysis lands. | |
| section_seek = gr.HTML(visible=False) | |
| # ── Derived prompt card ─────────────────────────────────────── | |
| # Coral-bordered card: the editable SA3 prompt the LLM derived | |
| # from the brief + measurements. User can tweak, then Regenerate | |
| # spawns 5 SA3 variants → playable inline + added to crate. | |
| with gr.Column(elem_classes=["dc-derived-card"]): | |
| gr.HTML('<div class="dc-label coral">DERIVED PROMPT</div>') | |
| # Lens chips — pick how strictly variants follow the anchor. | |
| # MATCH/LOOSE/FREE map to three lens prompts pre-computed in | |
| # parallel during run_brief; clicking a chip swaps the | |
| # textbox value INSTANTLY from cache (no LLM cost). | |
| gr.HTML( | |
| '<div class="dc-label" style="margin-top:10px;">PROMPT LENS · ' | |
| 'how strictly variants follow the anchor</div>' | |
| ) | |
| sa3_lens_choice = gr.Radio( | |
| choices=[ | |
| ("▮▮▮▮ MATCH · mirror exact arc", "match"), | |
| ("▮▮▯▯ LOOSE · keep feel, vary form", "loose"), | |
| ("▮▯▯▯ FREE · descriptors run free", "free"), | |
| ], | |
| value="loose", | |
| show_label=False, | |
| container=False, | |
| elem_classes=["dc-lens-chips"], | |
| ) | |
| sa3_var = gr.Textbox( | |
| show_label=False, | |
| lines=4, | |
| placeholder="Run analyze first — the blended SA3 prompt lands here. You can edit it before regenerating.", | |
| ) | |
| # Two control cards side-by-side — Variant Duration on the | |
| # left, Variants on the right. Stacked vertically the whole | |
| # band was very tall and read as heavier than the Regenerate | |
| # button beneath it; two columns balance the visual weight | |
| # and let the user see both knobs at once. | |
| with gr.Row(equal_height=True, | |
| elem_classes=["dc-regen-control-row"]): | |
| with gr.Column(scale=1, | |
| elem_classes=["dc-regen-control-card"]): | |
| # Per-variant duration. Default matches Generate's | |
| # Cue mode; bumped to the anchor's measured duration | |
| # via last_run.change so a 90s track regenerates at | |
| # 90s by default. Cost is flat per call on SA3, so | |
| # the user picks length, not budget. | |
| gr.HTML('<div class="dc-label">VARIANT DURATION</div>') | |
| # "Custom" = sentinel 0, same pattern as GEN's | |
| # duration radio (static choice + Number, never | |
| # dynamic Radio choices). _suggest_duration picks | |
| # it automatically when the anchor's length isn't | |
| # near a preset — a 4s ident regenerates at 4s. | |
| regen_duration = gr.Radio( | |
| choices=[ | |
| ("Cue · 15s", 15), | |
| ("Loop · 30s", 30), | |
| ("Track · 90s", 90), | |
| ("Long · 180s", 180), | |
| ("Custom", 0), | |
| ], | |
| value=15, | |
| show_label=False, | |
| info="Each variant generates at this length. Flat cost per call.", | |
| ) | |
| regen_duration_custom = gr.Number( | |
| value=8, minimum=1, maximum=sa3.ABSOLUTE_MAX_S, | |
| step=1, precision=0, | |
| label="Custom seconds (1–180)", | |
| visible=False, | |
| ) | |
| regen_duration.change( | |
| fn=lambda v: gr.update(visible=(v == 0)), | |
| inputs=[regen_duration], | |
| outputs=[regen_duration_custom], | |
| ) | |
| with gr.Column(scale=1, | |
| elem_classes=["dc-regen-control-card"]): | |
| # Variant count — the biggest cost lever. 1 = preview | |
| # (validate the derived prompt before burning a batch); | |
| # 3/5 = production spread. Default 5 stays the canonical | |
| # experience; 1 saves 80% for sanity checks. Plumbed | |
| # through into regenerate_variants(n_variants=…); also | |
| # drives the regen-button label + cost pip dynamically | |
| # via .change. | |
| gr.HTML('<div class="dc-label">VARIANTS</div>') | |
| regen_count = gr.Radio( | |
| choices=[ | |
| ("Preview · 1", 1), | |
| ("Trio · 3", 3), | |
| ("Set · 5", 5), | |
| ], | |
| value=5, | |
| show_label=False, | |
| info="How many takes per regenerate — flat SA3 cost per take. Use Preview to validate the prompt cheaply before committing.", | |
| ) | |
| # Disabled by default — gated on `last_run` flipping to a | |
| # populated analysis dict so users can't burn SA3 calls on | |
| # the empty structural fallback prompt before the LLM brief | |
| # has had a chance to land. | |
| regen_btn = gr.Button( | |
| "Regenerate · 5 variants ▸", | |
| variant="primary", size="lg", | |
| interactive=False, | |
| ) | |
| # Cost pip — dynamic on regen_count change so the user sees | |
| # the spread of their chip selection before clicking. | |
| regen_cost_pip = gr.HTML( | |
| cost_pip_html(SA3_COST_PER_CALL * 5, suffix="5 variants · flat") | |
| ) | |
| regen_status = gr.Markdown("") | |
| # Regen history tabs — every successful regen appends an | |
| # entry to regens_history_state and shows up here as a tab | |
| # labelled `{LENS}·{N}` (MATCH·1, LOOSE·2, etc.). Click a | |
| # tab to replay that batch into the variants row + prompt | |
| # card below. Latest tab auto-selects on each new regen. | |
| regen_tabs = gr.Radio( | |
| choices=[], | |
| show_label=False, | |
| container=False, | |
| interactive=True, | |
| visible=False, | |
| elem_classes=["dc-regen-tabs"], | |
| ) | |
| # Prompt-used readout — shows the exact derived prompt that | |
| # produced the variant set below, so the user can see which | |
| # phrasing led to which 5 gens. Updated by regenerate_variants | |
| # on each click and by regen_tabs.change on tab swap. | |
| variants_prompt_used = gr.HTML(visible=False) | |
| # Anchor + 5 variants on one row so users can A/B-test | |
| # each variant against the original. Each card is a Column | |
| # with a custom HTML header (BPM/Harm/Rhyt bars horizontal, | |
| # v-name right) + an Audio component (label hidden, the | |
| # custom header replaces it). | |
| gr.HTML('<div class="dc-label" style="margin-top:14px;">' | |
| 'ANCHOR + 5 VARIANTS</div>') | |
| with gr.Row(elem_classes=["dc-variant-row"]): | |
| with gr.Column(scale=1, elem_classes=["dc-variant-cell"]): | |
| anchor_title = gr.HTML( | |
| '<div class="dc-variant-title">' | |
| '<div class="bars anchor-label">★ ANCHOR</div>' | |
| '<div class="name">orig</div>' | |
| '</div>', | |
| visible=False, | |
| ) | |
| anchor_audio = gr.Audio(label="", show_label=False, | |
| interactive=False, | |
| type="filepath", scale=1, | |
| visible=False, | |
| elem_classes=["dc-anchor-audio"]) | |
| with gr.Column(scale=1, elem_classes=["dc-variant-cell"]): | |
| var1_title = gr.HTML(_variant_title_html(0, None)) | |
| var1 = gr.Audio(label="", show_label=False, | |
| interactive=False, type="filepath", scale=1, | |
| elem_id="abv1-var1") | |
| with gr.Column(scale=1, elem_classes=["dc-variant-cell"]): | |
| var2_title = gr.HTML(_variant_title_html(1, None)) | |
| var2 = gr.Audio(label="", show_label=False, | |
| interactive=False, type="filepath", scale=1, | |
| elem_id="abv1-var2") | |
| with gr.Column(scale=1, elem_classes=["dc-variant-cell"]): | |
| var3_title = gr.HTML(_variant_title_html(2, None)) | |
| var3 = gr.Audio(label="", show_label=False, | |
| interactive=False, type="filepath", scale=1, | |
| elem_id="abv1-var3") | |
| with gr.Column(scale=1, elem_classes=["dc-variant-cell"]): | |
| var4_title = gr.HTML(_variant_title_html(3, None)) | |
| var4 = gr.Audio(label="", show_label=False, | |
| interactive=False, type="filepath", scale=1, | |
| elem_id="abv1-var4") | |
| with gr.Column(scale=1, elem_classes=["dc-variant-cell"]): | |
| var5_title = gr.HTML(_variant_title_html(4, None)) | |
| var5 = gr.Audio(label="", show_label=False, | |
| interactive=False, type="filepath", scale=1, | |
| elem_id="abv1-var5") | |
| # Reset wavesurfer playhead to 0:00 whenever a variant's | |
| # backing <audio src> changes. Gradio v6's wavesurfer | |
| # preserves currentTime across value updates — so a freshly- | |
| # streamed gen would start from wherever the previous file | |
| # was paused. We watch each variant's audio child for src | |
| # mutations and force time-reset + pause. | |
| gr.HTML( | |
| '<script>' | |
| '(function(){' | |
| 'var IDS=["abv1-var1","abv1-var2","abv1-var3","abv1-var4","abv1-var5"];' | |
| 'function resetAudio(audio){' | |
| 'try{audio.pause();audio.currentTime=0;}catch(e){}' | |
| '}' | |
| 'function attachWatcher(rootId){' | |
| 'var root=document.getElementById(rootId);' | |
| 'if(!root||root.dataset.abv1Reset)return false;' | |
| 'root.dataset.abv1Reset="1";' | |
| # Observe the variant subtree for audio[src] changes. | |
| # Wavesurfer instantiates the <audio> tag lazily and | |
| # re-uses it across value updates; the src attribute | |
| # mutation is the signal we want. | |
| 'var obs=new MutationObserver(function(records){' | |
| 'records.forEach(function(r){' | |
| 'if(r.type==="attributes" && r.attributeName==="src" && r.target.tagName==="AUDIO"){' | |
| 'resetAudio(r.target);' | |
| '}' | |
| 'if(r.type==="childList"){' | |
| 'r.addedNodes.forEach(function(n){' | |
| 'if(n.tagName==="AUDIO"){resetAudio(n);}' | |
| 'else if(n.querySelectorAll){' | |
| 'n.querySelectorAll("audio").forEach(resetAudio);' | |
| '}' | |
| '});' | |
| '}' | |
| '});' | |
| '});' | |
| 'obs.observe(root,{childList:true,subtree:true,attributes:true,attributeFilter:["src"]});' | |
| 'return true;' | |
| '}' | |
| 'var tries=0;' | |
| 'var iv=setInterval(function(){' | |
| 'tries++;' | |
| 'var all=IDS.every(attachWatcher);' | |
| 'if(all||tries>60){clearInterval(iv);}' | |
| '},250);' | |
| '})();' | |
| '</script>' | |
| ) | |
| # Downstream outputs — three copy-paste artefacts a producer | |
| # leaves with after the brief. Collapsed by default; expand | |
| # when you want to take the brief into another tool. The mix | |
| # chain is the standout — Ableton stock-device starting point | |
| # with parameters anchored to LUFS/LRA/true-peak/per-stem. | |
| with gr.Accordion("Downstream outputs · Ableton starting point + SA3 prompts", | |
| open=False): | |
| gr.HTML( | |
| '<div class="dc-label" style="margin-top:4px;">MIX CHAIN — ABLETON</div>' | |
| ) | |
| mix_chain = gr.Textbox( | |
| label="", show_label=False, lines=18, interactive=True, | |
| ) | |
| gr.HTML( | |
| '<div class="dc-label" style="margin-top:14px;">SA3 MATCH-STYLE PROMPT</div>' | |
| ) | |
| sa3_match = gr.Textbox( | |
| label="", show_label=False, lines=8, interactive=True, | |
| ) | |
| gr.HTML( | |
| '<div class="dc-label" style="margin-top:14px;">ABLETON CLIP PLAN (JSON)</div>' | |
| ) | |
| clip_plan = gr.Textbox( | |
| label="", show_label=False, lines=12, interactive=True, | |
| ) | |
| with gr.Tab("Data", visible=False): | |
| data_tbl = gr.Dataframe( | |
| headers=["field", "value"], label="measurements", | |
| interactive=False, wrap=True, | |
| ) | |
| sections_tbl = gr.Dataframe( | |
| headers=["label", "start_s", "end_s", "length_s"], | |
| label="sections", interactive=False, | |
| ) | |
| tags_tbl = gr.Dataframe( | |
| headers=["kind", "label", "score"], | |
| label="tags (top-5 per kind)", interactive=False, | |
| ) | |
| errors_box = gr.Textbox(label="stage errors", lines=4, interactive=False) | |
| timings_box = gr.Textbox(label="timings", lines=6, interactive=False) | |
| # The hidden "Use" tab used to host sa3_match / clip_plan / mix_chain. | |
| # All three moved up into the Analysis tab's "Downstream outputs" | |
| # accordion (per v2 design: keep the artefacts next to the brief | |
| # that produced them). The Compare tab still has its own A-vs-B | |
| # mix-chain comparison — that one's about which LLM read it best, | |
| # which is a different question. | |
| with gr.Tab("LAB · why grounding?"): | |
| gr.Markdown( | |
| "**The experiment behind this app.** Does giving an LLM *measured*" | |
| " numbers beat letting it listen? Pit three models against the same" | |
| " track: **A** and **B** receive the measured analysis JSON; **C**" | |
| " receives the raw audio with **no measurements** — it has to guess" | |
| " BPM, key, loudness by ear. Run an analysis on the ANALYSE tab" | |
| " first, then come back and see the difference grounding makes." | |
| ) | |
| # Button on its own row above the selectors so the three model | |
| # dropdowns line up cleanly with A/B/C status headers, briefs, | |
| # and the mix-chain table columns below. | |
| with gr.Row(): | |
| compare_btn = gr.Button("Run side-by-side", variant="primary", size="lg") | |
| with gr.Row(equal_height=True): | |
| # Each dropdown lives in its own Column so the three line | |
| # up at the top regardless of any captions hanging beneath | |
| # them. C used to carry an `info=` string which Gradio v6 | |
| # renders ABOVE the field (Dropdown default) — that pushed | |
| # C's dropdown ~30px below A and B. Moving the description | |
| # below as a markdown caption restores the alignment. | |
| with gr.Column(scale=1): | |
| model_a_dd = gr.Dropdown(TEXT_MODELS, value=DEFAULT_MODEL_A, | |
| label="A · measured") | |
| with gr.Column(scale=1): | |
| model_b_dd = gr.Dropdown(TEXT_MODELS, value=DEFAULT_MODEL_B, | |
| label="B · measured") | |
| with gr.Column(scale=1): | |
| model_c_dd = gr.Dropdown(AUDIO_MODEL_CHOICES, value=DEFAULT_MODEL_C, | |
| label="C · audio-only") | |
| gr.Markdown( | |
| "_Audio-input multimodal model — listens to the raw audio with no measurements supplied. gemini is the default._", | |
| elem_classes=["dc-compare-c-caption"], | |
| ) | |
| # Status headers — one Markdown per column for the model-name + | |
| # status badge. Briefs stay in three columns below because they're | |
| # short paragraphs that read fine side-by-side. | |
| with gr.Row(equal_height=False): | |
| a_header = gr.Markdown(_compare_header("A · measured", DEFAULT_MODEL_A, "_click Run_")) | |
| b_header = gr.Markdown(_compare_header("B · measured", DEFAULT_MODEL_B, "_click Run_")) | |
| c_header = gr.Markdown(_compare_header("C · audio-only", DEFAULT_MODEL_C, "_click Run_")) | |
| with gr.Row(equal_height=False): | |
| a_brief = gr.Markdown("_(pending)_") | |
| b_brief = gr.Markdown("_(pending)_") | |
| c_brief = gr.Markdown("_(pending)_") | |
| # The mix-chain section now lives in ONE wide markdown table — | |
| # rows = sections, columns = models. Forces vertical alignment | |
| # even when one model's section is 5 items and another's is 2. | |
| gr.Markdown("### Mix chain comparison") | |
| comparison_table = gr.Markdown("_click **Run side-by-side** to populate._") | |
| # Export the comparison as two separate artifacts: | |
| # - PNG scorecard for messaging / DM (short, screenshot-sized) | |
| # - Markdown full report for email / Notion / archive | |
| # Hidden until compare_state.done flips true — matches Analyse, | |
| # where Share/Validate stay hidden until last_run lands. Empty | |
| # file boxes + disabled-feel buttons read as broken otherwise. | |
| with gr.Group(visible=False) as compare_share_group: | |
| gr.Markdown("### Share") | |
| with gr.Row(): | |
| scorecard_btn = gr.Button("📸 Download scorecard (PNG)", variant="secondary") | |
| report_btn = gr.Button("📄 Download full report (MD)", variant="secondary") | |
| with gr.Row(): | |
| scorecard_file = gr.File(label="Scorecard image", interactive=False) | |
| report_file = gr.File(label="Full report", interactive=False) | |
| # Hidden slots — kept so the output tuple's shape matches between | |
| # the empty-state and running-state yields. | |
| a_timing = gr.Markdown(visible=False) | |
| b_timing = gr.Markdown(visible=False) | |
| c_timing = gr.Markdown(visible=False) | |
| compare_state = gr.State(None) | |
| with gr.Tab("TRANSFORM · beta") as morpho_tab: | |
| gr.Markdown( | |
| "**Power-user territory — both engines need something you may" | |
| " not have yet.** Transform a crate tile via audio-to-audio:" | |
| " pick an engine, pick a source, configure, hit Transform. The" | |
| " result lands as a new tile in the crate with the source tile" | |
| " as its parent.\n\n" | |
| "_**Morpho** · 30 hosted neural transforms · needs an API key" | |
| " from the Neutone team · per-input-second billing · 1000h" | |
| " budget per model._\n\n" | |
| "_**Local SA3 (audio-to-audio)** · needs the local bridge" | |
| " running on your machine (Apple Silicon; see the GEN tab's" | |
| " Local server card) · $0/call · needs `LOCAL_GEN_BACKEND=mlx-sa3`._" | |
| ) | |
| # Engine selector — top-level toggle that swaps which control | |
| # group is visible. Each engine has its own readiness UI | |
| # (Morpho's connect band vs SA3's local-bridge probe pill) | |
| # per Codex's separation spec — Morpho path stays unchanged | |
| # when SA3 is added. Visibility wiring happens via the | |
| # transform_engine.change handler at the bottom of build_ui. | |
| transform_engine = gr.Radio( | |
| choices=[ | |
| ("Neutone Morpho · cloud", "morpho"), | |
| ("Local SA3 · audio-to-audio", "sa3-local"), | |
| ], | |
| value="morpho", | |
| show_label=False, | |
| container=False, | |
| elem_classes=["dc-transform-engine"], | |
| ) | |
| # Per-session Neutone key. Same pattern as api_key_state for | |
| # Pollinations: lives in gr.State, never persists to disk on | |
| # HF Spaces (multi-user filesystem). Threaded through every | |
| # Morpho-calling handler. On desktop, the demo.load below | |
| # auto-populates from ~/.neutone_key so users with a local | |
| # key don't have to re-enter it. | |
| neutone_key_state = gr.State("") | |
| # ── Connect band (visible until a session-scoped key lands) ── | |
| with gr.Group() as morpho_connect_group: | |
| gr.Markdown( | |
| "**🔐 Connect Neutone** — paste your Morpho API key to" | |
| " enable the 30 transform models. Key is held in this" | |
| " browser session only; nothing is written to disk." | |
| " Request a key from the Neutone team if you don't have one." | |
| ) | |
| with gr.Row(): | |
| morpho_key_input = gr.Textbox( | |
| type="password", | |
| show_label=False, | |
| placeholder="paste Neutone API key…", | |
| scale=4, | |
| container=False, | |
| ) | |
| morpho_connect_btn = gr.Button( | |
| "Connect", variant="primary", scale=1, | |
| ) | |
| morpho_connect_status = gr.Markdown("") | |
| # ── Main panel (revealed after connect; hidden in empty state) ── | |
| with gr.Group(visible=False) as morpho_main_group: | |
| morpho_disconnect_btn = gr.Button( | |
| "✓ Connected · disconnect", size="sm", | |
| elem_classes=["dc-disconnect-btn"], | |
| ) | |
| with gr.Row(equal_height=True): | |
| with gr.Column(scale=2): | |
| morpho_tile_picker = gr.Dropdown( | |
| choices=[], | |
| label="Source tile", | |
| info="Pick a tile from your crate to transform. Use GEN to add tiles first.", | |
| interactive=True, | |
| ) | |
| morpho_anchor_audio = gr.Audio( | |
| label="Source preview", | |
| type="filepath", | |
| interactive=False, | |
| ) | |
| with gr.Column(scale=2): | |
| morpho_model = gr.Dropdown( | |
| choices=[], | |
| label="Morpho model", | |
| info="30 neural transforms · grouped by category.", | |
| interactive=True, | |
| ) | |
| morpho_model_desc = gr.Markdown( | |
| "_Pick a model to see its description and quota._", | |
| ) | |
| morpho_preset = gr.Radio( | |
| choices=[], | |
| label="Preset", | |
| info="Each model ships 4 named presets · click to load values into the macros.", | |
| interactive=True, | |
| container=False, | |
| ) | |
| with gr.Row(equal_height=True): | |
| with gr.Column(scale=1): | |
| morpho_p1 = gr.Slider(0, 100, value=50.0, step=1.0, | |
| label="p1", interactive=True) | |
| with gr.Column(scale=1): | |
| morpho_p2 = gr.Slider(0, 100, value=50.0, step=1.0, | |
| label="p2", interactive=True) | |
| with gr.Column(scale=1): | |
| morpho_p3 = gr.Slider(0, 100, value=50.0, step=1.0, | |
| label="p3", interactive=True) | |
| with gr.Column(scale=1): | |
| morpho_p4 = gr.Slider(0, 100, value=50.0, step=1.0, | |
| label="p4", interactive=True) | |
| morpho_transform_btn = gr.Button( | |
| "Transform ▸", variant="primary", size="lg", | |
| interactive=False, | |
| ) | |
| morpho_status = gr.Markdown("") | |
| morpho_output_audio = gr.Audio( | |
| label="Transformed output", type="filepath", | |
| interactive=False, | |
| ) | |
| # ============================================================ | |
| # SA3 LOCAL ENGINE GROUP — audio-to-audio via the local bridge. | |
| # Hidden by default; revealed when transform_engine == "sa3-local". | |
| # The whole control surface is independent from Morpho — | |
| # separate source picker, separate readiness pill, separate | |
| # transform button — so engine state never crosses streams. | |
| # ============================================================ | |
| with gr.Group(visible=False) as sa3_engine_group: | |
| # Local-bridge readiness — hits the same localhost URL the | |
| # GEN tab probes, but checks `transform_supported: true` in | |
| # the / payload (only mlx-sa3 backend supports /transform). | |
| # Re-detect button + probe JS lower down. | |
| gr.HTML( | |
| '<div id="dc-sa3-transform-status" class="dc-local-status not-detected">' | |
| '<span class="dot">●</span>' | |
| '<span class="msg">Checking for local SA3 bridge with /transform support…</span>' | |
| '<button id="dc-sa3-transform-redetect" type="button">Re-detect</button>' | |
| '</div>' | |
| ) | |
| with gr.Row(equal_height=True): | |
| with gr.Column(scale=2): | |
| sa3_tile_picker = gr.Dropdown( | |
| choices=[], | |
| label="Source tile", | |
| info="Pick a tile from your crate to transform. GEN to add tiles first.", | |
| interactive=True, | |
| ) | |
| # gr.Audio gives us the pretty wavesurfer waveform. | |
| # The JS prelude doesn't need to query its DOM — | |
| # we hand the Transform prelude the source URL via | |
| # a hidden Textbox instead (sa3_source_url below). | |
| # Cleaner than fishing a <audio src=…> out of | |
| # wavesurfer markup. | |
| sa3_source_audio = gr.Audio( | |
| label="Source preview", | |
| type="filepath", | |
| interactive=False, | |
| elem_id="sa3-source-audio", | |
| ) | |
| # Hidden state — Python writes the source tile's | |
| # Gradio file-proxy path here (`/gradio_api/file=…`). | |
| # The Transform JS prelude reads this value, builds | |
| # an absolute URL via `new URL(path, location.origin)`, | |
| # and POSTs `audio_url` to the local bridge so the | |
| # bridge can fetch the source itself instead of | |
| # the browser shipping raw bytes through localhost. | |
| sa3_source_url = gr.Textbox(visible=False, value="") | |
| with gr.Column(scale=2): | |
| sa3_prompt = gr.Textbox( | |
| label="Transform prompt", | |
| placeholder="describe what to morph the source toward — e.g. 'glitchy IDM percussion, broken beats'", | |
| lines=4, | |
| ) | |
| sa3_init_strength = gr.Slider( | |
| minimum=0.10, maximum=1.00, value=0.70, step=0.02, | |
| label="Init strength", | |
| info="0.4-0.6 subtle drift · 0.7 sweet spot · 0.9-1.0 heavy redraw (init ignored at 1.0).", | |
| ) | |
| sa3_duration = gr.Radio( | |
| choices=[("5s", 5), ("10s", 10), | |
| ("15s", 15), ("30s", 30)], | |
| value=10, | |
| label="Output duration", | |
| info="Source is trimmed or zero-padded to match.", | |
| ) | |
| with gr.Accordion("Advanced · SA3 sampling", open=False): | |
| sa3_steps = gr.Slider( | |
| minimum=1, maximum=16, value=8, step=1, | |
| label="Sampling steps", | |
| info="8 is the sweet spot. 1-4 fast/rough; 8+ diminishing returns.", | |
| ) | |
| sa3_cfg = gr.Slider( | |
| minimum=1.0, maximum=5.0, value=1.0, step=0.1, | |
| label="CFG (>1.0 enables negative prompt)", | |
| ) | |
| sa3_negative = gr.Textbox( | |
| label="Negative prompt", | |
| info="Only applied when CFG > 1.0. Push away from these descriptors.", | |
| lines=2, | |
| ) | |
| # Hidden slots — the Transform button's JS prelude writes | |
| # the bridge response b64 here (or an error string), then | |
| # the Python handler decodes + writes to the crate. | |
| sa3_transform_b64 = gr.Textbox(visible=False, value="") | |
| sa3_transform_error = gr.Textbox(visible=False, value="") | |
| sa3_transform_btn = gr.Button( | |
| "Transform ▸", variant="primary", size="lg", | |
| ) | |
| sa3_transform_status = gr.Markdown("") | |
| sa3_transform_output = gr.Audio( | |
| label="Transformed output", | |
| type="filepath", | |
| interactive=False, | |
| ) | |
| # Probe script — mounts on tab visibility. Re-uses the same | |
| # localhost URL that GEN's local-server panel resolves | |
| # (#dc-local-url textbox in the GEN tab DOM). | |
| gr.HTML( | |
| '<script>' | |
| '(function(){' | |
| 'function getUrl(){' | |
| 'var box=document.getElementById("dc-local-url");' | |
| 'var ta=box?box.querySelector("input,textarea"):null;' | |
| 'return ta?(ta.value||"http://localhost:7864").trim().replace(/\\/$/,"")' | |
| ':"http://localhost:7864";' | |
| '}' | |
| 'function setStatus(state,msg){' | |
| 'var s=document.getElementById("dc-sa3-transform-status");' | |
| 'if(!s)return;' | |
| 's.className="dc-local-status "+state;' | |
| 'var m=s.querySelector(".msg");if(m)m.textContent=msg;' | |
| '}' | |
| # Expose the probe globally so the engine-change | |
| # handler can fire it without relying on the | |
| # MutationObserver (which doesn't always catch | |
| # Gradio's visibility flips). | |
| 'window.__sa3TransformProbe=function(){probe();};' | |
| 'async function probe(){' | |
| 'var url=getUrl();' | |
| 'setStatus("not-detected","Probing "+url+"/…");' | |
| 'try{' | |
| 'var ac=new AbortController();' | |
| 'var t=setTimeout(function(){ac.abort();},2500);' | |
| 'var r=await fetch(url+"/",{method:"GET",signal:ac.signal});' | |
| 'clearTimeout(t);' | |
| 'if(!r.ok){setStatus("not-detected","Bridge unreachable at "+url);return;}' | |
| 'var j=await r.json();' | |
| 'if(j && j.transform_supported===true){' | |
| 'setStatus("detected","✓ SA3 transform ready · "+url+" · $0/call");' | |
| '}else{' | |
| 'setStatus("not-detected","Bridge found but /transform not supported. Restart the bridge with LOCAL_GEN_BACKEND=mlx-sa3 to enable it.");' | |
| '}' | |
| '}catch(e){setStatus("not-detected","No local bridge at "+url+" — install via the GEN tab\\u2019s Local server card.");}' | |
| '}' | |
| 'function bind(){' | |
| 'var btn=document.getElementById("dc-sa3-transform-redetect");' | |
| 'if(!btn)return false;' | |
| 'if(btn.dataset.bound)return true;' | |
| 'btn.dataset.bound="1";' | |
| 'btn.addEventListener("click",function(e){e.preventDefault();probe();});' | |
| 'return true;' | |
| '}' | |
| 'var t=0;var iv=setInterval(function(){' | |
| 't++;if(bind()||t>60)clearInterval(iv);' | |
| '},200);' | |
| 'function watchVisible(){' | |
| 'var g=document.querySelector("[id^=sa3_engine_group]");' | |
| 'if(!g)return false;' | |
| 'var o=new MutationObserver(function(){' | |
| 'var hidden=g.classList.contains("hidden")||g.style.display==="none";' | |
| 'if(!hidden)probe();' | |
| '});' | |
| 'o.observe(g,{attributes:true,attributeFilter:["class","style"]});' | |
| 'if(!g.classList.contains("hidden"))probe();' | |
| 'return true;' | |
| '}' | |
| 'var v=0;var vIv=setInterval(function(){' | |
| 'v++;if(watchVisible()||v>60)clearInterval(vIv);' | |
| '},200);' | |
| '})();' | |
| '</script>', | |
| elem_classes=["dc-script-only"], | |
| ) | |
| with gr.Tab("Raw", visible=False): | |
| raw_json = gr.Code(label="full analysis JSON", language="json", lines=24) | |
| bpm_mode.change( | |
| fn=lambda m: (gr.update(visible=m.startswith("Auto")), | |
| gr.update(visible=m.startswith("Manual"))), | |
| inputs=[bpm_mode], | |
| outputs=[genre_grp, manual_grp], | |
| ) | |
| # Snap picks of disabled "(soon)" models back to SA3. SA3 and | |
| # local-server are the two real options; everything else 500s or | |
| # is text-only (elevenmusic). | |
| _ALLOWED_GEN_MODELS = {sa3.DEFAULT_MODEL, "local-server"} | |
| def _enforce_allowed_gen(v): | |
| return v if v in _ALLOWED_GEN_MODELS else sa3.DEFAULT_MODEL | |
| gen_model.change(fn=_enforce_allowed_gen, inputs=[gen_model], outputs=[gen_model]) | |
| # Show the local-server settings group only when the local-server | |
| # model is selected. Keeps Generate uncluttered for SA3-only users. | |
| gen_model.change( | |
| fn=lambda v: gr.update(visible=(v == "local-server")), | |
| inputs=[gen_model], | |
| outputs=[local_gen_group], | |
| ) | |
| # YouTube fetch → populates the upload slot; user then hits | |
| # Analyze as normal. Also fires on Enter in the URL box. | |
| for _yt_evt in (yt_fetch_btn.click, yt_url.submit): | |
| _yt_evt( | |
| fn=lambda: "_fetching audio from YouTube…_", | |
| outputs=[yt_status], | |
| ).then( | |
| fn=fetch_youtube_audio, | |
| inputs=[yt_url], | |
| outputs=[audio_in, yt_status], | |
| ) | |
| run_btn.click( | |
| fn=run_brief, | |
| inputs=[audio_in, bpm_mode, genre, bpm_num, model_dd, api_key_state], | |
| outputs=[ | |
| paragraph, caption, anchor_player, wave, section_seek, | |
| metrics_html, | |
| data_tbl, sections_tbl, tags_tbl, | |
| errors_box, timings_box, | |
| sa3_var, sa3_match, clip_plan, mix_chain, | |
| raw_json, last_run, | |
| ], | |
| api_name="analyze", | |
| ).then( | |
| # Reset the lens chip to LOOSE on every new analysis so the | |
| # selected chip matches the textbox content (run_brief defaults | |
| # to LOOSE in sa3_var_final). Without this, a user who clicks | |
| # MATCH on track A then analyses track B sees a LOOSE prompt | |
| # while the chip still reads MATCH — confusing. | |
| fn=lambda: gr.update(value="loose"), | |
| inputs=[], | |
| outputs=[sa3_lens_choice], | |
| ) | |
| compare_btn.click( | |
| fn=run_compare, | |
| inputs=[last_run, model_a_dd, model_b_dd, model_c_dd, api_key_state], | |
| outputs=[ | |
| a_header, a_brief, | |
| b_header, b_brief, | |
| c_header, c_brief, | |
| comparison_table, | |
| a_timing, b_timing, c_timing, | |
| compare_state, | |
| ], | |
| api_name="compare", | |
| ) | |
| # Reveal the Share section (scorecard + report buttons + file | |
| # slots) once compare_state.done flips true. Matches the Analyse | |
| # pattern where Share/Validate are hidden in empty state. The | |
| # export handlers already guard against unfinished state — this | |
| # is the visual layer of the same gate. | |
| compare_state.change( | |
| fn=lambda s: gr.update(visible=bool(s and s.get("done"))), | |
| inputs=[compare_state], | |
| outputs=[compare_share_group], | |
| ) | |
| # Enable Validate-vs-Gemini + Share buttons only after analysis | |
| # lands. Both surfaces are useless without a populated last_run. | |
| # share_options_group (variants checkbox + bundle file slot) does | |
| # NOT reveal here any more — Codex feedback: it was showing up | |
| # immediately after analysis even when the user wasn't about to | |
| # share, reading as clutter / pre-commitment. Now it appears only | |
| # on Share button click (see share_btn.click below) so the user | |
| # commits to sharing before seeing the include-variants toggle. | |
| last_run.change( | |
| fn=lambda lr: ( | |
| gr.update(interactive=isinstance(lr, dict)), | |
| gr.update(interactive=isinstance(lr, dict)), | |
| ), | |
| inputs=[last_run], | |
| outputs=[validate_btn, share_btn], | |
| ) | |
| # Share button → write the current analysis state to a .abv1 | |
| # bundle (anchor mp3 + analysis JSON, optionally + variants) and | |
| # surface a download. Recipient drops the file into Import below. | |
| # When include-variants is on, we read the current var1..5 | |
| # gr.Audio values and pass them to export_bundle. | |
| def _do_export(state, include_variants, v1, v2, v3, v4, v5): | |
| variant_paths = None | |
| if include_variants: | |
| # Audio component values arrive as filepath strings (or | |
| # dicts {"path": ...} in some Gradio v6 builds). Coerce. | |
| def _p(v): | |
| if isinstance(v, str): | |
| return v | |
| if isinstance(v, dict): | |
| return v.get("path") or v.get("name") | |
| return None | |
| variant_paths = [_p(v) for v in (v1, v2, v3, v4, v5)] | |
| path = share.export_bundle(state, variant_paths=variant_paths) | |
| # Reveal the share group + downloadable file slot once the | |
| # user has committed to sharing. Subsequent clicks (e.g. after | |
| # toggling include-variants) re-export and re-fill the slot. | |
| group_show = gr.update(visible=True) | |
| if not path: | |
| return group_show, gr.update(visible=False, value=None) | |
| return group_show, gr.update(value=path, visible=True) | |
| share_btn.click( | |
| fn=_do_export, | |
| inputs=[last_run, share_include_variants, | |
| var1, var2, var3, var4, var5], | |
| outputs=[share_options_group, share_file], | |
| ) | |
| # Import .abv1 → populate the ANALYSE view directly from the | |
| # bundle. No 'click Analyze after'; the bundle contains the | |
| # analysis already. Same 17 output slots as run_brief + the | |
| # import-card status + crate refresh — outputs ordered to match | |
| # the run_btn.click outputs above so last_run gets populated and | |
| # Validate / Share light up automatically via last_run.change. | |
| import_file.upload( | |
| fn=load_brief_from_bundle, | |
| inputs=[import_file, session_id], | |
| outputs=[ | |
| paragraph, caption, anchor_player, wave, section_seek, | |
| metrics_html, | |
| data_tbl, sections_tbl, tags_tbl, | |
| errors_box, timings_box, | |
| sa3_var, sa3_match, clip_plan, mix_chain, | |
| raw_json, last_run, | |
| import_status, crate_picker, crate_header, | |
| ], | |
| ) | |
| # Validate button on Analysis: switch to Compare tab (via JS) and | |
| # fire run_compare with the current analysis state. Same outputs | |
| # as compare_btn — populates the Compare tab's columns directly. | |
| validate_btn.click( | |
| fn=run_compare, | |
| inputs=[last_run, model_a_dd, model_b_dd, model_c_dd, api_key_state], | |
| outputs=[ | |
| a_header, a_brief, | |
| b_header, b_brief, | |
| c_header, c_brief, | |
| comparison_table, | |
| a_timing, b_timing, c_timing, | |
| compare_state, | |
| ], | |
| js=""" | |
| () => { | |
| // Find and click the LAB tab button so the user lands | |
| // on the side-by-side as the comparison runs. Matched | |
| // by prefix — tab is labelled "LAB · why grounding?". | |
| const tabs = document.querySelectorAll('[role="tab"]'); | |
| for (const t of tabs) { | |
| const txt = (t.innerText || '').trim().toUpperCase(); | |
| if (txt === 'LAB' || txt.startsWith('LAB')) { | |
| t.click(); | |
| break; | |
| } | |
| } | |
| return []; // no input mutation | |
| } | |
| """, | |
| ) | |
| scorecard_btn.click( | |
| fn=export_scorecard, | |
| inputs=[compare_state], | |
| outputs=[scorecard_file], | |
| api_name="export_scorecard", | |
| ) | |
| report_btn.click( | |
| fn=export_report, | |
| inputs=[compare_state], | |
| outputs=[report_file], | |
| api_name="export_report", | |
| ) | |
| # Connect: pure client-side navigation to enter.pollinations.ai — | |
| # no popup, no orphan tab. Same-tab redirect; Pollinations sends the | |
| # user back to this URL with #api_key=… in the fragment, picked up | |
| # by the demo.load handler below. Pattern matches abv1 (see | |
| # standalone/public/index.html:4946-5047). | |
| # ── Generate-tab wiring ────────────────────────────────────────── | |
| # JS prelude: if the user picked "local-server", fetch their | |
| # localhost gen endpoint from the browser BEFORE calling the | |
| # Python handler, base64-encode the audio bytes, and pass them | |
| # through as the last input. Otherwise pass through unchanged | |
| # and let generate_sa3 take the Pollinations path. | |
| # gen_duration_custom rides along as a trailing input; the Custom | |
| # sentinel (0) is resolved SERVER-SIDE in generate_sa3. The JS | |
| # prelude must pass the radio value through untouched — Gradio v6 | |
| # strict-validates each returned value against the source | |
| # component's choices, so writing resolved seconds into the Radio | |
| # slot 500s with "Value: 4 is not in the list of choices". The | |
| # prelude computes its own resolved copy only for the local-server | |
| # fetch body. | |
| gen_click = gen_btn.click( | |
| fn=generate_sa3, | |
| inputs=[gen_prompt, gen_model, gen_duration, | |
| api_key_state, local_gen_url, local_gen_b64, | |
| local_gen_error, session_id, gen_duration_custom], | |
| outputs=[gen_status, crate_picker, crate_preview, crate_meta, crate_header], | |
| js=""" | |
| async (prompt, model, duration, apiKey, localUrl, _ignored_b64, _ignored_err, sessionId, durationCustom) => { | |
| // Custom chip → sentinel 0. Resolve into a LOCAL copy for | |
| // the fetch body only; the returned duration slot must | |
| // stay the raw radio value (see wiring comment above). | |
| const durEff = (parseInt(duration) === 0) | |
| ? Math.max(1, Math.min(180, parseInt(durationCustom) || 15)) | |
| : (parseInt(duration) || 15); | |
| console.log('[local-gen args]', { | |
| prompt: (prompt||'').slice(0,40), | |
| model, duration, localUrl, | |
| sessionId: (sessionId||'').slice(0,8) | |
| }); | |
| if (model !== 'local-server') { | |
| return [prompt, model, duration, apiKey, localUrl, '', '', sessionId, durationCustom]; | |
| } | |
| // Wrapper-iframe gate — huggingface.co/spaces/* blocks all | |
| // fetches to http://localhost (see hf-spaces-csp-localhost | |
| // memory note). Fail fast with the actual fix instead of a | |
| // TypeError the user can't act on. | |
| let framed = false; | |
| try { framed = (window.top !== window.self); } catch (e) { framed = true; } | |
| if (framed) { | |
| return [prompt, model, duration, apiKey, localUrl, '', | |
| 'Local server can\\u2019t run inside the huggingface.co wrapper — ' + | |
| 'open this Space directly at ' + window.location.origin + '/ and retry.', | |
| sessionId, durationCustom]; | |
| } | |
| const base = (localUrl || 'http://localhost:7864').replace(/\\/$/,''); | |
| // Loopback-only — the local-server path is for a server on | |
| // THIS machine. Reject anything else before we POST to it | |
| // so a stale-localStorage or malicious URL can't exfil | |
| // the prompt or steer the browser at an internal endpoint. | |
| let parsed; | |
| try { | |
| parsed = new URL(base); | |
| const allowed = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); | |
| if (!(parsed.protocol === 'http:' || parsed.protocol === 'https:') || | |
| !allowed.has(parsed.hostname.toLowerCase())) { | |
| console.error('[local-gen] refusing non-loopback URL:', base); | |
| return [prompt, model, duration, apiKey, localUrl, '', | |
| 'URL must be loopback (localhost / 127.0.0.1 / [::1]). Got: ' + base, sessionId, durationCustom]; | |
| } | |
| } catch (e) { | |
| console.error('[local-gen] invalid URL:', base, e && e.message); | |
| return [prompt, model, duration, apiKey, localUrl, '', | |
| 'Invalid URL: ' + base, sessionId, durationCustom]; | |
| } | |
| // NOTE: don't add a "mixed-content" guard here. Chrome | |
| // explicitly EXEMPTS http://localhost from mixed-content | |
| // blocking — an HTTPS Space CAN fetch http://localhost | |
| // and that's the documented path. A previous version of | |
| // this prelude added a guard that incorrectly blocked the | |
| // very fetch Chrome would have allowed. | |
| // | |
| // The "Failed to fetch" you might see with a healthy | |
| // bridge is almost always Private Network Access (PNA): | |
| // Chrome requires the bridge to ACK the preflight with | |
| // Access-Control-Allow-Private-Network: true. See | |
| // docs/local-gen-server-example.py for the OPTIONS | |
| // handler that does this. | |
| const url = base + '/generate'; | |
| console.log('[local-gen] POST', url, 'prompt=', (prompt||'').slice(0,40)); | |
| try { | |
| const r = await fetch(url, { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| prompt: prompt || '', | |
| duration: durEff, | |
| }), | |
| }); | |
| console.log('[local-gen] response', r.status, 'content-type=', r.headers.get('content-type')); | |
| if (!r.ok) { | |
| // Pull the body for context (truncated, in case it's an HTML error page). | |
| let body = ''; | |
| try { body = (await r.text()).slice(0, 200); } catch (_) {} | |
| console.error('[local-gen] HTTP', r.status, body); | |
| return [prompt, model, duration, apiKey, localUrl, '', | |
| 'HTTP ' + r.status + ' from /generate' + (body ? ' · ' + body : ''), | |
| sessionId, durationCustom]; | |
| } | |
| const buf = await r.arrayBuffer(); | |
| console.log('[local-gen] received', buf.byteLength, 'bytes'); | |
| if (!buf.byteLength) { | |
| console.error('[local-gen] empty body'); | |
| return [prompt, model, duration, apiKey, localUrl, '', | |
| 'empty body from /generate', sessionId, durationCustom]; | |
| } | |
| // Chunked binary→string to avoid blowing the stack on big WAVs. | |
| const bytes = new Uint8Array(buf); | |
| const CHUNK = 0x8000; | |
| let bin = ''; | |
| for (let i = 0; i < bytes.length; i += CHUNK) { | |
| bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); | |
| } | |
| const b64 = btoa(bin); | |
| console.log('[local-gen] b64 length', b64.length, '— passing to Python'); | |
| return [prompt, model, duration, apiKey, localUrl, b64, '', sessionId, durationCustom]; | |
| } catch (e) { | |
| // TypeError: Failed to fetch → CORS / mixed-content / PNA. | |
| // SyntaxError / DOMException / etc — surface the real message. | |
| const name = (e && e.name) ? e.name : 'Error'; | |
| const msg = (e && e.message) ? e.message : String(e); | |
| console.error('[local-gen] fetch/encode failed:', name, msg, e); | |
| return [prompt, model, duration, apiKey, localUrl, '', | |
| name + ': ' + msg, sessionId, durationCustom]; | |
| } | |
| } | |
| """, | |
| api_name="sa3_generate", | |
| ) | |
| # Tick the session-spend pill (~0.04 ◆ per SA3 call). Skip when the | |
| # user picked the local-server model — that path never bills Pollinations. | |
| gen_click.then( | |
| fn=lambda curr, model, key: tick_session_spend( | |
| curr, 0.0 if model == "local-server" else SA3_COST_PER_CALL, key, | |
| ), | |
| inputs=[session_spend_state, gen_model, api_key_state], | |
| outputs=[topbar, session_spend_state], | |
| ) | |
| refresh_crate_btn.click( | |
| fn=refresh_crate, | |
| inputs=[session_id], | |
| outputs=[crate_picker, crate_header], | |
| ) | |
| crate_picker.change( | |
| fn=select_tile, | |
| inputs=[crate_picker, session_id], | |
| outputs=[crate_preview, crate_meta, crate_actions_row], | |
| ) | |
| delete_tile_btn.click( | |
| fn=delete_tile, | |
| inputs=[crate_picker, session_id], | |
| outputs=[crate_picker, crate_preview, crate_meta, crate_header, crate_actions_row], | |
| ) | |
| # Bridge to existing Compare flow: writes the tile's audio path | |
| # into the top-level audio_in component, surfaces a status nudge, | |
| # then auto-runs analyze. The Analysis tab is where the brief lands. | |
| use_for_analysis_btn.click( | |
| fn=send_tile_to_analysis, | |
| inputs=[crate_picker, session_id], | |
| # bpm_mode/genre/bpm_num get updated from the tile's source prompt | |
| # before run_brief reads them — otherwise dnb gens analyze at half | |
| # tempo (87 instead of 174 with no prior to anchor librosa). | |
| # current_source_prompt carries the tile's source_prompt into | |
| # run_brief so the LLM can blend user-intent back into the | |
| # derived SA3 prompt. | |
| outputs=[audio_in, gen_status, bpm_mode, genre, bpm_num, | |
| current_source_prompt], | |
| ).then( | |
| fn=run_brief, | |
| inputs=[audio_in, bpm_mode, genre, bpm_num, model_dd, | |
| api_key_state, current_source_prompt], | |
| outputs=[ | |
| paragraph, caption, anchor_player, wave, section_seek, | |
| metrics_html, | |
| data_tbl, sections_tbl, tags_tbl, | |
| errors_box, timings_box, | |
| sa3_var, sa3_match, clip_plan, mix_chain, | |
| raw_json, last_run, | |
| ], | |
| ) | |
| # Enable Regenerate only once `last_run` flips to a populated dict — | |
| # ie. analysis completed and the LLM brief landed. Before that the | |
| # derived prompt is just the structural BPM/key/sections fallback, | |
| # which produces variants that match measurements but lose the | |
| # user's intent (and burn 5×SA3 calls for no real value). | |
| last_run.change( | |
| fn=lambda lr: gr.update(interactive=isinstance(lr, dict)), | |
| inputs=[last_run], | |
| outputs=[regen_btn], | |
| ) | |
| # Lens chip swap — when the user clicks MATCH/LOOSE/FREE, look up | |
| # the pre-computed prompt in last_run.sa3_lenses and load it into | |
| # the textbox. Falls back gracefully when the cache is missing or | |
| # the lens hadn't computed yet. | |
| def _swap_lens(lens, lr): | |
| if not isinstance(lr, dict): | |
| return gr.update() # no analysis yet — leave textbox alone | |
| lenses = lr.get("sa3_lenses") or {} | |
| text = lenses.get(lens, "") | |
| if not text: | |
| return gr.update() | |
| return gr.update(value=text) | |
| sa3_lens_choice.change( | |
| fn=_swap_lens, | |
| inputs=[sa3_lens_choice, last_run], | |
| outputs=[sa3_var], | |
| ) | |
| # Mirror the chosen lens into current_lens_state so the next | |
| # regen run can be labelled MATCH·N / LOOSE·N / FREE·N when | |
| # added to regens_history_state. | |
| sa3_lens_choice.change( | |
| fn=lambda v: v, | |
| inputs=[sa3_lens_choice], | |
| outputs=[current_lens_state], | |
| ) | |
| # Populate the anchor audio slot from last_run.audio_path so the | |
| # user can A/B against the original next to V1–V5 in one row. | |
| # Anchor title + audio flip visibility together. | |
| def _set_anchor(lr): | |
| if not isinstance(lr, dict): | |
| return (gr.update(visible=False), | |
| gr.update(value=None, visible=False)) | |
| path = lr.get("audio_path") or None | |
| if not path: | |
| return (gr.update(visible=False), | |
| gr.update(value=None, visible=False)) | |
| return (gr.update(visible=True), | |
| gr.update(value=path, visible=True)) | |
| last_run.change( | |
| fn=_set_anchor, | |
| inputs=[last_run], | |
| outputs=[anchor_title, anchor_audio], | |
| ) | |
| # Auto-snap variant duration to the anchor's measured length. | |
| # Within 2s of a preset → that preset chip (30.4s clip → Loop · 30s). | |
| # Anything else → the Custom chip with the exact rounded seconds, | |
| # so a 4s ident regenerates at 4s instead of the nearest preset. | |
| # User can still override by clicking a different chip. | |
| def _suggest_duration(lr): | |
| if not isinstance(lr, dict): | |
| return gr.update(), gr.update() | |
| an = lr.get("analysis") | |
| anchor_s = getattr(an, "duration_s", None) if an else None | |
| if not anchor_s or anchor_s <= 0: | |
| return gr.update(), gr.update() | |
| presets = [15, 30, 90, 180] | |
| closest = min(presets, key=lambda p: abs(p - anchor_s)) | |
| if abs(closest - anchor_s) <= 2: | |
| return gr.update(value=closest), gr.update(visible=False) | |
| secs = max(1, min(sa3.ABSOLUTE_MAX_S, int(round(anchor_s)))) | |
| return (gr.update(value=0), | |
| gr.update(value=secs, visible=True)) | |
| last_run.change( | |
| fn=_suggest_duration, | |
| inputs=[last_run], | |
| outputs=[regen_duration, regen_duration_custom], | |
| ) | |
| # Regenerate 5 variants from the (possibly edited) derived prompt. | |
| # Streams output: each audio slot fills in as the corresponding SA3 | |
| # call completes. Variants also get added to the crate with a | |
| # parent_id back to the source tile when one's known. | |
| # regen_duration_custom is a trailing input; the Custom sentinel | |
| # (0) is resolved SERVER-SIDE in regenerate_variants — the JS | |
| # prelude must pass the radio value through raw because Gradio v6 | |
| # strict-validates it against `choices` on preprocess (same rule | |
| # as the gen click above). The prelude resolves its own copy only | |
| # for the local-server fetch bodies. | |
| regen_chain = regen_btn.click( | |
| fn=regenerate_variants, | |
| inputs=[sa3_var, last_run, regen_duration, api_key_state, regen_count, | |
| session_id, gen_model, local_gen_url, | |
| local_regen_b64_json, local_regen_error, | |
| regen_duration_custom], | |
| outputs=[var1, var2, var3, var4, var5, | |
| var1_title, var2_title, var3_title, var4_title, var5_title, | |
| regen_status, variants_prompt_used, | |
| current_matches_state], | |
| api_name="regenerate_variants", | |
| js=""" | |
| async (prompt, last_run, duration, apiKey, n, sessionId, model, localUrl, | |
| _ignored_arr, _ignored_err, durationCustom) => { | |
| // Custom chip → sentinel 0. Resolve a LOCAL copy for the | |
| // fetch bodies; the returned duration slot stays raw. | |
| const durEff = (parseInt(duration) === 0) | |
| ? Math.max(1, Math.min(180, parseInt(durationCustom) || 15)) | |
| : (parseInt(duration) || 15); | |
| console.log('[regen args]', {model, n, duration: durEff, localUrl}); | |
| // Pollinations path — pass through untouched, server handles it. | |
| if (model !== 'local-server') { | |
| return [prompt, last_run, duration, apiKey, n, sessionId, | |
| model, localUrl, '', '', durationCustom]; | |
| } | |
| // Wrapper-iframe gate — same rule as the GEN prelude. | |
| let framed = false; | |
| try { framed = (window.top !== window.self); } catch (e) { framed = true; } | |
| if (framed) { | |
| return [prompt, last_run, duration, apiKey, n, sessionId, | |
| model, localUrl, '', | |
| 'Local server can\\u2019t run inside the huggingface.co wrapper — ' + | |
| 'open this Space directly at ' + window.location.origin + '/ and retry.', durationCustom]; | |
| } | |
| const base = (localUrl || 'http://localhost:7864').replace(/\\/$/, ''); | |
| // Loopback gate — same rule the GEN prelude enforces. | |
| let parsed; | |
| try { | |
| parsed = new URL(base); | |
| const allowed = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); | |
| if (!(parsed.protocol === 'http:' || parsed.protocol === 'https:') || | |
| !allowed.has(parsed.hostname.toLowerCase())) { | |
| return [prompt, last_run, duration, apiKey, n, sessionId, | |
| model, localUrl, '', | |
| 'URL must be loopback. Got: ' + base, durationCustom]; | |
| } | |
| } catch (e) { | |
| return [prompt, last_run, duration, apiKey, n, sessionId, | |
| model, localUrl, '', 'Invalid URL: ' + base, durationCustom]; | |
| } | |
| const url = base + '/generate'; | |
| const N = Math.max(1, Math.min(5, parseInt(n) || 5)); | |
| const D = durEff; | |
| const arr = []; | |
| try { | |
| for (let i = 0; i < N; i++) { | |
| console.log('[local-regen] POST ' + (i+1) + '/' + N + ' ' + url); | |
| const r = await fetch(url, { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({prompt: prompt || '', duration: D}), | |
| }); | |
| if (!r.ok) { | |
| let body = ''; | |
| try { body = (await r.text()).slice(0, 200); } catch(_) {} | |
| return [prompt, last_run, duration, apiKey, n, sessionId, | |
| model, localUrl, '', | |
| 'HTTP ' + r.status + ' on variant ' + (i+1) + | |
| (body ? ' · ' + body : ''), durationCustom]; | |
| } | |
| const buf = await r.arrayBuffer(); | |
| if (!buf.byteLength) { | |
| return [prompt, last_run, duration, apiKey, n, sessionId, | |
| model, localUrl, '', | |
| 'empty body on variant ' + (i+1), durationCustom]; | |
| } | |
| // Chunked binary→string to avoid blowing the stack on big WAVs. | |
| const bytes = new Uint8Array(buf); | |
| const CHUNK = 0x8000; | |
| let bin = ''; | |
| for (let j = 0; j < bytes.length; j += CHUNK) { | |
| bin += String.fromCharCode.apply(null, bytes.subarray(j, j + CHUNK)); | |
| } | |
| arr.push(btoa(bin)); | |
| console.log('[local-regen] variant ' + (i+1) + ' got ' + buf.byteLength + ' bytes'); | |
| } | |
| } catch (e) { | |
| const name = (e && e.name) ? e.name : 'Error'; | |
| const msg = (e && e.message) ? e.message : String(e); | |
| console.error('[local-regen] fetch/encode failed:', name, msg, e); | |
| return [prompt, last_run, duration, apiKey, n, sessionId, | |
| model, localUrl, '', name + ': ' + msg, durationCustom]; | |
| } | |
| console.log('[local-regen] all ' + N + ' done'); | |
| return [prompt, last_run, duration, apiKey, n, sessionId, | |
| model, localUrl, JSON.stringify(arr), '', durationCustom]; | |
| } | |
| """, | |
| ).then( | |
| # After all variants land, refresh the crate dropdown so the | |
| # new tiles appear under it. | |
| fn=refresh_crate, | |
| inputs=[session_id], | |
| outputs=[crate_picker, crate_header], | |
| ) | |
| # n_variants × SA3 flat cost. Honest about cost — sequential gen. | |
| # Local-server path is $0/call, so skip the tick when that's the | |
| # selected model (Codex spec — local should mean local all the way). | |
| regen_chain.then( | |
| fn=lambda curr, key, n, m: tick_session_spend( | |
| curr, | |
| 0.0 if m == "local-server" else SA3_COST_PER_CALL * max(1, min(5, int(n or 5))), | |
| key, | |
| ), | |
| inputs=[session_spend_state, api_key_state, regen_count, gen_model], | |
| outputs=[topbar, session_spend_state], | |
| ) | |
| # Variant-count chip → live update button label + cost pip so the | |
| # user sees the cost commitment BEFORE clicking. No backend round | |
| # trip — just two HTML/label refreshes. Also responds to gen_model | |
| # so switching to local-server flips the pip to $0. | |
| def _refresh_regen_cost(n: int, model: str = ""): | |
| n = max(1, min(5, int(n or 5))) | |
| label = "Regenerate · 1 variant ▸" if n == 1 else f"Regenerate · {n} variants ▸" | |
| if (model or "") == "local-server": | |
| suffix = ("1 variant · local · $0" if n == 1 | |
| else f"{n} variants · local · $0") | |
| cost = 0.0 | |
| else: | |
| suffix = "1 variant · flat" if n == 1 else f"{n} variants · flat" | |
| cost = SA3_COST_PER_CALL * n | |
| return ( | |
| gr.update(value=label), | |
| gr.update(value=cost_pip_html(cost, suffix=suffix)), | |
| ) | |
| regen_count.change( | |
| fn=_refresh_regen_cost, | |
| inputs=[regen_count, gen_model], | |
| outputs=[regen_btn, regen_cost_pip], | |
| ) | |
| gen_model.change( | |
| fn=_refresh_regen_cost, | |
| inputs=[regen_count, gen_model], | |
| outputs=[regen_btn, regen_cost_pip], | |
| ) | |
| # After streaming completes, snapshot the batch into regens_history. | |
| # The tab strip auto-selects the new entry; replay handler below | |
| # restores the variant cards if the user clicks an older tab. | |
| regen_chain.then( | |
| fn=commit_regen_to_history, | |
| inputs=[regens_history_state, current_lens_state, sa3_var, | |
| var1, var2, var3, var4, var5, current_matches_state], | |
| outputs=[regens_history_state, regen_tabs, variants_prompt_used], | |
| ) | |
| # Tab click → replay that batch into the variants row + prompt card. | |
| regen_tabs.change( | |
| fn=replay_regen_from_history, | |
| inputs=[regens_history_state, regen_tabs], | |
| outputs=[var1, var2, var3, var4, var5, | |
| var1_title, var2_title, var3_title, var4_title, var5_title, | |
| variants_prompt_used], | |
| ) | |
| # No connect_btn anymore — the pollen pill itself has the inline | |
| # window.location redirect to enter.pollinations.ai (see theme.py | |
| # pollen_pill_html). disconnect_btn is hidden, JS-triggered. | |
| disconnect_btn.click( | |
| fn=disconnect_wallet, | |
| inputs=[], | |
| outputs=[topbar, api_key_state, session_spend_state], | |
| api_name="disconnect_wallet", | |
| ) | |
| # On page load, check the URL fragment for #api_key=… that the | |
| # Pollinations redirect leaves us. The js callback strips the | |
| # fragment from the URL bar and returns the key as the input arg. | |
| # On HF Spaces, the key flows into api_key_state (per-session, in | |
| # memory only). On desktop it ALSO gets written to disk via wallet. | |
| demo.load( | |
| fn=save_key_from_fragment, | |
| inputs=[_fragment_key], | |
| outputs=[topbar, api_key_state, session_spend_state], | |
| js=""" | |
| () => { | |
| // Side-effect: install the postMessage listener that catches | |
| // an OAuth-popup's key handoff and drops it into the hidden | |
| // #wallet-bridge-key textbox. Idempotent across reloads via | |
| // the ready flag. Folded into THIS demo.load (not a separate | |
| // fn=lambda: None one) because Gradio v6 rejects load | |
| // handlers with an empty outputs list — Object.keys throws | |
| // on the response shape. | |
| if (!window.__abv1_walletBridgeReady) { | |
| window.__abv1_walletBridgeReady = true; | |
| window.addEventListener('message', function(ev) { | |
| try { | |
| if (ev.origin !== window.location.origin) return; | |
| const d = ev.data || {}; | |
| if (d.kind !== 'abv1-wallet' || !d.api_key) return; | |
| if (!/^sk_/.test(d.api_key)) return; | |
| function deliver() { | |
| const box = document.getElementById('wallet-bridge-key'); | |
| const ta = box ? box.querySelector('input, textarea') : null; | |
| if (!ta) return false; | |
| ta.value = d.api_key; | |
| ta.dispatchEvent(new Event('input', {bubbles: true})); | |
| ta.dispatchEvent(new Event('change', {bubbles: true})); | |
| return true; | |
| } | |
| if (deliver()) return; | |
| let t = 0; | |
| const iv = setInterval(function() { | |
| t++; | |
| if (deliver() || t > 60) clearInterval(iv); | |
| }, 200); | |
| } catch (e) { console.error('[wallet-bridge]', e); } | |
| }, false); | |
| console.log('[wallet-bridge] listener installed'); | |
| } | |
| // Fragment capture for the OAuth landing case (this very tab | |
| // came back from Pollinations with #api_key=…). | |
| const hash = window.location.hash || ''; | |
| const m = hash.match(/api_key=([^&]+)/); | |
| if (!m) return ''; | |
| // Popup case: post the key back to the opener tab (which | |
| // holds the user's actual work) and self-close. The opener's | |
| // listener (installed above) drops it into #wallet-bridge-key | |
| // → .change → save_key_from_fragment. No reload, state intact. | |
| try { | |
| if (window.opener && !window.opener.closed) { | |
| window.opener.postMessage( | |
| {kind: 'abv1-wallet', api_key: m[1]}, | |
| window.location.origin | |
| ); | |
| history.replaceState(null, '', window.location.pathname + window.location.search); | |
| try { window.close(); } catch (_) {} | |
| } | |
| } catch (_) {} | |
| history.replaceState(null, '', window.location.pathname + window.location.search); | |
| return m[1]; | |
| } | |
| """, | |
| ) | |
| # Popup-bridge handler: when the message listener installed below | |
| # drops a key into _wallet_bridge_key, this fires the same | |
| # save_key_from_fragment path the URL-fragment route uses. | |
| _wallet_bridge_key.change( | |
| fn=save_key_from_fragment, | |
| inputs=[_wallet_bridge_key], | |
| outputs=[topbar, api_key_state, session_spend_state], | |
| ) | |
| # NOTE: the wallet-bridge message listener used to live in its own | |
| # demo.load(fn=lambda: None, outputs=[], js=…) here. Gradio v6 | |
| # rejects that shape — the submit pipeline runs Object.keys on | |
| # the JS response and throws "Cannot convert undefined or null | |
| # to object" on every subsequent click event in the page. Listener | |
| # is now folded into the existing fragment-capture demo.load above | |
| # so we don't add a second load handler with an empty outputs list. | |
| # Refresh crate_picker + header at every page load. Without this, | |
| # the server-side Radio component's `choices` snapshot is whatever | |
| # existed when the module was imported. On HF the module imports | |
| # once per worker — if /tmp was empty at that moment, the Radio's | |
| # validator stays empty for the worker's whole lifetime and any | |
| # tile id sent from the client gets rejected as "not in choices". | |
| # Refreshing on demo.load resyncs the server validator with the | |
| # actual on-disk crate state. | |
| # Mint a session_id on every page load. Random opaque token — | |
| # used as the subdir under /tmp/abv1-crate/ so each visitor's | |
| # crate is isolated. Reloads regenerate it (and orphan the prior | |
| # session's files in /tmp); fine for a PoC, /tmp gets swept on | |
| # reboot. localStorage persistence is a follow-up if we want | |
| # crates to survive a reload. | |
| demo.load( | |
| fn=lambda: secrets.token_hex(8), | |
| inputs=[], | |
| outputs=[session_id], | |
| ) | |
| # refresh_crate now takes session_id so a fresh visitor sees only | |
| # their own (empty) crate, not whatever the worker had on disk. | |
| demo.load( | |
| fn=refresh_crate, | |
| inputs=[session_id], | |
| outputs=[crate_picker, crate_header], | |
| ) | |
| # Same refresh for the Morpho tile picker — it pulls from the | |
| # same per-session crate so a visitor sees only their own tiles. | |
| # Fires on initial mount AND every time the user opens the | |
| # TRANSFORM tab (so tiles generated mid-session also appear) | |
| # AND every time the main crate strip refreshes (mirror it). | |
| def _refresh_morpho_picker(sid): | |
| return gr.update(choices=crate.tile_choices(session_id=sid)) | |
| demo.load( | |
| fn=_refresh_morpho_picker, | |
| inputs=[session_id], | |
| outputs=[morpho_tile_picker], | |
| ) | |
| morpho_tab.select( | |
| fn=_refresh_morpho_picker, | |
| inputs=[session_id], | |
| outputs=[morpho_tile_picker], | |
| ) | |
| # Mirror: whenever the main crate Radio's choices update (which | |
| # happens after every gen / regen / delete via refresh_crate), | |
| # copy those choices onto the Morpho dropdown. Same source data, | |
| # different widget shape — fine to share. | |
| crate_picker.change( | |
| fn=lambda sid: gr.update(choices=crate.tile_choices(session_id=sid)), | |
| inputs=[session_id], | |
| outputs=[morpho_tile_picker], | |
| ) | |
| # ── Transform tab — connect / disconnect flow ───────────────────── | |
| # Connect a key (or auto-load on desktop). Validates by calling | |
| # /models/info — a successful 200 means the key works AND we have | |
| # the model list to populate the dropdown in one round trip. | |
| def _morpho_connect(typed_key): | |
| key = (typed_key or "").strip() | |
| if not key: | |
| return ( | |
| "", gr.update(), # state, model dropdown | |
| gr.update(visible=True), # connect group | |
| gr.update(visible=False), # main group | |
| "_❌ Enter a key first._", # connect status | |
| gr.update(value=""), # clear textbox | |
| ) | |
| try: | |
| ms = morpho.models(session_key=key, force_refresh=True) | |
| except morpho.MorphoError as e: | |
| return ( | |
| "", gr.update(), | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| f"_❌ {e}_", | |
| gr.update(), # keep what the user typed so they can edit | |
| ) | |
| choices = [ | |
| (f"{(m.get('model_category') or '?').upper():<10} · {m['model_name']}", | |
| m["model_name"]) | |
| for m in sorted(ms, key=lambda m: (m.get("model_category") or "zzz", | |
| m.get("model_name") or "")) | |
| ] | |
| first = choices[0][1] if choices else None | |
| return ( | |
| key, # state | |
| gr.update(choices=choices, value=first), # model dropdown | |
| gr.update(visible=False), # connect group hides | |
| gr.update(visible=True), # main group reveals | |
| "", # connect status clears | |
| gr.update(value=""), # clear the textbox | |
| ) | |
| morpho_connect_btn.click( | |
| fn=_morpho_connect, | |
| inputs=[morpho_key_input], | |
| outputs=[neutone_key_state, morpho_model, | |
| morpho_connect_group, morpho_main_group, | |
| morpho_connect_status, morpho_key_input], | |
| ) | |
| # Enter inside the password textbox triggers the same connect flow. | |
| morpho_key_input.submit( | |
| fn=_morpho_connect, | |
| inputs=[morpho_key_input], | |
| outputs=[neutone_key_state, morpho_model, | |
| morpho_connect_group, morpho_main_group, | |
| morpho_connect_status, morpho_key_input], | |
| ) | |
| # Disconnect — clears the session-scoped key + collapses panel back. | |
| def _morpho_disconnect(): | |
| return ( | |
| "", # state | |
| gr.update(choices=[], value=None), # dropdown | |
| gr.update(visible=True), # connect group back | |
| gr.update(visible=False), # main hides | |
| "_Disconnected._", # connect status | |
| gr.update(value=""), # clear textbox | |
| ) | |
| morpho_disconnect_btn.click( | |
| fn=_morpho_disconnect, | |
| inputs=[], | |
| outputs=[neutone_key_state, morpho_model, | |
| morpho_connect_group, morpho_main_group, | |
| morpho_connect_status, morpho_key_input], | |
| ) | |
| # Auto-connect on desktop (only) — if a key exists at ~/.neutone_key | |
| # or in the NEUTONE_API_KEY env var, skip the manual entry. On HF | |
| # Spaces this returns None (IS_HF_SPACE check in morpho.get_key) | |
| # so the entry UI stays visible until the user pastes their key. | |
| def _morpho_auto_connect(): | |
| key = morpho.get_key() # respects IS_HF_SPACE rule | |
| if not key: | |
| return ( | |
| "", gr.update(), | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| "", | |
| ) | |
| try: | |
| ms = morpho.models(session_key=key, force_refresh=True) | |
| except morpho.MorphoError: | |
| return ( | |
| "", gr.update(), | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| "_⚠️ Local key found but rejected by Neutone. Enter a fresh key._", | |
| ) | |
| choices = [ | |
| (f"{(m.get('model_category') or '?').upper():<10} · {m['model_name']}", | |
| m["model_name"]) | |
| for m in sorted(ms, key=lambda m: (m.get("model_category") or "zzz", | |
| m.get("model_name") or "")) | |
| ] | |
| first = choices[0][1] if choices else None | |
| return ( | |
| key, | |
| gr.update(choices=choices, value=first), | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| "", | |
| ) | |
| demo.load( | |
| fn=_morpho_auto_connect, | |
| inputs=[], | |
| outputs=[neutone_key_state, morpho_model, | |
| morpho_connect_group, morpho_main_group, | |
| morpho_connect_status], | |
| ) | |
| # ── Transform tab — main controls ──────────────────────────────── | |
| # Tile picker → load source audio preview + enable Transform button | |
| # only when a tile is selected. | |
| def _morpho_pick_tile(tile_id, sid): | |
| if not tile_id: | |
| return (gr.update(value=None), gr.update(interactive=False)) | |
| t = crate.get_tile(tile_id, session_id=sid) | |
| if not t: | |
| return (gr.update(value=None), gr.update(interactive=False)) | |
| return (gr.update(value=t.audio_path), gr.update(interactive=True)) | |
| morpho_tile_picker.change( | |
| fn=_morpho_pick_tile, | |
| inputs=[morpho_tile_picker, session_id], | |
| outputs=[morpho_anchor_audio, morpho_transform_btn], | |
| ) | |
| # Model dropdown → update slider labels, populate preset chips, | |
| # refresh model-description blurb + live quota line. All driven | |
| # by /models/info (cached per session_key in morpho.py). | |
| def _morpho_model_changed(model_name, key): | |
| if not model_name: | |
| return (gr.update(), gr.update(), gr.update(), gr.update(), | |
| gr.update(choices=[], value=None), | |
| gr.update(value="")) | |
| try: | |
| labels = morpho.param_labels(model_name, session_key=key) | |
| presets = morpho.preset_choices(model_name, session_key=key) | |
| m = morpho.model_by_name(model_name, session_key=key) or {} | |
| except morpho.MorphoError as e: | |
| err = f"_❌ Neutone: {e}_" | |
| return (gr.update(), gr.update(), gr.update(), gr.update(), | |
| gr.update(choices=[], value=None), | |
| gr.update(value=err)) | |
| def _slider(p): | |
| lab = labels[p]["name"] | |
| desc = labels[p]["description"] | |
| return gr.update(label=f"{p} · {lab}", info=desc, | |
| value=labels[p]["default"]) | |
| preset_names = [p["name"] for p in presets] | |
| used = m.get("inference_seconds_used") | |
| limit = m.get("inference_seconds_limit") | |
| quota_line = "" | |
| if used is not None and limit: | |
| pct = 100.0 * float(used) / float(limit) | |
| quota_line = (f" · quota {float(used):.1f}s / " | |
| f"{float(limit)/3600:.0f}h ({pct:.2f}%)") | |
| short = m.get("model_description", "") or "" | |
| tags = ", ".join(m.get("tags") or []) | |
| desc_md = (f"**{model_name}** · _{tags}_{quota_line}\n\n{short}" | |
| if short else f"**{model_name}** · _{tags}_{quota_line}") | |
| return ( | |
| _slider("p1"), _slider("p2"), _slider("p3"), _slider("p4"), | |
| gr.update(choices=preset_names, value=None), | |
| gr.update(value=desc_md), | |
| ) | |
| morpho_model.change( | |
| fn=_morpho_model_changed, | |
| inputs=[morpho_model, neutone_key_state], | |
| outputs=[morpho_p1, morpho_p2, morpho_p3, morpho_p4, | |
| morpho_preset, morpho_model_desc], | |
| ) | |
| # Preset chip → load that preset's macro values into the sliders. | |
| def _morpho_apply_preset(preset_name, model_name, key): | |
| if not preset_name or not model_name: | |
| return gr.update(), gr.update(), gr.update(), gr.update() | |
| presets = morpho.preset_choices(model_name, session_key=key) | |
| hit = next((p for p in presets if p.get("name") == preset_name), None) | |
| if not hit: | |
| return gr.update(), gr.update(), gr.update(), gr.update() | |
| return (gr.update(value=float(hit.get("p1", 50.0))), | |
| gr.update(value=float(hit.get("p2", 50.0))), | |
| gr.update(value=float(hit.get("p3", 50.0))), | |
| gr.update(value=float(hit.get("p4", 50.0)))) | |
| morpho_preset.change( | |
| fn=_morpho_apply_preset, | |
| inputs=[morpho_preset, morpho_model, neutone_key_state], | |
| outputs=[morpho_p1, morpho_p2, morpho_p3, morpho_p4], | |
| ) | |
| # The actual Transform call. Reads source tile audio, posts to | |
| # /process_audio with the session-scoped key, lands result as a | |
| # child tile in the crate with parent_id of the source tile. | |
| def _morpho_run(tile_id, model_name, p1, p2, p3, p4, sid, key): | |
| if not key: | |
| yield ("_❌ Neutone key missing — disconnect / reconnect to refresh._", | |
| gr.update(value=None), | |
| gr.update(choices=crate.tile_choices(session_id=sid)), | |
| _crate_header_html(session_id=sid)) | |
| return | |
| if not tile_id or not model_name: | |
| yield ("_❌ Pick a source tile and a Morpho model._", | |
| gr.update(value=None), | |
| gr.update(choices=crate.tile_choices(session_id=sid)), | |
| _crate_header_html(session_id=sid)) | |
| return | |
| src = crate.get_tile(tile_id, session_id=sid) | |
| if not src: | |
| yield ("_❌ Source tile not found._", | |
| gr.update(value=None), | |
| gr.update(choices=crate.tile_choices(session_id=sid)), | |
| _crate_header_html(session_id=sid)) | |
| return | |
| yield (f"_transforming via **{model_name}** … " | |
| "(warm ~3-5× realtime; cold ~7-10× the first call)_", | |
| gr.update(value=None), | |
| gr.update(choices=crate.tile_choices(session_id=sid)), | |
| _crate_header_html(session_id=sid)) | |
| sess_dir = crate.crate_dir(sid) | |
| tmp_out = sess_dir / f"morpho-{crate.new_id()}.wav" | |
| try: | |
| res = morpho.transform( | |
| src.audio_path, | |
| model_name=model_name, | |
| p1=p1, p2=p2, p3=p3, p4=p4, | |
| out_path=tmp_out, | |
| session_key=key, | |
| ) | |
| except morpho.MorphoError as e: | |
| yield (f"_❌ Morpho transform failed: {e}_", | |
| gr.update(value=None), | |
| gr.update(choices=crate.tile_choices(session_id=sid)), | |
| _crate_header_html(session_id=sid)) | |
| return | |
| tile = crate.add_tile( | |
| audio_path=str(tmp_out), | |
| source_prompt=f"morpho({model_name}, p1={p1:.0f}, p2={p2:.0f}, " | |
| f"p3={p3:.0f}, p4={p4:.0f})", | |
| parent_id=src.id, | |
| model=f"neutone-morpho:{model_name}", | |
| duration_s=res.input_seconds or src.duration_s, | |
| session_id=sid, | |
| ) | |
| final_path = sess_dir / f"{tile.id}.wav" | |
| try: | |
| Path(tmp_out).rename(final_path) | |
| tile.audio_path = str(final_path) | |
| tile.save() | |
| except Exception: | |
| pass | |
| quota = "" | |
| if res.seconds_used is not None and res.seconds_limit: | |
| quota = (f" · quota {res.seconds_used:.1f}s / " | |
| f"{res.seconds_limit/3600:.0f}h") | |
| yield ( | |
| f"✅ `[{tile.id}]` **{tile.label}** · {res.bytes/1024:.0f} KB · " | |
| f"wall {res.wall_s:.1f}s{quota}", | |
| gr.update(value=tile.audio_path), | |
| gr.update(choices=crate.tile_choices(session_id=sid), | |
| value=tile.id), | |
| _crate_header_html(session_id=sid), | |
| ) | |
| morpho_transform_btn.click( | |
| fn=_morpho_run, | |
| inputs=[morpho_tile_picker, morpho_model, | |
| morpho_p1, morpho_p2, morpho_p3, morpho_p4, | |
| session_id, neutone_key_state], | |
| outputs=[morpho_status, morpho_output_audio, | |
| crate_picker, crate_header], | |
| api_name="morpho_transform", | |
| ) | |
| # ============================================================ | |
| # SA3 LOCAL ENGINE — handlers for the audio-to-audio path. | |
| # Independent from Morpho above; nothing here touches neutone_key. | |
| # ============================================================ | |
| def _refresh_sa3_picker(sid): | |
| return gr.update(choices=crate.tile_choices(session_id=sid)) | |
| demo.load( | |
| fn=_refresh_sa3_picker, | |
| inputs=[session_id], | |
| outputs=[sa3_tile_picker], | |
| ) | |
| morpho_tab.select( | |
| fn=_refresh_sa3_picker, | |
| inputs=[session_id], | |
| outputs=[sa3_tile_picker], | |
| ) | |
| crate_picker.change( | |
| fn=_refresh_sa3_picker, | |
| inputs=[session_id], | |
| outputs=[sa3_tile_picker], | |
| ) | |
| # Tile selected → update both the visual gr.Audio (wavesurfer | |
| # waveform for the user) AND the hidden sa3_source_url Textbox | |
| # (relative `/gradio_api/file=…` path the Transform JS prelude | |
| # turns into an absolute URL and forwards to the local bridge). | |
| def _sa3_pick_tile(tile_id, sid): | |
| if not tile_id: | |
| return (gr.update(value=None), gr.update(value="")) | |
| t = crate.get_tile(tile_id, session_id=sid) | |
| if not t or not t.audio_path: | |
| return (gr.update(value=None), gr.update(value="")) | |
| # Relative path; JS will resolve against window.location.origin | |
| # so the absolute URL ends up at the .hf.space subdomain the | |
| # browser is already on. | |
| url_path = "/gradio_api/file=" + urllib.parse.quote( | |
| t.audio_path, safe="") | |
| return (gr.update(value=t.audio_path), | |
| gr.update(value=url_path)) | |
| sa3_tile_picker.change( | |
| fn=_sa3_pick_tile, | |
| inputs=[sa3_tile_picker, session_id], | |
| outputs=[sa3_source_audio, sa3_source_url], | |
| ) | |
| # Dummy gr.State that the engine-change .then(js=…) probe | |
| # trigger writes to — Gradio's submit pipeline requires a real | |
| # output to match the JS return value against. Same pattern as | |
| # _notepad_ack at line ~4191. | |
| _sa3_probe_ack = gr.State(None) | |
| # Engine radio → toggle which group is visible. Morpho's connect | |
| # band vs main panel state is reconstructed from neutone_key_state | |
| # so a round-trip through SA3 restores whatever Morpho was showing. | |
| def _toggle_transform_engine(engine, neutone_key): | |
| if engine == "sa3-local": | |
| return ( | |
| gr.update(visible=False), # morpho_connect_group | |
| gr.update(visible=False), # morpho_main_group | |
| gr.update(visible=True), # sa3_engine_group | |
| ) | |
| # "morpho" — restore connect-or-main from key presence. | |
| connected = bool(neutone_key) | |
| return ( | |
| gr.update(visible=not connected), | |
| gr.update(visible=connected), | |
| gr.update(visible=False), | |
| ) | |
| transform_engine.change( | |
| fn=_toggle_transform_engine, | |
| inputs=[transform_engine, neutone_key_state], | |
| outputs=[morpho_connect_group, morpho_main_group, sa3_engine_group], | |
| ).then( | |
| # Fire the SA3 bridge probe whenever the engine flips, so the | |
| # readiness pill turns green/red without the user having to | |
| # click Re-detect. window.__sa3TransformProbe is installed by | |
| # the inline <script> in the SA3 group. Same _notepad_ack | |
| # trick as the wallet bridge fix in 2fcdf15: give Gradio's | |
| # submit pipeline a real output to match the JS return | |
| # against, else Object.keys() throws. | |
| fn=lambda: None, | |
| inputs=[], | |
| outputs=[_sa3_probe_ack], | |
| js=""" | |
| () => { | |
| try { | |
| if (typeof window.__sa3TransformProbe === 'function') { | |
| window.__sa3TransformProbe(); | |
| } | |
| } catch (e) { console.error('[sa3-probe]', e); } | |
| return null; | |
| } | |
| """, | |
| ) | |
| # SA3 transform button — JS prelude fetches the source audio bytes | |
| # from the in-page <audio> element (Gradio's file proxy URL, same- | |
| # origin to the Space) and POSTs them to the local bridge as | |
| # base64. The response WAV is also b64'd and written into a hidden | |
| # Textbox; this Python handler decodes and writes to the crate. | |
| # Per Codex constraint #5: server filepaths NEVER cross the boundary | |
| # to localhost — the browser is the bytes courier. | |
| def _sa3_transform_handler(prompt, init_strength, duration, steps, | |
| cfg, neg_prompt, local_url, session_id_v, | |
| transform_b64, transform_error, tile_id): | |
| import base64 as _b64 | |
| sess_dir = crate.crate_dir(session_id_v) | |
| _audio_hide = gr.update(value=None, visible=False) | |
| crate_choices = crate.tile_choices(session_id=session_id_v) | |
| _picker_refresh = gr.update(choices=crate_choices) | |
| _header = _crate_header_html(session_id=session_id_v) | |
| if transform_error: | |
| return (f"_❌ Local SA3 transform failed: {transform_error}_", | |
| _audio_hide, _picker_refresh, _picker_refresh, _header) | |
| if not transform_b64: | |
| return ("_❌ Local SA3 transform returned no audio. Bridge running with `LOCAL_GEN_BACKEND=mlx-sa3`?_", | |
| _audio_hide, _picker_refresh, _picker_refresh, _header) | |
| try: | |
| raw = _b64.b64decode(transform_b64) | |
| except Exception as e: | |
| return (f"_❌ Decode failed: {e}_", | |
| _audio_hide, _picker_refresh, _picker_refresh, _header) | |
| parent_id = tile_id if tile_id else None | |
| short_prompt = (prompt or "").strip().replace("\n", " ")[:64] | |
| out_path = sess_dir / f"sa3-transform-{crate.new_id()}.wav" | |
| out_path.write_bytes(raw) | |
| tile = crate.add_tile( | |
| audio_path=str(out_path), | |
| source_prompt=f"[transform σ={float(init_strength):.2f}] {short_prompt}", | |
| parent_id=parent_id, | |
| model="local-sa3-transform", | |
| duration_s=float(duration), | |
| session_id=session_id_v, | |
| ) | |
| new_audio = sess_dir / f"{tile.id}.wav" | |
| try: | |
| out_path.rename(new_audio) | |
| tile.audio_path = str(new_audio) | |
| tile.save() | |
| except Exception: | |
| pass | |
| status = (f"✅ Local SA3 transform `[{tile.id}]` **{tile.label}** " | |
| f"({len(raw)/1024:.0f} KB) · init_strength {float(init_strength):.2f} · $0/call") | |
| return ( | |
| status, | |
| gr.update(value=tile.audio_path, visible=True), | |
| gr.update(choices=crate.tile_choices(session_id=session_id_v), | |
| value=tile.id), | |
| gr.update(choices=crate.tile_choices(session_id=session_id_v)), | |
| _crate_header_html(session_id=session_id_v), | |
| ) | |
| sa3_transform_btn.click( | |
| fn=_sa3_transform_handler, | |
| inputs=[sa3_prompt, sa3_init_strength, sa3_duration, | |
| sa3_steps, sa3_cfg, sa3_negative, | |
| local_gen_url, session_id, | |
| sa3_transform_b64, sa3_transform_error, | |
| sa3_tile_picker, sa3_source_url], | |
| outputs=[sa3_transform_status, sa3_transform_output, | |
| crate_picker, sa3_tile_picker, crate_header], | |
| api_name="sa3_local_transform", | |
| js=""" | |
| async (prompt, initStrength, duration, steps, cfg, negPrompt, | |
| localUrl, sessionId, _ignoreB64, _ignoreErr, | |
| tileId, sourcePath) => { | |
| // URL-pass pattern (replaces the earlier bytes-courier flow). | |
| // Python wrote the source tile's relative /gradio_api/file=… | |
| // path into sourcePath via _sa3_pick_tile. We resolve it | |
| // against this page's origin (.hf.space subdomain) so the | |
| // bridge — which has internet but obviously can't reach | |
| // localhost-of-the-browser — can fetch the source itself | |
| // and skip a base64 round-trip across two boundaries. | |
| if (!sourcePath) { | |
| return [prompt, initStrength, duration, steps, cfg, negPrompt, | |
| localUrl, sessionId, '', | |
| 'No source tile selected — pick one from the dropdown first.', | |
| tileId]; | |
| } | |
| // Wrapper-iframe gate — same rule as the GEN local-server | |
| // prelude: the hf.co wrapper blocks fetches to localhost. | |
| let framed = false; | |
| try { framed = (window.top !== window.self); } catch (e) { framed = true; } | |
| if (framed) { | |
| return [prompt, initStrength, duration, steps, cfg, negPrompt, | |
| localUrl, sessionId, '', | |
| 'Local SA3 can\\u2019t run inside the huggingface.co wrapper — ' + | |
| 'open this Space directly at ' + window.location.origin + '/ and retry.', | |
| tileId]; | |
| } | |
| let audioUrl; | |
| try { | |
| audioUrl = new URL(sourcePath, window.location.origin).href; | |
| } catch (e) { | |
| return [prompt, initStrength, duration, steps, cfg, negPrompt, | |
| localUrl, sessionId, '', | |
| 'Bad source URL: ' + (e.message || e), tileId]; | |
| } | |
| // Loopback gate on the BRIDGE URL — same rule everywhere. | |
| const base = (localUrl || 'http://localhost:7864').replace(/\\/$/, ''); | |
| try { | |
| const p = new URL(base); | |
| const allowed = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); | |
| if (!allowed.has(p.hostname.toLowerCase())) { | |
| return [prompt, initStrength, duration, steps, cfg, negPrompt, | |
| localUrl, sessionId, '', | |
| 'Bridge URL must be loopback. Got: ' + base, tileId]; | |
| } | |
| } catch (e) { | |
| return [prompt, initStrength, duration, steps, cfg, negPrompt, | |
| localUrl, sessionId, '', | |
| 'Invalid bridge URL: ' + base, tileId]; | |
| } | |
| const url = base + '/transform'; | |
| console.log('[sa3-transform] POST', url, | |
| 'audio_url=' + audioUrl + ' prompt=' + (prompt||'').slice(0,40)); | |
| let outBuf; | |
| try { | |
| const r = await fetch(url, { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| prompt: prompt || '', | |
| audio_url: audioUrl, | |
| duration: parseInt(duration) || 10, | |
| init_strength: parseFloat(initStrength) || 0.7, | |
| steps: parseInt(steps) || 8, | |
| cfg: parseFloat(cfg) || 1.0, | |
| negative_prompt: negPrompt || '', | |
| }), | |
| }); | |
| if (!r.ok) { | |
| let body = ''; | |
| try { body = (await r.text()).slice(0, 200); } catch (_) {} | |
| return [prompt, initStrength, duration, steps, cfg, negPrompt, | |
| localUrl, sessionId, '', | |
| 'HTTP ' + r.status + ' from /transform' + (body ? ' · ' + body : ''), | |
| tileId]; | |
| } | |
| outBuf = await r.arrayBuffer(); | |
| } catch (e) { | |
| const name = (e && e.name) ? e.name : 'Error'; | |
| const msg = (e && e.message) ? e.message : String(e); | |
| console.error('[sa3-transform] fetch failed:', name, msg, e); | |
| return [prompt, initStrength, duration, steps, cfg, negPrompt, | |
| localUrl, sessionId, '', name + ': ' + msg, tileId]; | |
| } | |
| if (!outBuf.byteLength) { | |
| return [prompt, initStrength, duration, steps, cfg, negPrompt, | |
| localUrl, sessionId, '', | |
| 'empty body from /transform', tileId]; | |
| } | |
| // Chunked binary → base64 for the OUTPUT only (bridge → browser). | |
| const CHUNK = 0x8000; | |
| const outBytes = new Uint8Array(outBuf); | |
| let outBin = ''; | |
| for (let i = 0; i < outBytes.length; i += CHUNK) { | |
| outBin += String.fromCharCode.apply(null, outBytes.subarray(i, i + CHUNK)); | |
| } | |
| const outB64 = btoa(outBin); | |
| console.log('[sa3-transform] got', outBuf.byteLength, 'bytes'); | |
| return [prompt, initStrength, duration, steps, cfg, negPrompt, | |
| localUrl, sessionId, outB64, '', tileId]; | |
| } | |
| """, | |
| ) | |
| # Notepad bootstrap — mounts the notes button inside the GEN prompt | |
| # textbox + wires the slide-in drawer with localStorage-backed save | |
| # / use / delete. Inline <script> tags rendered via gr.HTML(...) do | |
| # NOT execute under Gradio v6 on HF (verified by Codex on live | |
| # deploy) — demo.load(js=) is the supported execution path. CSS for | |
| # the button + drawer still lives in the gr.HTML <style> block | |
| # above; only the behaviour code lives here. | |
| # | |
| # Codex P1 (2026-06-29): bound to a hidden gr.State as a real | |
| # output — fn=lambda: None with outputs=[] is the same shape that | |
| # crashed the Generate handler with Object.keys() of undefined. | |
| # Same workaround as the wallet bridge folded-into-fragment-load | |
| # fix (commit 2fcdf15): give Gradio a real output to match the | |
| # JS return value against, and the submit pipeline stays happy. | |
| _notepad_ack = gr.State(None) | |
| demo.load( | |
| fn=lambda: None, | |
| inputs=[], | |
| outputs=[_notepad_ack], | |
| js=NOTEPAD_BOOTSTRAP_JS, | |
| ) | |
| # ── Headless API surface ───────────────────────────────────────── | |
| # Hidden components + a hidden button carry the api_name route so | |
| # gradio_client (and plain HTTP POST to /gradio_api/call/…) can | |
| # reach audio_to_prompt_api. Visibility is UI-only — the endpoint | |
| # is registered server-side regardless. This is the same pattern | |
| # the app's other api_name routes use, and it plays nicely with | |
| # gradio_client.handle_file for the upload. | |
| with gr.Group(visible=False): | |
| _api_audio_in = gr.File( | |
| type="filepath", | |
| file_types=[ | |
| "audio", | |
| ".mp3", ".wav", ".flac", ".ogg", ".aiff", ".aif", | |
| ".m4a", ".aac", ".opus", ".wma", | |
| ".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", | |
| ], | |
| label="api audio in", | |
| ) | |
| _api_token_in = gr.Textbox(value="", label="api token") | |
| _api_fast_in = gr.Checkbox(value=False, label="fast (skip demucs)") | |
| _api_bpm_in = gr.Textbox(value="default", label="bpm prior") | |
| _api_llm_in = gr.Checkbox(value=False, label="llm (Pollinations prompt)") | |
| _api_lens_in = gr.Textbox(value="loose", label="lens (match/loose/free)") | |
| # v-steer — the caller's current gen prompt; non-empty switches | |
| # the endpoint to anchored steering (appended LAST so existing | |
| # positional callers keep working unchanged). | |
| _api_origp_in = gr.Textbox(value="", label="original prompt (anchored steering)") | |
| # CLAP similarity vector — appended LAST so existing positional | |
| # callers keep working unchanged. | |
| _api_embed_in = gr.Checkbox(value=False, label="embedding (CLAP vector)") | |
| _api_json_out = gr.JSON(label="api result") | |
| _api_btn = gr.Button("audio_to_prompt") | |
| _api_btn.click( | |
| fn=audio_to_prompt_api, | |
| inputs=[_api_audio_in, _api_token_in, _api_fast_in, _api_bpm_in, | |
| _api_llm_in, _api_lens_in, _api_origp_in, _api_embed_in], | |
| outputs=[_api_json_out], | |
| api_name="audio_to_prompt", | |
| show_progress="hidden", | |
| ) | |
| return demo | |
| NOTEPAD_BOOTSTRAP_JS = r""" | |
| () => { | |
| var KEY = "abv1.savedPrompts"; | |
| var MAX = 50; | |
| var NOTE_SVG = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="2" width="10" height="12" rx="1.5"/><path d="M5.5 5h5M5.5 8h5M5.5 11h3"/></svg>'; | |
| function load() { | |
| try { var s = localStorage.getItem(KEY); return s ? JSON.parse(s) : []; } | |
| catch (e) { return []; } | |
| } | |
| function save(arr) { | |
| try { localStorage.setItem(KEY, JSON.stringify(arr.slice(0, MAX))); } | |
| catch (e) {} | |
| } | |
| function getPromptTextarea() { | |
| var box = document.getElementById("gen-prompt-box"); | |
| return box ? box.querySelector("textarea") : null; | |
| } | |
| function fmtTs(ts) { | |
| var d = new Date(ts); | |
| var m = String(d.getMinutes()).padStart(2, "0"); | |
| var h = String(d.getHours()).padStart(2, "0"); | |
| var mo = String(d.getMonth() + 1).padStart(2, "0"); | |
| var dy = String(d.getDate()).padStart(2, "0"); | |
| return mo + "-" + dy + " " + h + ":" + m; | |
| } | |
| function esc(s) { var d = document.createElement("div"); d.textContent = s; return d.innerHTML; } | |
| function mountDrawer() { | |
| if (document.getElementById("dc-notepad-drawer")) return; | |
| var d = document.createElement("div"); | |
| d.className = "dc-notepad-drawer"; | |
| d.id = "dc-notepad-drawer"; | |
| d.innerHTML = | |
| '<div class="dc-notepad-head">' + | |
| '<span class="ttl">PROMPT NOTEPAD</span>' + | |
| '<button class="close" id="dc-notepad-close" title="Close">×</button>' + | |
| '</div>' + | |
| '<div class="dc-notepad-actions">' + | |
| '<button id="dc-notepad-save">Save current prompt</button>' + | |
| '</div>' + | |
| '<div class="dc-notepad-list" id="dc-notepad-list"></div>'; | |
| document.body.appendChild(d); | |
| } | |
| function injectPromptBtn() { | |
| var box = document.getElementById("gen-prompt-box"); | |
| if (!box) return false; | |
| box.style.setProperty("position", "relative", "important"); | |
| box.style.setProperty("overflow", "visible", "important"); | |
| if (box.querySelector(".dc-notepad-btn")) return true; | |
| var btn = document.createElement("button"); | |
| btn.className = "dc-notepad-btn"; | |
| btn.type = "button"; | |
| btn.title = "Prompt notepad — save / recall"; | |
| btn.innerHTML = NOTE_SVG; | |
| btn.addEventListener("click", function (ev) { | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| openDrawer(); | |
| }); | |
| box.appendChild(btn); | |
| return true; | |
| } | |
| function render() { | |
| var list = document.getElementById("dc-notepad-list"); | |
| if (!list) return; | |
| var arr = load(); | |
| if (!arr.length) { | |
| list.innerHTML = '<div class="dc-notepad-empty">No saved prompts yet. Type one in Generate, then hit Save.</div>'; | |
| return; | |
| } | |
| list.innerHTML = arr.map(function (e, i) { | |
| return '<div class="dc-notepad-row">' + | |
| '<div class="meta">' + fmtTs(e.ts) + '</div>' + | |
| '<div class="text">' + esc(e.text) + '</div>' + | |
| '<div class="row-actions">' + | |
| '<button class="use" data-i="' + i + '" data-act="use">↑ use</button>' + | |
| '<button class="del" data-i="' + i + '" data-act="del">× delete</button>' + | |
| '</div>' + | |
| '</div>'; | |
| }).join(""); | |
| } | |
| function openDrawer() { | |
| mountDrawer(); | |
| var drawer = document.getElementById("dc-notepad-drawer"); | |
| if (drawer) { drawer.classList.add("open"); render(); } | |
| } | |
| function closeDrawer() { | |
| var drawer = document.getElementById("dc-notepad-drawer"); | |
| if (drawer) drawer.classList.remove("open"); | |
| } | |
| // Event delegation off document — survives Gradio re-renders of the | |
| // injected button + the body-mounted drawer. | |
| document.addEventListener("click", function (ev) { | |
| var b = ev.target.closest("button"); | |
| if (!b) return; | |
| if (b.id === "dc-notepad-close") { ev.preventDefault(); closeDrawer(); return; } | |
| if (b.id === "dc-notepad-save") { | |
| ev.preventDefault(); | |
| var ta = getPromptTextarea(); | |
| if (!ta || !ta.value.trim()) { | |
| b.textContent = "Generate prompt is empty"; | |
| setTimeout(function () { b.textContent = "Save current prompt"; }, 1400); | |
| return; | |
| } | |
| var arr = load(); | |
| if (arr.length && arr[0].text === ta.value) { | |
| b.textContent = "Already saved"; | |
| setTimeout(function () { b.textContent = "Save current prompt"; }, 1200); | |
| return; | |
| } | |
| arr.unshift({ ts: Date.now(), text: ta.value }); | |
| save(arr); render(); | |
| b.textContent = "Saved ✓"; | |
| setTimeout(function () { b.textContent = "Save current prompt"; }, 1200); | |
| return; | |
| } | |
| var act = b.dataset && b.dataset.act; | |
| if (!act) return; | |
| var i = parseInt(b.dataset.i, 10); | |
| if (isNaN(i)) return; | |
| var arr = load(); | |
| if (act === "use") { | |
| ev.preventDefault(); | |
| var ta = getPromptTextarea(); | |
| if (ta) { | |
| ta.value = arr[i].text; | |
| ta.dispatchEvent(new Event("input", { bubbles: true })); | |
| ta.dispatchEvent(new Event("change", { bubbles: true })); | |
| try { localStorage.setItem("abv1.genPrompt", arr[i].text); } catch (e) {} | |
| closeDrawer(); | |
| } | |
| } else if (act === "del") { | |
| ev.preventDefault(); | |
| arr.splice(i, 1); | |
| save(arr); render(); | |
| } | |
| }, true); | |
| // Mount the button now + keep watching for Gradio re-renders that | |
| // drop it. Both a short poll (button mounts late in Gradio's first | |
| // render cycle) and a MutationObserver (catches later re-renders). | |
| var tries = 0; | |
| var iv = setInterval(function () { | |
| tries++; | |
| injectPromptBtn(); | |
| if (tries > 30) clearInterval(iv); | |
| }, 200); | |
| try { | |
| var mo = new MutationObserver(function () { injectPromptBtn(); }); | |
| mo.observe(document.body, { childList: true, subtree: true }); | |
| } catch (e) {} | |
| return null; | |
| } | |
| """ | |
| # Allow Gradio to serve files from the crate root. On HF Spaces this MUST be | |
| # called at module load — Spaces ignores main()/demo.launch() and grabs the | |
| # module-level `demo` symbol directly, so any allowed_paths passed to | |
| # .launch() would never be applied. set_static_paths is the global hook. | |
| # The root covers every per-session subdir created at runtime; isolation here | |
| # is by session_id scoping in crate.* (listings only show this visitor's | |
| # tiles). A motivated user who guesses another session's file path could | |
| # still GET it from the static handler — defense layered above the obvious | |
| # leakage (the chip strip). Strong isolation would route file URLs through | |
| # a session-aware handler; not the goal of this pass. | |
| gr.set_static_paths([str(crate.CRATE_DIR)]) | |
| # Build the UI at module load and expose `demo` so HF can pick it up. The | |
| # build is fast (a few hundred ms) and only runs once per worker. On HF | |
| # Spaces, Gradio's auto-launch calls .queue() and .launch() itself. | |
| demo = build_ui() | |
| demo.queue(default_concurrency_limit=4) | |
| def main() -> int: | |
| """Local entrypoint — `python app.py`. On HF Spaces this never runs; | |
| Spaces imports the module and launches `demo` directly.""" | |
| import os | |
| port_env = os.environ.get("GRADIO_SERVER_PORT") or os.environ.get("PORT") | |
| port = int(port_env) if port_env else None | |
| # Theme + CSS already live on the Blocks instance (set in build_ui). | |
| demo.launch(server_port=port) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |