Aster Vale commited on
Commit
dc77e30
·
1 Parent(s): cb848fc

hf-space: rewrite to route through API — removes local ML, fixes P0+P1 UX issues

Browse files
Files changed (3) hide show
  1. app.py +434 -278
  2. requirements.txt +2 -5
  3. space.yaml +23 -0
app.py CHANGED
@@ -2,343 +2,499 @@
2
  Signal Loom AI — HuggingFace Spaces App
3
  =======================================
4
  Demo surface for Signal Loom AI.
5
- Structured media-to-intelligence output with transcription, optional diarization, and machine-readable JSON.
6
- URL ingestion: YouTube (best-effort), TikTok, Instagram, LinkedIn, Google Drive, Dropbox, direct links.
 
 
7
 
8
  Author: Aster Vale / Signal Loom AI
9
  """
10
 
11
- import os
12
- import subprocess
13
- import tempfile
14
  import json
15
- from pathlib import Path
16
-
17
- # ZeroGPU / CPU-only: disable CUDA before importing torch-based modules
18
- os.environ["CUDA_VISIBLE_DEVICES"] = ""
19
 
20
- import whisper
21
 
22
- try:
23
- from pyannote.audio import Pipeline as DiarizationPipeline
24
- PYANNOTE_AVAILABLE = True
25
- except (ImportError, OSError):
26
- PYANNOTE_AVAILABLE = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
- try:
29
- from sentence_transformers import SentenceTransformer
30
- TRANSFORMERS_AVAILABLE = True
31
- except (ImportError, OSError):
32
- TRANSFORMERS_AVAILABLE = False
33
 
34
- import gradio as gr
 
 
 
 
35
 
36
- DEVICE = "cpu"
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
- def get_device_display():
40
- if DEVICE == "cuda":
41
- return "NVIDIA GPU (cloud)"
42
- return "CPU"
 
43
 
44
 
45
- # ── URL Downloader ─────────────────────────────────────────────────────────────
46
- def download_url(url):
47
- """
48
- Download media from URL using yt-dlp (YouTube, TikTok, Instagram, etc.)
49
- or direct httpx for direct file links. Returns (filepath, title_or_error).
50
- """
51
- import shutil
52
- import httpx
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- yt_path = shutil.which("yt-dlp")
55
- if not yt_path:
56
- try:
57
- suffix = Path(url).suffix or ".mp4"
58
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
59
- with httpx.stream("GET", url, timeout=300, follow_redirects=True) as r:
60
- r.raise_for_status()
61
- tmp.write(r.read())
62
- tmp.close()
63
- return tmp.name, Path(url).stem or "media"
64
- except Exception as e:
65
- return None, f"yt-dlp unavailable and direct download failed: {e}"
66
 
67
- try:
68
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".%(ext)s")
69
- tmp_path = tmp.name
70
- tmp.close()
71
- result = subprocess.run(
72
- [
73
- yt_path,
74
- "--no-playlist",
75
- "--extract-audio",
76
- "--audio-format", "mp3",
77
- "--audio-quality", "0",
78
- "--js-runtimes", "node,deno",
79
- "--extractor-retries", "2",
80
- "--fragment-retries", "2",
81
- "--output", tmp_path,
82
- "--", url,
83
- ],
84
- capture_output=True, text=True, timeout=600,
85
- )
86
- if result.returncode != 0:
87
- err = (result.stderr or '').strip()
88
- lowered = err.lower()
89
- if 'sign in to confirm you\'re not a bot' in lowered or 'cookies-from-browser' in lowered or 'cookies' in lowered:
90
- return None, (
91
- 'YouTube blocked anonymous extraction for this video. '
92
- 'Please fall back to direct upload, or use a Google Drive / Dropbox / direct file link instead.'
93
- )
94
- if 'no supported javascript runtime could be found' in lowered:
95
- return None, (
96
- 'This source needs a JavaScript runtime for extraction. '
97
- 'Please try again shortly, or fall back to direct upload / non-YouTube hosted links.'
98
- )
99
- return None, f"Download failed: {err}"
100
- import glob
101
- files = glob.glob(tmp_path.replace(".%(ext)s", "*"))
102
- if files:
103
- return files[0], Path(files[0]).stem
104
- return None, "Download succeeded but no output file found"
105
- except subprocess.TimeoutExpired:
106
- return None, "Download timed out (10 min limit)"
107
- except Exception as e:
108
- return None, f"Download error: {e}"
109
 
 
110
 
111
- # ── Stage 1: Whisper ──────────────────────────────────────────────────────────
112
- def transcribe(audio_path, model_name="base", language=None):
113
- model = whisper.load_model(model_name, device="cpu")
114
- options = {}
115
- if language:
116
- options["language"] = language
117
- options["word_timestamps"] = True
118
- return model.transcribe(audio_path, **options)
119
 
 
120
 
121
- # ── Stage 2: Diarization ─────────────────────────────────────────────────────
122
- def diarize(audio_path, hf_token=None):
123
- if not PYANNOTE_AVAILABLE:
124
- return None, "pyannote.audio not installed"
125
- if not hf_token:
126
- return None, "HF token required for diarization"
127
- try:
128
- import torch
129
- pipeline = DiarizationPipeline.from_pretrained(
130
- "pyannote/speaker-diarization-3.1",
131
- use_auth_token=hf_token,
132
- )
133
- pipeline.to(torch.device("cpu"))
134
- diarization = pipeline(audio_path)
135
- segments = []
136
- for turn, _, speaker in diarization.itertracks(yield_label=True):
137
- segments.append({"start": round(turn.start, 2), "end": round(turn.end, 2), "speaker": speaker})
138
- return segments, None
139
- except Exception as e:
140
- return None, str(e)
141
-
142
-
143
- # ── Output formatters ──────────────────────────────────────────────────────���──
144
- def format_srt(segments):
145
- lines = []
146
- for i, seg in enumerate(segments, 1):
147
- def ts(s):
148
- h, m, sec = int(s // 3600), int((s % 3600) // 60), int(s % 60)
149
- ms = int((s % 1) * 1000)
150
- return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}"
151
- lines.append(f"{i}\n{ts(seg['start'])} --> {ts(seg['end'])}\n{seg['text'].strip()}\n")
152
- return "\n".join(lines)
153
-
154
-
155
- def format_vtt(segments):
156
- lines = ["WEBVTT\n"]
157
- for seg in segments:
158
- def ts(s):
159
- h, m, sec = int(s // 3600), int((s % 3600) // 60), int(s % 60)
160
- ms = int((s % 1) * 1000)
161
- return f"{h:02d}:{m:02d}:{sec:02d}.{ms:03d}"
162
- lines.append(f"{ts(seg['start'])} --> {ts(seg['end'])}\n{seg['text'].strip()}\n")
163
- return "\n".join(lines)
164
-
165
-
166
- # ── Main pipeline ─────────────────────────────────────────────────────────────
167
- def run_pipeline(audio_file, url_input, model_size, language, enable_diarization, hf_token, output_formats):
168
  """
