audio-brief / app.py
kalamishere's picture
fix(embedding): deterministic CLAP on full-length audio (random-truncation bug)
2c07fe1
Raw
History Blame Contribute Delete
265 kB
"""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 &amp; 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 &amp;&amp; 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())