""" Signal Loom AI — HuggingFace Spaces App ======================================= Demo surface for Signal Loom AI. Routes all transcription through the Signal Loom API — no local ML. API: http://localhost:18790 Auth: Bearer token (demo key hardcoded; replace with your own at signalloomai.com) Author: Aster Vale / Signal Loom AI """ import asyncio import httpx import json import os import time from typing import Literal import gradio as gr # ── Configuration ──────────────────────────────────────────────────────────── API_BASE_URL = os.environ.get("SIGNAL_LOOM_API_URL", "http://localhost:18790") # Demo API key — replace with your own key from signalloomai.com API_KEY = os.environ.get("SIGNAL_LOOM_API_KEY", "slo_oEDM0c-d") POLL_INTERVAL_SECONDS = 3 POLL_TIMEOUT_SECONDS = 300 # 5 minutes HEADERS = {"Authorization": f"Bearer {API_KEY}"} # Supported formats SUPPORTED_EXTENSIONS = [ ".mp3", ".wav", ".m4a", ".flac", ".ogg", ".mp4", ".mov", ".mkv", ".avi", ".webm", ] MAX_FILE_SIZE_MB = 500 OUTPUT_FORMAT_OPTIONS = ["txt", "json", "srt", "vtt"] # ── API Client ───────────────────────────────────────────────────────────────── async def _submit_url(url: str, language: str | None = None) -> str: """Submit a URL for transcription. Returns job_id.""" async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: params = {"url": url} if language: params["language"] = language resp = await client.post( f"{API_BASE_URL}/v1/transcribe", headers=HEADERS, data=params, ) if resp.status_code == 429: detail = resp.json().get("detail", "Rate limit reached") raise gr.Error( f"Monthly audio minute limit reached. " f"Upgrade at signalloomai.com/#pricing — {detail}" ) if resp.status_code == 401 or resp.status_code == 403: raise gr.Error("Invalid API key. This demo's key may have been revoked.") if resp.status_code == 400: raise gr.Error(f"Bad request: {resp.json().get('detail', resp.text)}") resp.raise_for_status() return resp.json()["job_id"] async def _submit_file(file_path: str, language: str | None = None) -> str: """Upload a file for transcription. Returns job_id.""" async with httpx.AsyncClient(timeout=120.0) as client: with open(file_path, "rb") as f: files = {"file": (os.path.basename(file_path), f)} data = {} if language: data["language"] = language resp = await client.post( f"{API_BASE_URL}/v1/transcribe", headers=HEADERS, files=files, data=data, ) if resp.status_code == 413: raise gr.Error(f"File too large. Maximum size is {MAX_FILE_SIZE_MB}MB.") if resp.status_code == 429: detail = resp.json().get("detail", "Rate limit reached") raise gr.Error( f"Monthly audio minute limit reached. " f"Upgrade at signalloomai.com/#pricing — {detail}" ) if resp.status_code == 401 or resp.status_code == 403: raise gr.Error("Invalid API key. This demo's key may have been revoked.") if resp.status_code == 400: raise gr.Error(f"Bad request: {resp.json().get('detail', resp.text)}") resp.raise_for_status() return resp.json()["job_id"] async def _poll_job(job_id: str, status_md, timeout: int = POLL_TIMEOUT_SECONDS): """Poll job status, yielding status strings as it progresses.""" start = time.monotonic() interval = POLL_INTERVAL_SECONDS last_status = "unknown" while time.monotonic() - start < timeout: await asyncio.sleep(interval) async with httpx.AsyncClient(timeout=30.0) as client: try: resp = await client.get( f"{API_BASE_URL}/v1/status/{job_id}", headers=HEADERS, ) resp.raise_for_status() except httpx.HTTPStatusError as e: if e.response.status_code == 404: raise gr.Error(f"Job {job_id} not found. It may have expired.") raise gr.Error(f"Status check failed: {e}") data = resp.json() status = data.get("status", "unknown") if status == last_status and status not in ("queued", "processing", "uploaded"): # No meaningful change; keep polling pass last_status = status if status == "queued": yield "status_md", "⏳ **Submitted — Queued for processing...**\n\n_Position unknown. Waiting for a worker slot..._", None elif status == "processing": elapsed = int(time.monotonic() - start) language = data.get("language") or "auto-detected" dur = data.get("duration_seconds") dur_str = f", ~{int(dur)}s audio" if dur else "" yield ( "status_md", f"🔄 **Processing...** (`{elapsed}s elapsed`)\n\n" f"Model: `{data.get('model', 'whisper-large-v3-turbo')}` | " f"Language: `{language}`{dur_str}\n\n" f"_Polling every {interval}s — typically 1–3 min for short files._", None, ) interval = min(interval * 1.2, 8.0) elif status == "completed": # Done — let the result fetch handle the final output return elif status in ("failed", "error"): failure = data.get("failure_code") or data.get("error") or "unknown" raise gr.Error( f"❌ Transcription failed{f' ({failure})' if failure else ''}.\n\n" f"Try a different file/URL, or check signalloomai.com for support." ) elif status in ("cancelled", "expired"): raise gr.Error( f"Job {status}. Results are retained for 7 days after completion. " f"Please submit a new transcription." ) else: # Unknown / created / uploaded — keep waiting yield ( "status_md", f"🔄 **Status: `{status}`** — waiting for processing...\n\n" f"_Still starting up... ({(time.monotonic() - start):.0f}s elapsed)_", None, ) raise gr.Error( f"⏱ Transcription timed out after {timeout}s. " f"The file may be too long or the service is under load. " f"Check job status at signalloomai.com or try again shortly." ) async def _fetch_result(job_id: str) -> dict: """Fetch the final transcript result.""" async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.get( f"{API_BASE_URL}/v1/result/{job_id}", headers=HEADERS, ) if resp.status_code == 202: raise gr.Error(f"Job {job_id} returned unexpected 202 (still processing). Try again shortly.") if resp.status_code == 404: raise gr.Error(f"Result for job {job_id} not found. It may have expired.") if resp.status_code == 410: raise gr.Error(f"Result for job {job_id} has expired. Results are retained for 7 days after completion.") resp.raise_for_status() return resp.json() def _build_outputs(result: dict, requested_formats: list[str]) -> dict: """Build the output dict matching the expected format options.""" outputs = {} transcript = result.get("transcript") or {} raw_text = result.get("raw_text") or "" srt_text = result.get("srt") or "" vtt_text = result.get("vtt") or "" # TXT if "txt" in requested_formats: if raw_text: outputs["txt"] = raw_text.strip() elif transcript: # Reconstruct from segments text = " ".join(seg.get("text", "").strip() for seg in transcript.get("segments", []) if seg.get("text")) outputs["txt"] = text.strip() else: outputs["txt"] = raw_text or "(no transcript available)" # JSON if "json" in requested_formats: outputs["json"] = { "status": "success", "job_id": result.get("job_id"), "language": transcript.get("language", "unknown") if isinstance(transcript, dict) else "unknown", "duration_seconds": transcript.get("duration_seconds") if isinstance(transcript, dict) else None, "segments": [ { "start_seconds": round(seg.get("start", seg.get("start_time", 0)), 2), "end_seconds": round(seg.get("end", seg.get("end_time", 0)), 2), "speaker": seg.get("speaker", "UNKNOWN"), "text": seg.get("text", "").strip(), } for seg in (transcript.get("segments", []) if isinstance(transcript, dict) else []) if seg.get("text", "").strip() ], } # SRT if "srt" in requested_formats: outputs["srt"] = srt_text # VTT if "vtt" in requested_formats: outputs["vtt"] = vtt_text return outputs # ── Pipeline (generator — yields (output_key, value, _) for Gradio streaming) ── async def run_pipeline( audio_file: str | None, url_input: str | None, language: str | None, output_formats: list[str], ) -> tuple: """ Full transcription pipeline via Signal Loom API. Accepts file upload OR URL ingestion. Yields status/outputs for Gradio streaming. Returns tuple: (status_md_text, txt_output, json_output, srt_output, vtt_output) """ requested_formats = output_formats or ["txt", "json"] # ── Step 0: validate inputs ─────────────────────────────────────────────── if url_input and url_input.strip(): source_label = f"URL" elif audio_file: source_label = f"File: {os.path.basename(audio_file)}" # Check file size (best-effort — actual limit enforced by API) try: size_mb = os.path.getsize(audio_file) / (1024 * 1024) if size_mb > MAX_FILE_SIZE_MB: raise gr.Error( f"File too large ({size_mb:.0f}MB). Maximum is {MAX_FILE_SIZE_MB}MB.\n\n" f"For larger files, use signalloomai.com API." ) except OSError: pass else: raise gr.Error("Please paste a URL or upload a file first.") # ── Step 1: submit ──────────────────────────────────────────────────────── yield ( "status_md", f"🚀 **Submitting...** `{source_label}`", None, None, None, None, ) try: if url_input and url_input.strip(): job_id = await _submit_url(url_input.strip(), language) elif audio_file: job_id = await _submit_file(audio_file, language) else: return except gr.Error: raise except httpx.HTTPStatusError as e: try: detail = e.response.json().get("detail", e.response.text) except Exception: detail = str(e) raise gr.Error(f"Submission failed: {detail}") except Exception as e: raise gr.Error(f"Submission failed: {e}") # Immediate acknowledgement state yield ( "status_md", f"✅ **Submitted — Transcription in progress...**\n\n" f"Job ID: `{job_id}`\n\n" f"⏱ Processing typically takes 1–3 minutes depending on length.\n\n" f"_Results are not stored long-term — this is a stateless demo._", None, None, None, None, ) # ── Step 2: poll ─────────────────────────────────────────────────────────── try: async for update in _poll_job(job_id, None): if update is None: continue key, val = update[0], update[1] if key == "status_md": yield val, None, None, None, None, None except gr.Error: raise except Exception as e: raise gr.Error(f"Polling failed: {e}") # ── Step 3: fetch result ────────────────────────────────────────────────── yield ( "status_md", f"📥 **Fetching transcript...**", None, None, None, None, ) try: result = await _fetch_result(job_id) except gr.Error: raise except httpx.HTTPStatusError as e: try: detail = e.response.json().get("detail", e.response.text) except Exception: detail = str(e) raise gr.Error(f"Failed to fetch result: {detail}") except Exception as e: raise gr.Error(f"Failed to fetch result: {e}") # ── Step 4: format and return ─────────────────────────────────────────────── outputs = _build_outputs(result, requested_formats) transcript = result.get("transcript") or {} segments = transcript.get("segments", []) if isinstance(transcript, dict) else [] num_speakers = len(set(s.get("speaker") for s in segments if s.get("speaker") and s.get("speaker") != "UNKNOWN")) or "N/A" lang = transcript.get("language", "unknown") if isinstance(transcript, dict) else "unknown" dur = transcript.get("duration_seconds") if isinstance(transcript, dict) else None summary = ( f"✅ **Done!** `{len(segments)}` segments" f" | Lang: `{lang}`" f" | Speakers: `{num_speakers}`" + (f" | Duration: `{int(dur)}s`" if dur else "") ) cta = ( "\n\n---\n" "🚀 **Want API access?** " "[Get your free key at signalloomai.com](https://signalloomai.com/signup.html) " "— 100 min/month free, no credit card required." ) yield ( "status_md", summary + cta, outputs.get("txt"), outputs.get("json"), outputs.get("srt"), outputs.get("vtt"), ) # ── Gradio Interface ───────────────────────────────────────────────────────── _DARK_CSS = """ /* Dark ambient background */ body { background: #0f1117; } /* Minimal dark chrome */ .gradio-container { background: transparent; } /* Card-like panels */ .panel-box { background: #161b27; border: 1px solid #1e2535; border-radius: 8px; padding: 16px; } /* Status highlight */ .status-ready { color: #34d399; font-weight: 600; } .status-processing { color: #fbbf24; font-weight: 600; } .status-error { color: #f87171; font-weight: 600; } /* Compact markdown in output */ .json-output pre { font-size: 12px; } /* Links */ a { color: #818cf8; text-decoration: none; } a:hover { text-decoration: underline; } """ with gr.Blocks( title="Signal Loom AI", css=_DARK_CSS, ) as demo: gr.Markdown( "## ✦ Signal Loom AI\n" "**Structured media intelligence — transcription, speaker diarization, and machine-readable output.**\n\n" "⚡ **Powered by Signal Loom API** — MLX Whisper on Apple Silicon.\n\n" "| | |\n" "|---|---|\n" "| **Free tier** | 100 min/month |\n" "| **Output** | TXT · JSON · SRT · VTT |\n" "| **Sources** | YouTube, direct upload, 30+ platforms |\n\n" "📌 **This is a demo surface.** For API access and production use → **[signalloomai.com](https://signalloomai.com)**\n\n" "---\n" ) with gr.Row(equal_height=False): # ── LEFT: Inputs ──────────────────────────────────────────────────────── with gr.Column(scale=1, min_width=320): gr.Markdown("### 🔗 Option 1: Paste a URL") gr.Markdown( "_YouTube, direct links, Google Drive, Dropbox, and more._\n\n" "" "Note: Some YouTube videos may be blocked by platform anti-bot protections. " "Direct uploads are the most reliable." "" ) url_input = gr.Textbox( label="Media URL", placeholder="https://www.youtube.com/watch?v=...", lines=2, info="We'll download and transcribe automatically", ) gr.Markdown("### 📁 Option 2: Upload a File") gr.Markdown( f"**Supported:** mp3 · wav · m4a · flac · ogg · mp4 · mov · mkv · avi · webm \n" f"**Max size:** {MAX_FILE_SIZE_MB}MB \n" "" "Results are not stored long-term — stateless demo surface." "" ) audio_input = gr.File( label="Upload Audio or Video", file_types=SUPPORTED_EXTENSIONS, type="filepath", ) with gr.Accordion("⚙️ Options", open=False): language = gr.Dropdown( label="Language (auto-detect if blank)", choices=["", "en", "es", "fr", "de", "it", "pt", "zh", "ja", "ko", "ar", "ru"], value="", ) output_formats = gr.CheckboxGroup( label="Output Formats", choices=OUTPUT_FORMAT_OPTIONS, value=["txt", "json"], ) run_btn = gr.Button("▶ Run Transcription", variant="primary", size="lg") # ── RIGHT: Outputs ────────────────────────────────────────────────────── with gr.Column(scale=2, min_width=400): status_md = gr.Markdown( "**Ready.** Paste a URL or upload a file, then click **Run Transcription**.\n\n" "⏱ Processing typically takes 1–3 minutes depending on length and queue depth.", ) gr.Markdown("#### 📄 Transcript (TXT)") txt_output = gr.Textbox(label=None, lines=8, interactive=False, show_label=False) gr.Markdown("#### 📊 Structured JSON") json_output = gr.JSON(label=None, show_label=False) gr.Markdown("#### 📝 Subtitles (SRT)") srt_output = gr.Textbox(label=None, lines=6, interactive=False, show_label=False) gr.Markdown("#### 🎬 Captions (VTT)") vtt_output = gr.Textbox(label=None, lines=6, interactive=False, show_label=False) # Footer gr.Markdown( "---\n" "**Signal Loom AI** — _Media In. Machine Intelligence Out._ \n" "This HuggingFace Space is a **demo surface**. For full API access, billing, and production use → " "[**signalloomai.com**](https://signalloomai.com) | " "[Get free API key](https://signalloomai.com/signup.html) | " "[HuggingFace Org](https://huggingface.co/signalloom-ai)" ) # ── Event binding ───────────────────────────────────────────────────────── run_btn.click( fn=run_pipeline, inputs=[audio_input, url_input, language, output_formats], outputs=[status_md, txt_output, json_output, srt_output, vtt_output], queue=True, concurrency_limit=None, ) # ── Local launch ───────────────────────────────────────────────────────────── if __name__ == "__main__": import sys port = int(sys.argv[1]) if len(sys.argv) > 1 else 18791 demo.launch(server_port=port, server_name="0.0.0.0")