169
- Full transcription pipeline. Accepts file upload OR URL ingestion.
170
- Supported URLs: YouTube, TikTok, Instagram, LinkedIn, Google Drive,
171
- Dropbox, Box, Digify, and direct file links.
172
  """
173
- audio_path = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  try:
175
- # Step 1: resolve source
176
  if url_input and url_input.strip():
177
- url = url_input.strip()
178
- yield "Downloading media from URL...", {}
179
- audio_path, err = download_url(url)
180
- if err:
181
- yield f"Download failed: {err}", {}
182
- return
183
- yield "Download complete. Starting transcription...", {}
184
- elif audio_file is not None:
185
- audio_path = audio_file
186
  else:
187
- yield "Please upload a file or enter a URL first.", {}
188
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
- # Step 2: transcribe
191
- yield "Transcribing (Whisper MLX on Apple Silicon)...", {}
192
- result = transcribe(audio_path, model_name=model_size, language=language or None)
193
- segments = result.get("segments", [])
194
-
195
- # Step 3: diarize
196
- speakers = None
197
- if enable_diarization and PYANNOTE_AVAILABLE:
198
- yield "Detecting speakers (pyannote)...", {}
199
- speakers, err = diarize(audio_path, hf_token or None)
200
- if err:
201
- speakers = None
202
-
203
- # Merge speakers
204
- if speakers:
205
- for seg in segments:
206
- start = seg.get("start", 0)
207
- end = seg.get("end", start + 1)
208
- seg["speaker"] = "UNKNOWN"
209
- for sp in speakers:
210
- if sp["start"] <= start < sp["end"] or sp["start"] < end <= sp["end"]:
211
- seg["speaker"] = sp["speaker"]
212
- break
213
-
214
- # Step 4: format outputs
215
- outputs = {}
216
- if "txt" in output_formats:
217
- outputs["TXT Transcript"] = result.get("text", "")
218
- if "json" in output_formats:
219
- out = {
220
- "status": "success",
221
- "language": result.get("language", "unknown"),
222
- "segments": [
223
- {
224
- "start_seconds": round(s.get("start", 0), 2),
225
- "end_seconds": round(s.get("end", 0), 2),
226
- "speaker": s.get("speaker", "UNKNOWN"),
227
- "text": s.get("text", "").strip(),
228
- }
229
- for s in segments
230
- ],
231
- }
232
- outputs["JSON (Structured)"] = out
233
- if "srt" in output_formats:
234
- outputs["SRT Subtitles"] = format_srt(segments)
235
- if "vtt" in output_formats:
236
- outputs["VTT Captions"] = format_vtt(segments)
237
-
238
- diarization_warning = ""
239
- if enable_diarization and not speakers:
240
- diarization_warning = " | Warning: transcription succeeded, but speaker diarization did not complete"
241
- summary = (
242
- f"Done. {len(segments)} segments | "
243
- f"Language: {result.get('language', 'unknown')} | "
244
- f"Speakers: {len(set(s.get('speaker') for s in segments)) if segments else 'N/A'}"
245
- f"{diarization_warning}"
246
- )
247
- yield summary, outputs
248
 
249
- finally:
250
- if audio_path and audio_path != audio_file:
251
- try:
252
- os.unlink(audio_path)
253
- except Exception:
254
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
256
 
257
  # ── Gradio Interface ─────────────────────────────────────────────────────────
258
- with gr.Blocks(title="Signal Loom AI", dark=True) as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  gr.Markdown(
260
- "## Signal Loom AI\n"
261
- "**Demo surface for structured media intelligence** — transcription, optional diarization, and machine-readable JSON.\n\n"
262
- "Running here on: Hugging Face CPU demo environment\n\n"
263
- "**Signal Loom turns audio/video into structured output for AI workflows.** This HF Space is a demo surface. "
264
- "For API access, production workflow, and the main product experience, use "
265
- "[signalloomai.com](https://signalloomai.com)."
 
 
 
 
266
  )
267
 
268
- with gr.Row():
269
- with gr.Column(scale=1):
270
- gr.Markdown("### Option 1: Paste a URL")
 
271
  gr.Markdown(
272
- "*YouTube (best-effort), TikTok, Instagram, LinkedIn, Google Drive, Dropbox, "
273
- "Box, Digify, and direct file links*"
274
- )
275
- gr.Markdown(
276
- "<span style='font-size: 12px; color: #888;'>"
277
- "Some YouTube videos may require fallback upload due to platform anti-bot protections. "
278
- "Non-YouTube links are the most reliable today."
279
  "</span>"
280
  )
281
  url_input = gr.Textbox(
282
  label="Media URL",
283
  placeholder="https://www.youtube.com/watch?v=...",
284
- info="Paste a link — we'll download and transcribe automatically",
285
  lines=2,
 
286
  )
287
 
288
- gr.Markdown("### Option 2: Upload a File")
289
- gr.Markdown("**Demo note:** No Signal Loom account is required for this HF demo. For API keys and product access, use signalloomai.com.")
 
 
 
 
 
 
290
  audio_input = gr.File(
291
- label="Upload Audio or Video (max 5 GB)",
292
- file_types=[".mp3", ".wav", ".m4a", ".flac", ".ogg", ".mp4", ".mov", ".mkv"],
293
  type="filepath",
294
  )
295
 
296
- with gr.Accordion("Options", open=False):
297
- model_size = gr.Dropdown(
298
- label="Whisper Model",
299
- choices=["tiny", "base", "small", "medium", "large-v3-turbo", "large"],
300
- value="base",
301
- )
302
  language = gr.Dropdown(
303
  label="Language (auto-detect if blank)",
304
- choices=["en", "es", "fr", "de", "it", "pt", "zh", "ja", "ko", "ar", "ru"],
305
- value=None,
306
- )
307
- enable_diarization = gr.Checkbox(
308
- label="Speaker Diarization (pyannote)",
309
- value=False,
310
- )
311
- hf_token = gr.Textbox(
312
- label="HF Token (for diarization)",
313
- type="password",
314
- placeholder="hf_...",
315
- info="Get free token at huggingface.co/settings/tokens",
316
  )
317
  output_formats = gr.CheckboxGroup(
318
  label="Output Formats",
319
- choices=["txt", "json", "srt", "vtt"],
320
- value=["txt", "json", "srt", "vtt"],
321
  )
322
 
323
- run_btn = gr.Button("Run Transcription", variant="primary")
324
 
325
- with gr.Column(scale=2):
326
- status = gr.Markdown("**Ready.** Paste a URL or upload a file, then click Run.")
327
- outputs = gr.JSON(label="Output")
 
 
 
328
 
329
- run_btn.click(
330
- fn=run_pipeline,
331
- inputs=[audio_input, url_input, model_size, language, enable_diarization, hf_token, output_formats],
332
- outputs=[status, outputs],
333
- )
 
 
 
334
 
 
 
 
 
335
  gr.Markdown(
336
  "---\n"
337
- "**Signal Loom AI** — *Media In. Machine Intelligence Out.*\n"
338
- "This HF Space is a demo surface. Want API access and the main product workflow? "
339
- "[Get your free key at signalloomai.com](https://signalloomai.com/signup.html) | "
340
- "[Homepage](https://signalloomai.com) | "
341
  "[HuggingFace Org](https://huggingface.co/signalloom-ai)"
342
  )
343
 
344
- demo.launch(max_file_size=5 * 1024 * 1024 * 1024)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  Signal Loom AI — HuggingFace Spaces App
3
  =======================================
4
  Demo surface for Signal Loom AI.
5
+ Routes all transcription through the Signal Loom API — no local ML.
6
+
7
+ API: http://localhost:18790
8
+ Auth: Bearer token (demo key hardcoded; replace with your own at signalloomai.com)
9
 
10
  Author: Aster Vale / Signal Loom AI
11
  """
