@larkup/tool-video-intelligence 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +150 -0
- package/LICENSE +176 -0
- package/README.md +281 -0
- package/compose.gpu.yaml +14 -0
- package/compose.yaml +71 -0
- package/dist/agent.d.ts +131 -0
- package/dist/agent.js +2087 -0
- package/dist/brief.d.ts +2 -0
- package/dist/brief.js +37 -0
- package/dist/client.d.ts +46 -0
- package/dist/client.js +139 -0
- package/dist/contracts.d.ts +331 -0
- package/dist/contracts.js +1 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +391 -0
- package/dist/runtime.d.ts +96 -0
- package/dist/runtime.js +592 -0
- package/dist/ui.d.ts +82 -0
- package/dist/ui.js +87 -0
- package/package.json +84 -0
- package/runtime/Dockerfile +119 -0
- package/runtime/app/__init__.py +3 -0
- package/runtime/app/__main__.py +19 -0
- package/runtime/app/api/__init__.py +0 -0
- package/runtime/app/api/deps.py +69 -0
- package/runtime/app/api/v1.py +166 -0
- package/runtime/app/config.py +78 -0
- package/runtime/app/db/__init__.py +0 -0
- package/runtime/app/db/schemas.py +162 -0
- package/runtime/app/db/store.py +466 -0
- package/runtime/app/main.py +27 -0
- package/runtime/app/model_configuration.py +157 -0
- package/runtime/app/services/__init__.py +0 -0
- package/runtime/app/services/brain.py +2221 -0
- package/runtime/app/services/embedding.py +473 -0
- package/runtime/app/services/jobs.py +237 -0
- package/runtime/app/services/motion.py +66 -0
- package/runtime/app/services/pipeline.py +1911 -0
- package/runtime/app/services/scene.py +161 -0
- package/runtime/app/services/storage.py +44 -0
- package/runtime/app/services/transcription.py +667 -0
- package/runtime/app/services/vision.py +1441 -0
- package/runtime/app/utils/__init__.py +0 -0
- package/runtime/app/utils/timing.py +99 -0
- package/runtime/app/worker.py +20 -0
- package/runtime/pyproject.toml +56 -0
- package/runtime/requirements-cpu.txt +15 -0
- package/runtime/requirements-smoke.txt +7 -0
- package/runtime/requirements.txt +14 -0
- package/runtime/uv.lock +3637 -0
- package/scripts/grant-cloud-credits.sh +43 -0
- package/scripts/runtime.mjs +156 -0
- package/scripts/validate-indexing.mjs +168 -0
- package/tool.manifest.json +617 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""Speech-to-text, behind a provider so a deploy target can pick the cheapest
|
|
2
|
+
or fastest option available to it without the pipeline caring which one runs.
|
|
3
|
+
|
|
4
|
+
`whisper` decodes locally (faster-whisper); hosted providers are preferred by
|
|
5
|
+
managed workers and may fall back to Whisper when they return no usable speech.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import threading
|
|
13
|
+
import mimetypes
|
|
14
|
+
import tempfile
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
DEEPGRAM_URL = "https://api.deepgram.com/v1/listen"
|
|
23
|
+
OPENAI_TRANSCRIPT_URL = "https://api.openai.com/v1/audio/transcriptions"
|
|
24
|
+
GROQ_TRANSCRIPT_URL = "https://api.groq.com/openai/v1/audio/transcriptions"
|
|
25
|
+
ELEVENLABS_TRANSCRIPT_URL = "https://api.elevenlabs.io/v1/speech-to-text"
|
|
26
|
+
TranscriptionProgress = Callable[[int, int], None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TranscriptionProvider(ABC):
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def transcribe(
|
|
32
|
+
self, path: Path, language_hint: str | None, hints: list[str] | None = None
|
|
33
|
+
) -> tuple[list[dict[str, Any]], str | None]:
|
|
34
|
+
"""Returns (segments, detected_language). Each segment has startMs/endMs/text/words."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EmptyTranscriptionError(RuntimeError):
|
|
38
|
+
"""A provider completed successfully but returned no usable speech evidence."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _materialize_hosted_audio_chunks(
|
|
42
|
+
path: Path, duration_secs: float, chunk_secs: int, destination: Path
|
|
43
|
+
) -> list[tuple[float, Path]]:
|
|
44
|
+
"""Decode a long source once into small speech-provider uploads."""
|
|
45
|
+
if duration_secs <= chunk_secs:
|
|
46
|
+
return [(0.0, path)]
|
|
47
|
+
pattern = destination / "speech-%05d.mp3"
|
|
48
|
+
subprocess.run(
|
|
49
|
+
[
|
|
50
|
+
"ffmpeg",
|
|
51
|
+
"-nostdin",
|
|
52
|
+
"-hide_banner",
|
|
53
|
+
"-loglevel",
|
|
54
|
+
"error",
|
|
55
|
+
"-i",
|
|
56
|
+
str(path),
|
|
57
|
+
"-map",
|
|
58
|
+
"0:a:0",
|
|
59
|
+
"-vn",
|
|
60
|
+
"-ac",
|
|
61
|
+
"1",
|
|
62
|
+
"-ar",
|
|
63
|
+
"16000",
|
|
64
|
+
"-c:a",
|
|
65
|
+
"libmp3lame",
|
|
66
|
+
"-b:a",
|
|
67
|
+
"48k",
|
|
68
|
+
"-f",
|
|
69
|
+
"segment",
|
|
70
|
+
"-segment_time",
|
|
71
|
+
str(chunk_secs),
|
|
72
|
+
"-reset_timestamps",
|
|
73
|
+
"1",
|
|
74
|
+
str(pattern),
|
|
75
|
+
],
|
|
76
|
+
check=True,
|
|
77
|
+
capture_output=True,
|
|
78
|
+
)
|
|
79
|
+
chunks = sorted(destination.glob("speech-*.mp3"))
|
|
80
|
+
if not chunks:
|
|
81
|
+
raise RuntimeError("ffmpeg produced no audio chunks")
|
|
82
|
+
return [(index * float(chunk_secs), chunk) for index, chunk in enumerate(chunks)]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _rebase_segments(
|
|
86
|
+
segments: list[dict[str, Any]], offset_secs: float
|
|
87
|
+
) -> list[dict[str, Any]]:
|
|
88
|
+
offset_ms = round(offset_secs * 1_000)
|
|
89
|
+
rebased: list[dict[str, Any]] = []
|
|
90
|
+
for segment in segments:
|
|
91
|
+
copy = {**segment}
|
|
92
|
+
for key in ("startMs", "endMs"):
|
|
93
|
+
if isinstance(copy.get(key), (int, float)):
|
|
94
|
+
copy[key] = round(float(copy[key])) + offset_ms
|
|
95
|
+
copy["words"] = [
|
|
96
|
+
{
|
|
97
|
+
**word,
|
|
98
|
+
**{
|
|
99
|
+
key: round(float(word[key])) + offset_ms
|
|
100
|
+
for key in ("startMs", "endMs")
|
|
101
|
+
if isinstance(word.get(key), (int, float))
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
for word in segment.get("words") or []
|
|
105
|
+
if isinstance(word, dict)
|
|
106
|
+
]
|
|
107
|
+
rebased.append(copy)
|
|
108
|
+
return rebased
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _timestamped_segment(
|
|
112
|
+
item: dict[str, Any], words: list[dict[str, Any]] | None = None
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
return {
|
|
115
|
+
"startMs": round(float(item.get("start", 0)) * 1_000),
|
|
116
|
+
"endMs": round(float(item.get("end", 0)) * 1_000),
|
|
117
|
+
"text": str(item.get("transcript") or item.get("text") or "").strip(),
|
|
118
|
+
"words": [
|
|
119
|
+
{
|
|
120
|
+
"startMs": round(float(word.get("start", 0)) * 1_000),
|
|
121
|
+
"endMs": round(float(word.get("end", 0)) * 1_000),
|
|
122
|
+
"text": str(
|
|
123
|
+
word.get("punctuated_word") or word.get("word") or ""
|
|
124
|
+
).strip(),
|
|
125
|
+
"confidence": round(float(word.get("confidence", 0)), 4),
|
|
126
|
+
}
|
|
127
|
+
for word in (words if words is not None else item.get("words") or [])
|
|
128
|
+
if str(word.get("punctuated_word") or word.get("word") or "").strip()
|
|
129
|
+
],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _deepgram_segments(results: dict[str, Any]) -> list[dict[str, Any]]:
|
|
134
|
+
utterances = [
|
|
135
|
+
_timestamped_segment(utterance)
|
|
136
|
+
for utterance in results.get("utterances") or []
|
|
137
|
+
if str(utterance.get("transcript") or "").strip()
|
|
138
|
+
]
|
|
139
|
+
if utterances:
|
|
140
|
+
return utterances
|
|
141
|
+
|
|
142
|
+
channels = results.get("channels") or []
|
|
143
|
+
alternatives = channels[0].get("alternatives") or [] if channels else []
|
|
144
|
+
alternative = alternatives[0] if alternatives else {}
|
|
145
|
+
paragraphs = (alternative.get("paragraphs") or {}).get("paragraphs") or []
|
|
146
|
+
sentences = [
|
|
147
|
+
sentence
|
|
148
|
+
for paragraph in paragraphs
|
|
149
|
+
for sentence in paragraph.get("sentences") or []
|
|
150
|
+
if str(sentence.get("text") or "").strip()
|
|
151
|
+
]
|
|
152
|
+
if sentences:
|
|
153
|
+
words = alternative.get("words") or []
|
|
154
|
+
return [
|
|
155
|
+
_timestamped_segment(
|
|
156
|
+
sentence,
|
|
157
|
+
[
|
|
158
|
+
word
|
|
159
|
+
for word in words
|
|
160
|
+
if float(sentence.get("start", 0))
|
|
161
|
+
<= float(word.get("start", 0))
|
|
162
|
+
<= float(sentence.get("end", 0))
|
|
163
|
+
],
|
|
164
|
+
)
|
|
165
|
+
for sentence in sentences
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
words = [
|
|
169
|
+
word
|
|
170
|
+
for word in alternative.get("words") or []
|
|
171
|
+
if str(word.get("punctuated_word") or word.get("word") or "").strip()
|
|
172
|
+
]
|
|
173
|
+
if not words:
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
# Some Deepgram responses omit both utterances and paragraph metadata.
|
|
177
|
+
# Build short evidence windows from word timestamps instead of collapsing
|
|
178
|
+
# a long recording into one unusable segment.
|
|
179
|
+
groups: list[list[dict[str, Any]]] = []
|
|
180
|
+
current: list[dict[str, Any]] = []
|
|
181
|
+
for word in words:
|
|
182
|
+
previous_end = float(current[-1].get("end", 0)) if current else 0
|
|
183
|
+
group_start = float(current[0].get("start", 0)) if current else 0
|
|
184
|
+
word_start = float(word.get("start", 0))
|
|
185
|
+
if current and (
|
|
186
|
+
word_start - previous_end > 1.25 or word_start - group_start >= 15
|
|
187
|
+
):
|
|
188
|
+
groups.append(current)
|
|
189
|
+
current = []
|
|
190
|
+
current.append(word)
|
|
191
|
+
punctuated = str(word.get("punctuated_word") or "")
|
|
192
|
+
if punctuated.endswith((".", "?", "!", "؟")):
|
|
193
|
+
groups.append(current)
|
|
194
|
+
current = []
|
|
195
|
+
if current:
|
|
196
|
+
groups.append(current)
|
|
197
|
+
return [
|
|
198
|
+
_timestamped_segment(
|
|
199
|
+
{
|
|
200
|
+
"start": group[0].get("start", 0),
|
|
201
|
+
"end": group[-1].get("end", group[0].get("start", 0)),
|
|
202
|
+
"text": " ".join(
|
|
203
|
+
str(word.get("punctuated_word") or word.get("word") or "").strip()
|
|
204
|
+
for word in group
|
|
205
|
+
),
|
|
206
|
+
},
|
|
207
|
+
group,
|
|
208
|
+
)
|
|
209
|
+
for group in groups
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class WhisperProvider(TranscriptionProvider):
|
|
214
|
+
# Below this share of the source carrying speech, voice detection is
|
|
215
|
+
# assumed to have discarded speech rather than found silence. Continuous
|
|
216
|
+
# background noise -- a crowd, traffic, music, a busy room -- is what
|
|
217
|
+
# triggers it, and the speech it swallows is exactly the commentary or
|
|
218
|
+
# narration an answer later depends on. Measured on a noisy source: voice
|
|
219
|
+
# detection kept 2 segments where a second pass without it recovered 66.
|
|
220
|
+
MINIMUM_SPEECH_COVERAGE = 0.1
|
|
221
|
+
|
|
222
|
+
def __init__(self, device: str) -> None:
|
|
223
|
+
self.device = device
|
|
224
|
+
self._lock = threading.Lock()
|
|
225
|
+
self._model: Any = None
|
|
226
|
+
|
|
227
|
+
def _decode(
|
|
228
|
+
self,
|
|
229
|
+
path: Path,
|
|
230
|
+
language_hint: str | None,
|
|
231
|
+
hints: list[str] | None,
|
|
232
|
+
use_voice_detection: bool,
|
|
233
|
+
) -> tuple[list[dict[str, Any]], Any]:
|
|
234
|
+
segments, info = self._model.transcribe(
|
|
235
|
+
str(path),
|
|
236
|
+
language=language_hint,
|
|
237
|
+
vad_filter=use_voice_detection,
|
|
238
|
+
word_timestamps=True,
|
|
239
|
+
beam_size=5,
|
|
240
|
+
initial_prompt=", ".join(
|
|
241
|
+
hint.strip() for hint in (hints or []) if hint.strip()
|
|
242
|
+
)[:900]
|
|
243
|
+
or None,
|
|
244
|
+
)
|
|
245
|
+
return [
|
|
246
|
+
{
|
|
247
|
+
"startMs": round(segment.start * 1_000),
|
|
248
|
+
"endMs": round(segment.end * 1_000),
|
|
249
|
+
"text": segment.text.strip(),
|
|
250
|
+
"words": [
|
|
251
|
+
{
|
|
252
|
+
"startMs": round((word.start or segment.start) * 1_000),
|
|
253
|
+
"endMs": round((word.end or segment.end) * 1_000),
|
|
254
|
+
"text": word.word.strip(),
|
|
255
|
+
"confidence": round(float(word.probability), 4),
|
|
256
|
+
}
|
|
257
|
+
for word in (segment.words or [])
|
|
258
|
+
],
|
|
259
|
+
}
|
|
260
|
+
for segment in segments
|
|
261
|
+
if segment.text.strip()
|
|
262
|
+
], info
|
|
263
|
+
|
|
264
|
+
def transcribe(
|
|
265
|
+
self, path: Path, language_hint: str | None, hints: list[str] | None = None
|
|
266
|
+
) -> tuple[list[dict[str, Any]], str | None]:
|
|
267
|
+
with self._lock:
|
|
268
|
+
if self._model is None:
|
|
269
|
+
from faster_whisper import WhisperModel
|
|
270
|
+
|
|
271
|
+
model_name = os.getenv("LARKUP_VIDEO_WHISPER_MODEL", "small")
|
|
272
|
+
compute_type = "float16" if self.device == "cuda" else "int8"
|
|
273
|
+
self._model = WhisperModel(
|
|
274
|
+
model_name, device=self.device, compute_type=compute_type
|
|
275
|
+
)
|
|
276
|
+
result, info = self._decode(path, language_hint, hints, True)
|
|
277
|
+
duration = float(getattr(info, "duration", 0) or 0)
|
|
278
|
+
speech_secs = sum(
|
|
279
|
+
(item["endMs"] - item["startMs"]) / 1_000 for item in result
|
|
280
|
+
)
|
|
281
|
+
if duration > 0 and speech_secs / duration < self.MINIMUM_SPEECH_COVERAGE:
|
|
282
|
+
# Keep whichever pass heard more. Voice detection is worth
|
|
283
|
+
# having when it works -- it is faster and suppresses
|
|
284
|
+
# hallucinated text over silence -- so it is only overridden
|
|
285
|
+
# when a second pass demonstrably recovers more speech.
|
|
286
|
+
retried, retried_info = self._decode(path, language_hint, hints, False)
|
|
287
|
+
if len(retried) > len(result):
|
|
288
|
+
return retried, getattr(retried_info, "language", None)
|
|
289
|
+
return result, getattr(info, "language", None)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class DeepgramProvider(TranscriptionProvider):
|
|
293
|
+
def transcribe(
|
|
294
|
+
self, path: Path, language_hint: str | None, hints: list[str] | None = None
|
|
295
|
+
) -> tuple[list[dict[str, Any]], str | None]:
|
|
296
|
+
api_key = os.getenv("DEEPGRAM_API_KEY")
|
|
297
|
+
if not api_key:
|
|
298
|
+
raise RuntimeError("DEEPGRAM_API_KEY is not configured")
|
|
299
|
+
automatic_language = not language_hint or language_hint == "auto"
|
|
300
|
+
model = (
|
|
301
|
+
os.getenv(
|
|
302
|
+
(
|
|
303
|
+
"LARKUP_VIDEO_DEEPGRAM_AUTO_MODEL"
|
|
304
|
+
if automatic_language
|
|
305
|
+
else "LARKUP_VIDEO_DEEPGRAM_MODEL"
|
|
306
|
+
),
|
|
307
|
+
"nova-3",
|
|
308
|
+
).strip()
|
|
309
|
+
or "nova-3"
|
|
310
|
+
)
|
|
311
|
+
params: list[tuple[str, str]] = [
|
|
312
|
+
("model", model),
|
|
313
|
+
("smart_format", "true"),
|
|
314
|
+
("punctuate", "true"),
|
|
315
|
+
("utterances", "true"),
|
|
316
|
+
]
|
|
317
|
+
if not automatic_language:
|
|
318
|
+
params.append(("language", language_hint))
|
|
319
|
+
else:
|
|
320
|
+
# Nova-3's multilingual route recognizes code-switching and RTL
|
|
321
|
+
# languages in one pass. Generic language detection can select a
|
|
322
|
+
# single wrong model from noisy music, crowds, or commentary.
|
|
323
|
+
params.append(("language", "multi"))
|
|
324
|
+
for hint in (hints or [])[:100] if model.startswith("nova-3") else []:
|
|
325
|
+
normalized = hint.strip()
|
|
326
|
+
if normalized:
|
|
327
|
+
# Nova-3 keyterms improve transcription of people,
|
|
328
|
+
# organizations, products, and other proper nouns without
|
|
329
|
+
# changing the meaning of the audio or relying on a scenario.
|
|
330
|
+
params.append(("keyterm", normalized[:200]))
|
|
331
|
+
media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
332
|
+
timeout_secs = max(
|
|
333
|
+
30,
|
|
334
|
+
min(
|
|
335
|
+
900,
|
|
336
|
+
int(
|
|
337
|
+
os.getenv(
|
|
338
|
+
"LARKUP_VIDEO_TRANSCRIPTION_REQUEST_TIMEOUT_SECONDS", "60"
|
|
339
|
+
)
|
|
340
|
+
),
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
with path.open("rb") as source:
|
|
344
|
+
response = requests.post(
|
|
345
|
+
DEEPGRAM_URL,
|
|
346
|
+
params=params,
|
|
347
|
+
# Deepgram's pre-recorded endpoint uses the media Content-Type
|
|
348
|
+
# to decode local containers accurately; omitting it makes
|
|
349
|
+
# video speech recognition materially less reliable.
|
|
350
|
+
headers={
|
|
351
|
+
"Authorization": f"Token {api_key}",
|
|
352
|
+
"Content-Type": media_type,
|
|
353
|
+
},
|
|
354
|
+
data=source,
|
|
355
|
+
timeout=timeout_secs,
|
|
356
|
+
)
|
|
357
|
+
if not response.ok:
|
|
358
|
+
raise RuntimeError(
|
|
359
|
+
f"Deepgram request failed {response.status_code}: {response.text[:500]}"
|
|
360
|
+
)
|
|
361
|
+
body = response.json()
|
|
362
|
+
results = body.get("results") or {}
|
|
363
|
+
channels = results.get("channels") or [{}]
|
|
364
|
+
detected_language = channels[0].get("detected_language") if channels else None
|
|
365
|
+
segments = _deepgram_segments(results)
|
|
366
|
+
return segments, detected_language or (
|
|
367
|
+
language_hint if language_hint != "auto" else None
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class OpenAICompatibleProvider(TranscriptionProvider):
|
|
372
|
+
"""Timestamped transcription through OpenAI or Groq's compatible API."""
|
|
373
|
+
|
|
374
|
+
def __init__(self, provider: str) -> None:
|
|
375
|
+
self.provider = provider
|
|
376
|
+
|
|
377
|
+
def transcribe(
|
|
378
|
+
self, path: Path, language_hint: str | None, hints: list[str] | None = None
|
|
379
|
+
) -> tuple[list[dict[str, Any]], str | None]:
|
|
380
|
+
is_groq = self.provider == "groq"
|
|
381
|
+
api_key = os.getenv("GROQ_API_KEY" if is_groq else "OPENAI_API_KEY")
|
|
382
|
+
if not api_key:
|
|
383
|
+
raise RuntimeError(f"{self.provider.upper()}_API_KEY is not configured")
|
|
384
|
+
model = os.getenv(
|
|
385
|
+
(
|
|
386
|
+
"LARKUP_VIDEO_GROQ_TRANSCRIPTION_MODEL"
|
|
387
|
+
if is_groq
|
|
388
|
+
else "LARKUP_VIDEO_OPENAI_TRANSCRIPTION_MODEL"
|
|
389
|
+
),
|
|
390
|
+
"whisper-large-v3-turbo" if is_groq else "whisper-1",
|
|
391
|
+
)
|
|
392
|
+
data: list[tuple[str, str]] = [
|
|
393
|
+
("model", model),
|
|
394
|
+
("response_format", "verbose_json"),
|
|
395
|
+
("timestamp_granularities[]", "segment"),
|
|
396
|
+
("timestamp_granularities[]", "word"),
|
|
397
|
+
]
|
|
398
|
+
if language_hint and language_hint != "auto":
|
|
399
|
+
data.append(("language", language_hint))
|
|
400
|
+
prompt = ", ".join(hint.strip() for hint in (hints or []) if hint.strip())[:900]
|
|
401
|
+
if prompt:
|
|
402
|
+
data.append(("prompt", prompt))
|
|
403
|
+
media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
404
|
+
with path.open("rb") as source:
|
|
405
|
+
response = requests.post(
|
|
406
|
+
GROQ_TRANSCRIPT_URL if is_groq else OPENAI_TRANSCRIPT_URL,
|
|
407
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
408
|
+
data=data,
|
|
409
|
+
files={"file": (path.name, source, media_type)},
|
|
410
|
+
timeout=900,
|
|
411
|
+
)
|
|
412
|
+
if not response.ok:
|
|
413
|
+
raise RuntimeError(
|
|
414
|
+
f"{self.provider} transcription failed {response.status_code}: {response.text[:500]}"
|
|
415
|
+
)
|
|
416
|
+
body = response.json()
|
|
417
|
+
segments = [
|
|
418
|
+
{
|
|
419
|
+
"startMs": round(float(segment.get("start", 0)) * 1_000),
|
|
420
|
+
"endMs": round(float(segment.get("end", 0)) * 1_000),
|
|
421
|
+
"text": str(segment.get("text") or "").strip(),
|
|
422
|
+
"words": [
|
|
423
|
+
{
|
|
424
|
+
"startMs": round(float(word.get("start", 0)) * 1_000),
|
|
425
|
+
"endMs": round(float(word.get("end", 0)) * 1_000),
|
|
426
|
+
"text": str(word.get("word") or word.get("text") or "").strip(),
|
|
427
|
+
"confidence": 0.8,
|
|
428
|
+
}
|
|
429
|
+
for word in body.get("words") or []
|
|
430
|
+
if float(segment.get("start", 0))
|
|
431
|
+
<= float(word.get("start", 0))
|
|
432
|
+
<= float(segment.get("end", 0))
|
|
433
|
+
],
|
|
434
|
+
}
|
|
435
|
+
for segment in body.get("segments") or []
|
|
436
|
+
if str(segment.get("text") or "").strip()
|
|
437
|
+
]
|
|
438
|
+
return segments, body.get("language") or (
|
|
439
|
+
language_hint if language_hint != "auto" else None
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class ElevenLabsProvider(TranscriptionProvider):
|
|
444
|
+
def transcribe(
|
|
445
|
+
self, path: Path, language_hint: str | None, hints: list[str] | None = None
|
|
446
|
+
) -> tuple[list[dict[str, Any]], str | None]:
|
|
447
|
+
api_key = os.getenv("ELEVENLABS_API_KEY")
|
|
448
|
+
if not api_key:
|
|
449
|
+
raise RuntimeError("ELEVENLABS_API_KEY is not configured")
|
|
450
|
+
media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
451
|
+
data = {
|
|
452
|
+
"model_id": os.getenv(
|
|
453
|
+
"LARKUP_VIDEO_ELEVENLABS_TRANSCRIPTION_MODEL", "scribe_v2"
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
if language_hint and language_hint != "auto":
|
|
457
|
+
data["language_code"] = language_hint
|
|
458
|
+
with path.open("rb") as source:
|
|
459
|
+
response = requests.post(
|
|
460
|
+
ELEVENLABS_TRANSCRIPT_URL,
|
|
461
|
+
headers={"xi-api-key": api_key},
|
|
462
|
+
data=data,
|
|
463
|
+
files={"file": (path.name, source, media_type)},
|
|
464
|
+
timeout=900,
|
|
465
|
+
)
|
|
466
|
+
if not response.ok:
|
|
467
|
+
raise RuntimeError(
|
|
468
|
+
f"ElevenLabs transcription failed {response.status_code}: {response.text[:500]}"
|
|
469
|
+
)
|
|
470
|
+
body = response.json()
|
|
471
|
+
words = [
|
|
472
|
+
word
|
|
473
|
+
for word in body.get("words") or []
|
|
474
|
+
if str(word.get("text") or "").strip()
|
|
475
|
+
]
|
|
476
|
+
segments = [
|
|
477
|
+
{
|
|
478
|
+
"startMs": round(float(word.get("start", 0)) * 1_000),
|
|
479
|
+
"endMs": round(float(word.get("end", 0)) * 1_000),
|
|
480
|
+
"text": str(word.get("text") or "").strip(),
|
|
481
|
+
"words": [
|
|
482
|
+
{
|
|
483
|
+
"startMs": round(float(word.get("start", 0)) * 1_000),
|
|
484
|
+
"endMs": round(float(word.get("end", 0)) * 1_000),
|
|
485
|
+
"text": str(word.get("text") or "").strip(),
|
|
486
|
+
"confidence": float(word.get("logprob", 0.8)),
|
|
487
|
+
}
|
|
488
|
+
],
|
|
489
|
+
}
|
|
490
|
+
for word in words
|
|
491
|
+
if str(word.get("type") or "word") == "word"
|
|
492
|
+
]
|
|
493
|
+
return segments, body.get("language_code") or (
|
|
494
|
+
language_hint if language_hint != "auto" else None
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class TranscriptionService:
|
|
499
|
+
def __init__(self, device: str) -> None:
|
|
500
|
+
self._factories: dict[str, Any] = {
|
|
501
|
+
"whisper": lambda: WhisperProvider(device),
|
|
502
|
+
"deepgram": DeepgramProvider,
|
|
503
|
+
"openai": lambda: OpenAICompatibleProvider("openai"),
|
|
504
|
+
"groq": lambda: OpenAICompatibleProvider("groq"),
|
|
505
|
+
"elevenlabs": ElevenLabsProvider,
|
|
506
|
+
}
|
|
507
|
+
self._providers: dict[str, TranscriptionProvider] = {}
|
|
508
|
+
self.last_diagnostics: dict[str, Any] = {
|
|
509
|
+
"provider": None,
|
|
510
|
+
"fallbackProvider": None,
|
|
511
|
+
"fallbackUsed": False,
|
|
512
|
+
"chunkCount": 0,
|
|
513
|
+
"chunkErrors": 0,
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
def _get_provider(self, name: str) -> TranscriptionProvider:
|
|
517
|
+
if name not in self._providers:
|
|
518
|
+
factory = self._factories.get(name)
|
|
519
|
+
if factory is None:
|
|
520
|
+
raise ValueError(f"unknown transcription provider: {name!r}")
|
|
521
|
+
self._providers[name] = factory()
|
|
522
|
+
return self._providers[name]
|
|
523
|
+
|
|
524
|
+
def transcribe(
|
|
525
|
+
self,
|
|
526
|
+
path: Path,
|
|
527
|
+
language_hint: str | None,
|
|
528
|
+
hints: list[str] | None = None,
|
|
529
|
+
source_duration_secs: float | None = None,
|
|
530
|
+
progress: TranscriptionProgress | None = None,
|
|
531
|
+
) -> tuple[list[dict[str, Any]], str | None]:
|
|
532
|
+
primary_name = os.getenv(
|
|
533
|
+
"LARKUP_VIDEO_TRANSCRIPTION_PROVIDER", "whisper"
|
|
534
|
+
).strip()
|
|
535
|
+
fallback_name = os.getenv(
|
|
536
|
+
"LARKUP_VIDEO_TRANSCRIPTION_FALLBACK",
|
|
537
|
+
"whisper" if primary_name != "whisper" else "",
|
|
538
|
+
).strip()
|
|
539
|
+
self.last_diagnostics = {
|
|
540
|
+
"provider": primary_name,
|
|
541
|
+
"fallbackProvider": None,
|
|
542
|
+
"fallbackUsed": False,
|
|
543
|
+
"chunkCount": 0,
|
|
544
|
+
"completedChunks": 0,
|
|
545
|
+
"chunkErrors": 0,
|
|
546
|
+
}
|
|
547
|
+
primary_error: BaseException | None = None
|
|
548
|
+
try:
|
|
549
|
+
segments, language = self._transcribe_primary(
|
|
550
|
+
primary_name,
|
|
551
|
+
path,
|
|
552
|
+
language_hint,
|
|
553
|
+
hints,
|
|
554
|
+
source_duration_secs,
|
|
555
|
+
progress,
|
|
556
|
+
)
|
|
557
|
+
if segments:
|
|
558
|
+
return segments, language
|
|
559
|
+
primary_error = EmptyTranscriptionError(
|
|
560
|
+
f"{primary_name} returned no usable speech segments"
|
|
561
|
+
)
|
|
562
|
+
except BaseException as error:
|
|
563
|
+
primary_error = error
|
|
564
|
+
|
|
565
|
+
if fallback_name and fallback_name != primary_name:
|
|
566
|
+
try:
|
|
567
|
+
self.last_diagnostics["fallbackProvider"] = fallback_name
|
|
568
|
+
self.last_diagnostics["fallbackUsed"] = True
|
|
569
|
+
if progress:
|
|
570
|
+
progress(0, 1)
|
|
571
|
+
segments, language = self._get_provider(fallback_name).transcribe(
|
|
572
|
+
path, language_hint, hints
|
|
573
|
+
)
|
|
574
|
+
if segments:
|
|
575
|
+
self.last_diagnostics["completedChunks"] = 1
|
|
576
|
+
if progress:
|
|
577
|
+
progress(1, 1)
|
|
578
|
+
return segments, language
|
|
579
|
+
raise EmptyTranscriptionError(
|
|
580
|
+
f"{fallback_name} returned no usable speech segments"
|
|
581
|
+
)
|
|
582
|
+
except BaseException as fallback_error:
|
|
583
|
+
raise RuntimeError(
|
|
584
|
+
f"primary transcription failed ({type(primary_error).__name__}: "
|
|
585
|
+
f"{primary_error}); fallback failed ({type(fallback_error).__name__}: "
|
|
586
|
+
f"{fallback_error})"
|
|
587
|
+
) from fallback_error
|
|
588
|
+
|
|
589
|
+
assert primary_error is not None
|
|
590
|
+
raise primary_error
|
|
591
|
+
|
|
592
|
+
def _transcribe_primary(
|
|
593
|
+
self,
|
|
594
|
+
provider_name: str,
|
|
595
|
+
path: Path,
|
|
596
|
+
language_hint: str | None,
|
|
597
|
+
hints: list[str] | None,
|
|
598
|
+
source_duration_secs: float | None,
|
|
599
|
+
progress: TranscriptionProgress | None,
|
|
600
|
+
) -> tuple[list[dict[str, Any]], str | None]:
|
|
601
|
+
provider = self._get_provider(provider_name)
|
|
602
|
+
chunk_secs = max(
|
|
603
|
+
30,
|
|
604
|
+
min(900, int(os.getenv("LARKUP_VIDEO_TRANSCRIPTION_CHUNK_SECONDS", "180"))),
|
|
605
|
+
)
|
|
606
|
+
duration_secs = max(0.0, float(source_duration_secs or 0))
|
|
607
|
+
if provider_name == "whisper" or duration_secs <= chunk_secs:
|
|
608
|
+
self.last_diagnostics["chunkCount"] = 1
|
|
609
|
+
if progress:
|
|
610
|
+
progress(0, 1)
|
|
611
|
+
result = provider.transcribe(path, language_hint, hints)
|
|
612
|
+
self.last_diagnostics["completedChunks"] = 1
|
|
613
|
+
if progress:
|
|
614
|
+
progress(1, 1)
|
|
615
|
+
return result
|
|
616
|
+
|
|
617
|
+
concurrency = max(
|
|
618
|
+
1,
|
|
619
|
+
min(8, int(os.getenv("LARKUP_VIDEO_TRANSCRIPTION_CONCURRENCY", "3"))),
|
|
620
|
+
)
|
|
621
|
+
with tempfile.TemporaryDirectory(prefix="larkup-speech-chunks-") as temporary:
|
|
622
|
+
chunks = _materialize_hosted_audio_chunks(
|
|
623
|
+
path, duration_secs, chunk_secs, Path(temporary)
|
|
624
|
+
)
|
|
625
|
+
self.last_diagnostics["chunkCount"] = len(chunks)
|
|
626
|
+
if progress:
|
|
627
|
+
progress(0, len(chunks))
|
|
628
|
+
completed: list[tuple[float, list[dict[str, Any]], str | None]] = []
|
|
629
|
+
errors: list[BaseException] = []
|
|
630
|
+
processed_chunks = 0
|
|
631
|
+
with ThreadPoolExecutor(max_workers=min(concurrency, len(chunks))) as pool:
|
|
632
|
+
futures = {
|
|
633
|
+
pool.submit(
|
|
634
|
+
provider.transcribe, chunk, language_hint, hints
|
|
635
|
+
): offset
|
|
636
|
+
for offset, chunk in chunks
|
|
637
|
+
}
|
|
638
|
+
for future in as_completed(futures):
|
|
639
|
+
try:
|
|
640
|
+
segments, language = future.result()
|
|
641
|
+
completed.append((futures[future], segments, language))
|
|
642
|
+
except BaseException as error:
|
|
643
|
+
errors.append(error)
|
|
644
|
+
finally:
|
|
645
|
+
processed_chunks += 1
|
|
646
|
+
self.last_diagnostics["completedChunks"] = processed_chunks
|
|
647
|
+
if progress:
|
|
648
|
+
progress(processed_chunks, len(chunks))
|
|
649
|
+
self.last_diagnostics["chunkErrors"] = len(errors)
|
|
650
|
+
minimum_successes = max(1, round(len(chunks) * 0.8))
|
|
651
|
+
if len(completed) < minimum_successes:
|
|
652
|
+
detail = errors[0] if errors else "no chunk completed"
|
|
653
|
+
raise RuntimeError(
|
|
654
|
+
f"hosted transcription completed {len(completed)}/{len(chunks)} chunks: {detail}"
|
|
655
|
+
)
|
|
656
|
+
merged = [
|
|
657
|
+
segment
|
|
658
|
+
for offset, segments, _language in sorted(completed)
|
|
659
|
+
for segment in _rebase_segments(segments, offset)
|
|
660
|
+
]
|
|
661
|
+
languages = Counter(
|
|
662
|
+
language for _offset, _segments, language in completed if language
|
|
663
|
+
)
|
|
664
|
+
detected = languages.most_common(1)[0][0] if languages else None
|
|
665
|
+
return merged, detected or (
|
|
666
|
+
language_hint if language_hint != "auto" else None
|
|
667
|
+
)
|