12
 
13
+ import asyncio
14
+ import httpx
 
15
  import json
16
+ import os
17
+ import time
18
+ from typing import Literal
 
19
 
20
+ import gradio as gr
21
 
22
+ # ── Configuration ────────────────────────────────────────────────────────────
23
+ API_BASE_URL = os.environ.get("SIGNAL_LOOM_API_URL", "http://localhost:18790")
24
+ # Demo API key — replace with your own key from signalloomai.com
25
+ API_KEY = os.environ.get("SIGNAL_LOOM_API_KEY", "slo_oEDM0c-d")
26
+ POLL_INTERVAL_SECONDS = 3
27
+ POLL_TIMEOUT_SECONDS = 300 # 5 minutes
28
+
29
+ HEADERS = {"Authorization": f"Bearer {API_KEY}"}
30
+
31
+ # Supported formats
32
+ SUPPORTED_EXTENSIONS = [
33
+ ".mp3", ".wav", ".m4a", ".flac", ".ogg",
34
+ ".mp4", ".mov", ".mkv", ".avi", ".webm",
35
+ ]
36
+ MAX_FILE_SIZE_MB = 500
37
+
38
+ OUTPUT_FORMAT_OPTIONS = ["txt", "json", "srt", "vtt"]
39
+
40
+
41
+ # ── API Client ─────────────────────────────────────────────────────────────────
42
+
43
+ async def _submit_url(url: str, language: str | None = None) -> str:
44
+ """Submit a URL for transcription. Returns job_id."""
45
+ async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
46
+ params = {"url": url}
47
+ if language:
48
+ params["language"] = language
49
+ resp = await client.post(
50
+ f"{API_BASE_URL}/v1/transcribe",
51
+ headers=HEADERS,
52
+ data=params,
53
+ )
54
+ if resp.status_code == 429:
55
+ detail = resp.json().get("detail", "Rate limit reached")
56
+ raise gr.Error(
57
+ f"Monthly audio minute limit reached. "
58
+ f"Upgrade at signalloomai.com/#pricing — {detail}"
59
+ )
60
+ if resp.status_code == 401 or resp.status_code == 403:
61
+ raise gr.Error("Invalid API key. This demo's key may have been revoked.")
62
+ if resp.status_code == 400:
63
+ raise gr.Error(f"Bad request: {resp.json().get('detail', resp.text)}")
64
+ resp.raise_for_status()
65
+ return resp.json()["job_id"]
66
+
67
+
68
+ async def _submit_file(file_path: str, language: str | None = None) -> str:
69
+ """Upload a file for transcription. Returns job_id."""
70
+ async with httpx.AsyncClient(timeout=120.0) as client:
71
+ with open(file_path, "rb") as f:
72
+ files = {"file": (os.path.basename(file_path), f)}
73
+ data = {}
74
+ if language:
75
+ data["language"] = language
76
+ resp = await client.post(
77
+ f"{API_BASE_URL}/v1/transcribe",
78
+ headers=HEADERS,
79
+ files=files,
80
+ data=data,
81
+ )
82
+ if resp.status_code == 413:
83
+ raise gr.Error(f"File too large. Maximum size is {MAX_FILE_SIZE_MB}MB.")
84
+ if resp.status_code == 429:
85
+ detail = resp.json().get("detail", "Rate limit reached")
86
+ raise gr.Error(
87
+ f"Monthly audio minute limit reached. "
88
+ f"Upgrade at signalloomai.com/#pricing — {detail}"
89
+ )
90
+ if resp.status_code == 401 or resp.status_code == 403:
91
+ raise gr.Error("Invalid API key. This demo's key may have been revoked.")
92
+ if resp.status_code == 400:
93
+ raise gr.Error(f"Bad request: {resp.json().get('detail', resp.text)}")
94
+ resp.raise_for_status()
95
+ return resp.json()["job_id"]
96
 
 
 
 
 
 
97
 
98
+ async def _poll_job(job_id: str, status_md, timeout: int = POLL_TIMEOUT_SECONDS):
99
+ """Poll job status, yielding status strings as it progresses."""
100
+ start = time.monotonic()
101
+ interval = POLL_INTERVAL_SECONDS
102
+ last_status = "unknown"
103
 
104
+ while time.monotonic() - start < timeout:
105
+ await asyncio.sleep(interval)
106
 
107
+ async with httpx.AsyncClient(timeout=30.0) as client:
108
+ try:
109
+ resp = await client.get(
110
+ f"{API_BASE_URL}/v1/status/{job_id}",
111
+ headers=HEADERS,
112
+ )
113
+ resp.raise_for_status()
114
+ except httpx.HTTPStatusError as e:
115
+ if e.response.status_code == 404:
116
+ raise gr.Error(f"Job {job_id} not found. It may have expired.")
117
+ raise gr.Error(f"Status check failed: {e}")
118
+
119
+ data = resp.json()
120
+ status = data.get("status", "unknown")
121
+
122
+ if status == last_status and status not in ("queued", "processing", "uploaded"):
123
+ # No meaningful change; keep polling
124
+ pass
125
+ last_status = status
126
+
127
+ if status == "queued":
128
+ yield "status_md", "⏳ **Submitted — Queued for processing...**\n\n_Position unknown. Waiting for a worker slot..._", None
129
+ elif status == "processing":
130
+ elapsed = int(time.monotonic() - start)
131
+ language = data.get("language") or "auto-detected"
132
+ dur = data.get("duration_seconds")
133
+ dur_str = f", ~{int(dur)}s audio" if dur else ""
134
+ yield (
135
+ "status_md",
136
+ f"🔄 **Processing...** (`{elapsed}s elapsed`)\n\n"
137
+ f"Model: `{data.get('model', 'whisper-large-v3-turbo')}` | "
138
+ f"Language: `{language}`{dur_str}\n\n"
139
+ f"_Polling every {interval}s — typically 1–3 min for short files._",
140
+ None,
141
+ )
142
+ interval = min(interval * 1.2, 8.0)
143
+ elif status == "completed":
144
+ # Done — let the result fetch handle the final output
145
+ return
146
+ elif status in ("failed", "error"):
147
+ failure = data.get("failure_code") or data.get("error") or "unknown"
148
+ raise gr.Error(
149
+ f"❌ Transcription failed{f' ({failure})' if failure else ''}.\n\n"
150
+ f"Try a different file/URL, or check signalloomai.com for support."
151
+ )
152
+ elif status in ("cancelled", "expired"):
153
+ raise gr.Error(
154
+ f"Job {status}. Results are retained for 7 days after completion. "
155
+ f"Please submit a new transcription."
156
+ )
157
+ else:
158
+ # Unknown / created / uploaded — keep waiting
159
+ yield (
160
+ "status_md",
161
+ f"🔄 **Status: `{status}`** — waiting for processing...\n\n"
162
+ f"_Still starting up... ({(time.monotonic() - start):.0f}s elapsed)_",
163
+ None,
164
+ )
165
 
166
+ raise gr.Error(
167
+ f"⏱ Transcription timed out after {timeout}s. "
168
+ f"The file may be too long or the service is under load. "
169
+ f"Check job status at signalloomai.com or try again shortly."
170
+ )
171
 
172
 
173
+ async def _fetch_result(job_id: str) -> dict:
174
+ """Fetch the final transcript result."""
175
+ async with httpx.AsyncClient(timeout=30.0) as client:
176
+ resp = await client.get(
177
+ f"{API_BASE_URL}/v1/result/{job_id}",
178
+ headers=HEADERS,
179
+ )
180
+ if resp.status_code == 202:
181
+ raise gr.Error(f"Job {job_id} returned unexpected 202 (still processing). Try again shortly.")
182
+ if resp.status_code == 404:
183
+ raise gr.Error(f"Result for job {job_id} not found. It may have expired.")
184
+ if resp.status_code == 410:
185
+ raise gr.Error(f"Result for job {job_id} has expired. Results are retained for 7 days after completion.")
186
+ resp.raise_for_status()
187
+ return resp.json()
188
+
189
+
190
+ def _build_outputs(result: dict, requested_formats: list[str]) -> dict:
191
+ """Build the output dict matching the expected format options."""
192
+ outputs = {}
193
+
194
+ transcript = result.get("transcript") or {}
195
+ raw_text = result.get("raw_text") or ""
196
+ srt_text = result.get("srt") or ""
197
+ vtt_text = result.get("vtt") or ""
198
+
199
+ # TXT
200
+ if "txt" in requested_formats:
201
+ if raw_text:
202
+ outputs["txt"] = raw_text.strip()
203
+ elif transcript:
204
+ # Reconstruct from segments
205
+ text = " ".join(seg.get("text", "").strip() for seg in transcript.get("segments", []) if seg.get("text"))
206
+ outputs["txt"] = text.strip()
207
+ else:
208
+ outputs["txt"] = raw_text or "(no transcript available)"
209
+
210
+ # JSON
211
+ if "json" in requested_formats:
212
+ outputs["json"] = {
213
+ "status": "success",
214
+ "job_id": result.get("job_id"),
215
+ "language": transcript.get("language", "unknown") if isinstance(transcript, dict) else "unknown",
216
+ "duration_seconds": transcript.get("duration_seconds") if isinstance(transcript, dict) else None,
217
+ "segments": [
218
+ {
219
+ "start_seconds": round(seg.get("start", seg.get("start_time", 0)), 2),
220
+ "end_seconds": round(seg.get("end", seg.get("end_time", 0)), 2),
221
+ "speaker": seg.get("speaker", "UNKNOWN"),
222
+ "text": seg.get("text", "").strip(),
223
+ }
224
+ for seg in (transcript.get("segments", []) if isinstance(transcript, dict) else [])
225
+ if seg.get("text", "").strip()
226
+ ],
227
+ }
228
 
229
+ # SRT
230
+ if "srt" in requested_formats:
231
+ outputs["srt"] = srt_text
 
 
 
 
 
 
 
 
 
232
 
233
+ # VTT
234
+ if "vtt" in requested_formats:
235
+ outputs["vtt"] = vtt_text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
+ return outputs
238
 
 
 
 
 
 
 
 
 
239
 
240
+ # ── Pipeline (generator — yields (output_key, value, _) for Gradio streaming) ──
241
 
242
+ async def run_pipeline(
243
+ audio_file: str | None,
244
+ url_input: str | None,
245
+ language: str | None,
246
+ output_formats: list[str],
247
+ ) -> tuple:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  """
249
+ Full transcription pipeline via Signal Loom API.
250
+ Accepts file upload OR URL ingestion. Yields status/outputs for Gradio streaming.
251
+ Returns tuple: (status_md_text, txt_output, json_output, srt_output, vtt_output)
252
  """
253
+ requested_formats = output_formats or ["txt", "json"]
254
+
255
+ # ── Step 0: validate inputs ───────────────────────────────────────────────
256
+ if url_input and url_input.strip():
257
+ source_label = f"URL"
258
+ elif audio_file:
259
+ source_label = f"File: {os.path.basename(audio_file)}"
260
+ # Check file size (best-effort — actual limit enforced by API)
261
+ try:
262
+ size_mb = os.path.getsize(audio_file) / (1024 * 1024)
263
+ if size_mb > MAX_FILE_SIZE_MB:
264
+ raise gr.Error(
265
+ f"File too large ({size_mb:.0f}MB). Maximum is {MAX_FILE_SIZE_MB}MB.\n\n"
266
+ f"For larger files, use signalloomai.com API."
267
+ )
268
+ except OSError:
269
+ pass
270
+ else:
271
+ raise gr.Error("Please paste a URL or upload a file first.")
272
+
273
+ # ── Step 1: submit ────────────────────────────────────────────────────────
274
+ yield (
275
+ "status_md",
276
+ f"🚀 **Submitting...** `{source_label}`",
277
+ None, None, None, None,
278
+ )
279
+
280
  try:
 
281
  if url_input and url_input.strip():
282
+ job_id = await _submit_url(url_input.strip(), language)
283
+ elif audio_file:
284
+ job_id = await _submit_file(audio_file, language)
 
 
 
 
 
 
285
  else:
 
286
  return
287
+ except gr.Error:
288
+ raise
289
+ except httpx.HTTPStatusError as e:
290
+ try:
291
+ detail = e.response.json().get("detail", e.response.text)
292
+ except Exception:
293
+ detail = str(e)
294
+ raise gr.Error(f"Submission failed: {detail}")
295
+ except Exception as e:
296
+ raise gr.Error(f"Submission failed: {e}")
297
+
298
+ # Immediate acknowledgement state
299
+ yield (
300
+ "status_md",
301
+ f"✅ **Submitted — Transcription in progress...**\n\n"
302
+ f"Job ID: `{job_id}`\n\n"
303
+ f"⏱ Processing typically takes 1–3 minutes depending on length.\n\n"
304
+ f"_Results are not stored long-term — this is a stateless demo._",
305
+ None, None, None, None,
306
+ )
307
 
308
+ # ── Step 2: poll ───────────────────────────────────────────────────────────
309
+ try:
310
+ async for update in _poll_job(job_id, None):
311
+ if update is None:
312
+ continue
313
+ key, val = update[0], update[1]
314
+ if key == "status_md":
315
+ yield val, None, None, None, None, None
316
+ except gr.Error:
317
+ raise
318
+ except Exception as e:
319
+ raise gr.Error(f"Polling failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
+ # ── Step 3: fetch result ──────────────────────────────────────────────────
322
+ yield (
323
+ "status_md",
324
+ f"📥 **Fetching transcript...**",
325
+ None, None, None, None,
326
+ )
327
+
328
+ try:
329
+ result = await _fetch_result(job_id)
330
+ except gr.Error:
331
+ raise
332
+ except httpx.HTTPStatusError as e:
333
+ try:
334
+ detail = e.response.json().get("detail", e.response.text)
335
+ except Exception:
336
+ detail = str(e)
337
+ raise gr.Error(f"Failed to fetch result: {detail}")
338
+ except Exception as e:
339
+ raise gr.Error(f"Failed to fetch result: {e}")
340
+
341
+ # ── Step 4: format and return ───────────────────────────────────────────────
342
+ outputs = _build_outputs(result, requested_formats)
343
+
344
+ transcript = result.get("transcript") or {}
345
+ segments = transcript.get("segments", []) if isinstance(transcript, dict) else []
346
+ num_speakers = len(set(s.get("speaker") for s in segments if s.get("speaker") and s.get("speaker") != "UNKNOWN")) or "N/A"
347
+ lang = transcript.get("language", "unknown") if isinstance(transcript, dict) else "unknown"
348
+ dur = transcript.get("duration_seconds") if isinstance(transcript, dict) else None
349
+
350
+ summary = (
351
+ f"✅ **Done!** `{len(segments)}` segments"
352
+ f" | Lang: `{lang}`"
353
+ f" | Speakers: `{num_speakers}`"
354
+ + (f" | Duration: `{int(dur)}s`" if dur else "")
355
+ )
356
+ cta = (
357
+ "\n\n---\n"
358
+ "🚀 **Want API access?** "
359
+ "[Get your free key at signalloomai.com](https://signalloomai.com/signup.html) "
360
+ "— 100 min/month free, no credit card required."
361
+ )
362
+
363
+ yield (
364
+ "status_md",
365
+ summary + cta,
366
+ outputs.get("txt"),
367
+ outputs.get("json"),
368
+ outputs.get("srt"),
369
+ outputs.get("vtt"),
370
+ )
371
 
372
 
373
  # ── Gradio Interface ─────────────────────────────────────────────────────────
374
+
375
+ _DARK_CSS = """
376
+ /* Dark ambient background */
377
+ body { background: #0f1117; }
378
+ /* Minimal dark chrome */
379
+ .gradio-container { background: transparent; }
380
+ /* Card-like panels */
381
+ .panel-box { background: #161b27; border: 1px solid #1e2535; border-radius: 8px; padding: 16px; }
382
+ /* Status highlight */
383
+ .status-ready { color: #34d399; font-weight: 600; }
384
+ .status-processing { color: #fbbf24; font-weight: 600; }
385
+ .status-error { color: #f87171; font-weight: 600; }
386
+ /* Compact markdown in output */
387
+ .json-output pre { font-size: 12px; }
388
+ /* Links */
389
+ a { color: #818cf8; text-decoration: none; }
390
+ a:hover { text-decoration: underline; }
391
+ """
392
+
393
+ with gr.Blocks(
394
+ title="Signal Loom AI",
395
+ css=_DARK_CSS,
396
+ ) as demo:
397
+
398
  gr.Markdown(
399
+ "## ✦ Signal Loom AI\n"
400
+ "**Structured media intelligence — transcription, speaker diarization, and machine-readable output.**\n\n"
401
+ "⚡ **Powered by Signal Loom API** — MLX Whisper on Apple Silicon.\n\n"
402
+ "| | |\n"
403
+ "|---|---|\n"
404
+ "| **Free tier** | 100 min/month |\n"
405
+ "| **Output** | TXT · JSON · SRT · VTT |\n"
406
+ "| **Sources** | YouTube, direct upload, 30+ platforms |\n\n"
407
+ "📌 **This is a demo surface.** For API access and production use → **[signalloomai.com](https://signalloomai.com)**\n\n"
408
+ "---\n"
409
  )
410
 
411
+ with gr.Row(equal_height=False):
412
+ # ── LEFT: Inputs ────────────────────────────────────────────────────────
413
+ with gr.Column(scale=1, min_width=320):
414
+ gr.Markdown("### 🔗 Option 1: Paste a URL")
415
  gr.Markdown(
416
+ "_YouTube, direct links, Google Drive, Dropbox, and more._\n\n"
417
+ "<span style='font-size: 12px; color: #6b7280;'>"
418
+ "Note: Some YouTube videos may be blocked by platform anti-bot protections. "
419
+ "Direct uploads are the most reliable."
 
 
 
420
  "</span>"
421
  )
422
  url_input = gr.Textbox(
423
  label="Media URL",
424
  placeholder="https://www.youtube.com/watch?v=...",
 
425
  lines=2,
426
+ info="We'll download and transcribe automatically",
427
  )
428
 
429
+ gr.Markdown("### 📁 Option 2: Upload a File")
430
+ gr.Markdown(
431
+ f"**Supported:** mp3 · wav · m4a · flac · ogg · mp4 · mov · mkv · avi · webm \n"
432
+ f"**Max size:** {MAX_FILE_SIZE_MB}MB \n"
433
+ "<span style='font-size: 12px; color: #6b7280;'>"
434
+ "Results are not stored long-term — stateless demo surface."
435
+ "</span>"
436
+ )
437
  audio_input = gr.File(
438
+ label="Upload Audio or Video",
439
+ file_types=SUPPORTED_EXTENSIONS,
440
  type="filepath",
441
  )
442
 
443
+ with gr.Accordion("⚙️ Options", open=False):
 
 
 
 
 
444
  language = gr.Dropdown(
445
  label="Language (auto-detect if blank)",
446
+ choices=["", "en", "es", "fr", "de", "it", "pt", "zh", "ja", "ko", "ar", "ru"],
447
+ value="",
 
 
 
 
 
 
 
 
 
 
448
  )
449
  output_formats = gr.CheckboxGroup(
450
  label="Output Formats",
451
+ choices=OUTPUT_FORMAT_OPTIONS,
452
+ value=["txt", "json"],
453
  )
454
 
455
+ run_btn = gr.Button("▶ Run Transcription", variant="primary", size="lg")
456
 
457
+ # ── RIGHT: Outputs ──────────────────────────────────────────────────────
458
+ with gr.Column(scale=2, min_width=400):
459
+ status_md = gr.Markdown(
460
+ "**Ready.** Paste a URL or upload a file, then click **Run Transcription**.\n\n"
461
+ "⏱ Processing typically takes 1–3 minutes depending on length and queue depth.",
462
+ )
463
 
464
+ gr.Markdown("#### 📄 Transcript (TXT)")
465
+ txt_output = gr.Textbox(label=None, lines=8, interactive=False, show_label=False)
466
+
467
+ gr.Markdown("#### 📊 Structured JSON")
468
+ json_output = gr.JSON(label=None, show_label=False)
469
+
470
+ gr.Markdown("#### 📝 Subtitles (SRT)")
471
+ srt_output = gr.Textbox(label=None, lines=6, interactive=False, show_label=False)
472
 
473
+ gr.Markdown("#### 🎬 Captions (VTT)")
474
+ vtt_output = gr.Textbox(label=None, lines=6, interactive=False, show_label=False)
475
+
476
+ # Footer
477
  gr.Markdown(
478
  "---\n"
479
+ "**Signal Loom AI** — _Media In. Machine Intelligence Out._ \n"
480
+ "This HuggingFace Space is a **demo surface**. For full API access, billing, and production use → "
481
+ "[**signalloomai.com**](https://signalloomai.com) | "
482
+ "[Get free API key](https://signalloomai.com/signup.html) | "
483
  "[HuggingFace Org](https://huggingface.co/signalloom-ai)"
484
  )
485
 
486
+ # ── Event binding ─────────────────────────────────────────────────────────
487
+ run_btn.click(
488
+ fn=run_pipeline,
489
+ inputs=[audio_input, url_input, language, output_formats],
490
+ outputs=[status_md, txt_output, json_output, srt_output, vtt_output],
491
+ queue=True,
492
+ concurrency_limit=None,
493
+ )
494
+
495
+
496
+ # ── Local launch ─────────────────────────────────────────────────────────────
497
+ if __name__ == "__main__":
498
+ import sys
499
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 18791
500
+ demo.launch(server_port=port, server_name="0.0.0.0")
requirements.txt CHANGED
@@ -1,5 +1,2 @@
1
- openai-whisper
2
- pyannote.audio>=3.0
3
- soundfile
4
- sentence-transformers
5
- yt-dlp
 
1
+ httpx>=0.27.0
2
+ gradio>=5.0.0
 
 
 
space.yaml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Signal Loom AI — HuggingFace Spaces Configuration
2
+ # https://huggingface.co/docs/hub/spaces
3
+
4
+ sdk: gradio
5
+ app_path: app.py
6
+ name: signal-loom-ai
7
+ title: Signal Loom AI
8
+ description: |
9
+ Structured media intelligence — transcription, speaker diarization,
10
+ and machine-readable output (TXT, JSON, SRT, VTT).
11
+ Powered by Signal Loom API · MLX Whisper on Apple Silicon.
12
+ emoji: 🎙
13
+ color: indigo
14
+ tags:
15
+ - transcription
16
+ - whisper
17
+ - audio
18
+ - video
19
+ - diarization
20
+ - speech-to-text
21
+ - artificial-intelligence
22
+ - mlx
23
+ license: apache-2.0