github-actions[bot] commited on
Commit
bacf22b
·
1 Parent(s): c7f658d

Auto-deploy from GitHub: 1ff9b762b3692b3fb6428694bf175fc54879d453

Browse files
app/api/routes.py CHANGED
@@ -5,11 +5,12 @@ import uuid
5
  import json
6
  import asyncio
7
  import aiofiles
8
- from app.core.config import settings
9
  from custom_logger import logger_config as logger
10
  from app.db import crud
11
  from app.services.worker import start_worker, is_worker_running
12
- from app.services.streaming import StreamingSTT, ALLOWED_MODELS
 
13
 
14
  router = APIRouter()
15
 
@@ -26,13 +27,27 @@ async def index():
26
  return FileResponse('index.html')
27
 
28
  @router.post("/api/tasks/upload")
29
- async def upload_task(audio: UploadFile = File(...), hide_from_ui: str = Form("")):
 
 
 
 
 
 
30
  if not audio.filename:
31
  raise HTTPException(status_code=400, detail="No file selected")
32
-
33
  if not allowed_file(audio.filename):
34
  raise HTTPException(status_code=400, detail="Invalid file type")
35
-
 
 
 
 
 
 
 
 
36
  task_id = str(uuid.uuid4())
37
  filename = audio.filename
38
  filepath = os.path.join(settings.UPLOAD_FOLDER, f"{task_id}_{filename}")
@@ -48,17 +63,29 @@ async def upload_task(audio: UploadFile = File(...), hide_from_ui: str = Form(""
48
 
49
  hide_from_ui_val = 1 if hide_from_ui.lower() in ['true', '1'] else 0
50
 
51
- await crud.insert_task(task_id, filename, filepath, 'not_started', hide_from_ui_val)
52
-
 
53
  await start_worker()
54
-
55
  return JSONResponse(status_code=201, content={
56
  'id': task_id,
57
  'filename': filename,
58
  'status': 'not_started',
 
 
 
59
  'message': 'File uploaded successfully'
60
  })
61
 
 
 
 
 
 
 
 
 
62
  @router.get("/api/tasks")
63
  async def get_tasks():
64
  rows, queue_ids, processing_count, avg_time = await crud.get_all_tasks()
@@ -101,6 +128,9 @@ async def get_task(task_id: str):
101
  'filename': row['filename'],
102
  'status': row['status'],
103
  'result': row['result'],
 
 
 
104
  'created_at': row['created_at'],
105
  'processed_at': row['processed_at'],
106
  'progress': row['progress'] or 0,
@@ -139,23 +169,61 @@ async def websocket_transcribe(websocket: WebSocket):
139
  try:
140
  config_text = await websocket.receive_text()
141
  config = json.loads(config_text)
 
142
  model_name = config.get("model", "base")
 
 
 
143
 
144
- if model_name not in ALLOWED_MODELS:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  await websocket.send_json(
146
  {"type": "error", "message": f"Unsupported model: {model_name}"}
147
  )
148
  await websocket.close()
149
  return
150
 
 
 
 
 
 
 
 
 
 
151
  loop = asyncio.get_event_loop()
152
  # WhisperModel construction downloads/loads weights synchronously; run it
153
  # in an executor so it doesn't block the event loop (and every other
154
  # connection) while the model loads.
 
155
  stt = await loop.run_in_executor(
156
- None, lambda: StreamingSTT(model_name=model_name, device="cpu")
 
 
 
 
157
  )
158
- await websocket.send_json({"type": "ready", "sample_rate": stt.sample_rate})
 
 
 
 
 
 
159
 
160
  async def bg_process():
161
  while True:
 
5
  import json
6
  import asyncio
7
  import aiofiles
8
+ from app.core.config import settings, get_device
9
  from custom_logger import logger_config as logger
10
  from app.db import crud
11
  from app.services.worker import start_worker, is_worker_running
12
+ from app.services.streaming import create_streaming_stt, ALLOWED_MODELS
13
+ from stt.registry import describe, supports, streaming_engines, get_engine
14
 
15
  router = APIRouter()
16
 
 
27
  return FileResponse('index.html')
28
 
29
  @router.post("/api/tasks/upload")
30
+ async def upload_task(
31
+ audio: UploadFile = File(...),
32
+ hide_from_ui: str = Form(""),
33
+ language: str = Form("auto"),
34
+ task: str = Form("transcribe"),
35
+ engine: str = Form(""),
36
+ ):
37
  if not audio.filename:
38
  raise HTTPException(status_code=400, detail="No file selected")
39
+
40
  if not allowed_file(audio.filename):
41
  raise HTTPException(status_code=400, detail="Invalid file type")
42
+
43
+ language = (language or "auto").strip().lower()
44
+ task = (task or "transcribe").strip().lower()
45
+ engine = (engine or "").strip() or settings.STT_MODEL_NAME
46
+
47
+ ok, reason = supports(engine, language, task)
48
+ if not ok:
49
+ raise HTTPException(status_code=400, detail=reason)
50
+
51
  task_id = str(uuid.uuid4())
52
  filename = audio.filename
53
  filepath = os.path.join(settings.UPLOAD_FOLDER, f"{task_id}_{filename}")
 
63
 
64
  hide_from_ui_val = 1 if hide_from_ui.lower() in ['true', '1'] else 0
65
 
66
+ await crud.insert_task(task_id, filename, filepath, 'not_started', hide_from_ui_val,
67
+ language=language, task=task, engine=engine)
68
+
69
  await start_worker()
70
+
71
  return JSONResponse(status_code=201, content={
72
  'id': task_id,
73
  'filename': filename,
74
  'status': 'not_started',
75
+ 'language': language,
76
+ 'task': task,
77
+ 'engine': engine,
78
  'message': 'File uploaded successfully'
79
  })
80
 
81
+ @router.get("/api/engines")
82
+ async def get_engines():
83
+ """Capabilities of every engine, so the UI can build its own dropdowns."""
84
+ data = describe()
85
+ data["default_engine"] = settings.STT_MODEL_NAME
86
+ data["streaming_engines"] = sorted(streaming_engines())
87
+ return data
88
+
89
  @router.get("/api/tasks")
90
  async def get_tasks():
91
  rows, queue_ids, processing_count, avg_time = await crud.get_all_tasks()
 
128
  'filename': row['filename'],
129
  'status': row['status'],
130
  'result': row['result'],
131
+ 'language': row['language'],
132
+ 'task': row['task'],
133
+ 'engine': row['engine'],
134
  'created_at': row['created_at'],
135
  'processed_at': row['processed_at'],
136
  'progress': row['progress'] or 0,
 
169
  try:
170
  config_text = await websocket.receive_text()
171
  config = json.loads(config_text)
172
+ engine = config.get("engine", "fasterwhispher")
173
  model_name = config.get("model", "base")
174
+ language = config.get("language", "en") or "auto"
175
+ # Named stt_task, not task: `task` below is the asyncio background task.
176
+ stt_task = config.get("task", "transcribe")
177
 
178
+ ok, reason = supports(engine, language, stt_task)
179
+ if not ok:
180
+ await websocket.send_json({"type": "error", "message": reason})
181
+ await websocket.close()
182
+ return
183
+
184
+ if not get_engine(engine)["streaming"]:
185
+ await websocket.send_json(
186
+ {"type": "error", "message": f"Engine '{engine}' does not support live streaming"}
187
+ )
188
+ await websocket.close()
189
+ return
190
+
191
+ # Whisper sizes are only meaningful for the whisper engine.
192
+ if engine == "fasterwhispher" and model_name not in ALLOWED_MODELS:
193
  await websocket.send_json(
194
  {"type": "error", "message": f"Unsupported model: {model_name}"}
195
  )
196
  await websocket.close()
197
  return
198
 
199
+ # tiny/base translate Hindi poorly enough to look broken; warn rather
200
+ # than silently returning garbage English.
201
+ if engine == "fasterwhispher" and stt_task == "translate" and model_name in ("tiny", "base"):
202
+ await websocket.send_json({
203
+ "type": "warning",
204
+ "message": f"'{model_name}' gives weak translation quality; "
205
+ "prefer small or larger, or switch to the indic engine.",
206
+ })
207
+
208
  loop = asyncio.get_event_loop()
209
  # WhisperModel construction downloads/loads weights synchronously; run it
210
  # in an executor so it doesn't block the event loop (and every other
211
  # connection) while the model loads.
212
+ device = get_device()
213
  stt = await loop.run_in_executor(
214
+ None,
215
+ lambda: create_streaming_stt(
216
+ engine, model_name=model_name, device=device,
217
+ language=language, task=stt_task,
218
+ ),
219
  )
220
+ await websocket.send_json({
221
+ "type": "ready",
222
+ "sample_rate": stt.sample_rate,
223
+ "engine": engine,
224
+ "language": language,
225
+ "task": stt_task,
226
+ })
227
 
228
  async def bg_process():
229
  while True:
app/core/config.py CHANGED
@@ -1,5 +1,22 @@
1
  import os
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  class Config:
4
  PORT = int(os.environ.get('PORT', 7860))
5
  UPLOAD_FOLDER = 'uploads'
@@ -10,6 +27,12 @@ class Config:
10
  CWD = "./"
11
  PYTHON_PATH = "stt-transcribe"
12
  STT_MODEL_NAME = "parakeet"
 
 
 
 
 
 
13
  POLL_INTERVAL = 3
14
 
15
  settings = Config()
 
1
  import os
2
 
3
+ # Ensure CUDA 12 libraries are findable for faster-whisper/CTranslate2
4
+ _cuda12_path = "/usr/local/lib/ollama/cuda_v12"
5
+ if os.path.isdir(_cuda12_path):
6
+ cur = os.environ.get("LD_LIBRARY_PATH", "")
7
+ if _cuda12_path not in cur:
8
+ os.environ["LD_LIBRARY_PATH"] = f"{_cuda12_path}:{cur}" if cur else _cuda12_path
9
+
10
+ # get_device comes from the stt package (already a dependency) rather than
11
+ # jebin_lib: importing jebin_lib pulls in hf_bucket_client, which imports
12
+ # huggingface_hub.sync_bucket - removed from huggingface_hub - so a single
13
+ # broken transitive import would take down server startup.
14
+ try:
15
+ from jebin_lib.utils import get_device
16
+ except ImportError:
17
+ from stt.common import get_device
18
+
19
+
20
  class Config:
21
  PORT = int(os.environ.get('PORT', 7860))
22
  UPLOAD_FOLDER = 'uploads'
 
27
  CWD = "./"
28
  PYTHON_PATH = "stt-transcribe"
29
  STT_MODEL_NAME = "parakeet"
30
+ # Parakeet is English-only, so non-English audio and X->English translation
31
+ # are routed to a whisper engine instead.
32
+ STT_MULTILINGUAL_MODEL_NAME = os.environ.get('STT_MULTILINGUAL_MODEL', 'fasterwhispher')
33
+ # faster-whisper checkpoint size used for those jobs; "base" translates
34
+ # Hindi poorly, so default to something usable.
35
+ STT_WHISPER_MODEL = os.environ.get('STT_WHISPER_MODEL', 'small')
36
  POLL_INTERVAL = 3
37
 
38
  settings = Config()
app/db/crud.py CHANGED
@@ -4,12 +4,14 @@ from datetime import datetime, timedelta
4
  from app.core.config import settings
5
  from custom_logger import logger_config as logger
6
 
7
- async def insert_task(task_id: str, filename: str, filepath: str, status: str, hide_from_ui: int):
 
8
  async with aiosqlite.connect(settings.DATABASE_FILE) as db:
9
- await db.execute('''INSERT INTO tasks
10
- (id, filename, filepath, status, created_at, hide_from_ui)
11
- VALUES (?, ?, ?, ?, ?, ?)''',
12
- (task_id, filename, filepath, status, datetime.now().isoformat(), hide_from_ui))
 
13
  await db.commit()
14
  logger.debug(f"Inserted task {filename} (ID: {task_id}) into database.")
15
 
 
4
  from app.core.config import settings
5
  from custom_logger import logger_config as logger
6
 
7
+ async def insert_task(task_id: str, filename: str, filepath: str, status: str, hide_from_ui: int,
8
+ language: str = None, task: str = 'transcribe', engine: str = None):
9
  async with aiosqlite.connect(settings.DATABASE_FILE) as db:
10
+ await db.execute('''INSERT INTO tasks
11
+ (id, filename, filepath, status, created_at, hide_from_ui, language, task, engine)
12
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)''',
13
+ (task_id, filename, filepath, status, datetime.now().isoformat(), hide_from_ui,
14
+ language, task, engine))
15
  await db.commit()
16
  logger.debug(f"Inserted task {filename} (ID: {task_id}) into database.")
17
 
app/db/database.py CHANGED
@@ -15,7 +15,21 @@ async def init_db():
15
  processed_at TEXT,
16
  progress INTEGER DEFAULT 0,
17
  progress_text TEXT,
18
- hide_from_ui INTEGER DEFAULT 0)'''
 
 
 
19
  )
 
 
 
 
 
 
 
 
 
 
 
20
  await db.commit()
21
  logger.info("Database initialized successfully.")
 
15
  processed_at TEXT,
16
  progress INTEGER DEFAULT 0,
17
  progress_text TEXT,
18
+ hide_from_ui INTEGER DEFAULT 0,
19
+ language TEXT,
20
+ task TEXT DEFAULT 'transcribe',
21
+ engine TEXT)'''
22
  )
23
+
24
+ # Migrate databases created before language/task existed.
25
+ async with db.execute("PRAGMA table_info(tasks)") as cursor:
26
+ existing = {row[1] for row in await cursor.fetchall()}
27
+ if 'language' not in existing:
28
+ await db.execute("ALTER TABLE tasks ADD COLUMN language TEXT")
29
+ if 'task' not in existing:
30
+ await db.execute("ALTER TABLE tasks ADD COLUMN task TEXT DEFAULT 'transcribe'")
31
+ if 'engine' not in existing:
32
+ await db.execute("ALTER TABLE tasks ADD COLUMN engine TEXT")
33
+
34
  await db.commit()
35
  logger.info("Database initialized successfully.")
app/services/streaming.py CHANGED
@@ -9,6 +9,18 @@ from custom_logger import logger_config as logger
9
  # is ever attempted (an unknown name would otherwise trigger a download).
10
  ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
11
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  # Whisper weights are large, so identical (model, device) pairs are shared across
13
  # connections instead of loaded once per connection (4 concurrent large-v3
14
  # models would otherwise OOM). faster-whisper's WhisperModel is safe to use from
@@ -103,12 +115,20 @@ class _HypothesisBuffer:
103
 
104
 
105
  class StreamingSTT:
106
- def __init__(self, model_name="base", device="cpu", sample_rate=16000):
 
107
  if model_name not in ALLOWED_MODELS:
108
  raise ValueError(f"Unsupported model: {model_name}")
 
 
 
 
109
  self.sample_rate = sample_rate
110
  self.model_name = model_name
111
  self.device = device
 
 
 
112
  self.buffer = np.array([], dtype=np.float32)
113
  self.processed_until = 0
114
  # Absolute sample index of buffer[0]. Grows as _trim_buffer() discards
@@ -154,7 +174,12 @@ class StreamingSTT:
154
  def _transcribe_words(self, audio, time_offset):
155
  """Transcribe audio, returning [(start, end, text), ...] in absolute time."""
156
  segments, _ = self.model.transcribe(
157
- audio, beam_size=1, vad_filter=True, language="en", word_timestamps=True
 
 
 
 
 
158
  )
159
  words = []
160
  for seg in segments:
@@ -235,3 +260,196 @@ class StreamingSTT:
235
  self.buffer = np.array([], dtype=np.float32)
236
  import gc
237
  gc.collect()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  # is ever attempted (an unknown name would otherwise trigger a download).
10
  ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
11
 
12
+ # Capabilities come from the shared registry so the CLI, backend and UI can
13
+ # never disagree about what an engine accepts.
14
+ from stt.registry import ENGINES, ALL_TASKS
15
+
16
+ # None/"auto" lets whisper detect the language, but an explicit code is more
17
+ # reliable on short streaming windows.
18
+ ALLOWED_LANGUAGES = set(ENGINES["fasterwhispher"]["languages"])
19
+
20
+ # "transcribe" keeps the source language; "translate" is whisper's built-in
21
+ # X -> English translation (so Hindi speech comes back as English text).
22
+ ALLOWED_TASKS = set(ALL_TASKS)
23
+
24
  # Whisper weights are large, so identical (model, device) pairs are shared across
25
  # connections instead of loaded once per connection (4 concurrent large-v3
26
  # models would otherwise OOM). faster-whisper's WhisperModel is safe to use from
 
115
 
116
 
117
  class StreamingSTT:
118
+ def __init__(self, model_name="base", device="cpu", sample_rate=16000,
119
+ language="en", task="transcribe"):
120
  if model_name not in ALLOWED_MODELS:
121
  raise ValueError(f"Unsupported model: {model_name}")
122
+ if language is not None and language not in ALLOWED_LANGUAGES:
123
+ raise ValueError(f"Unsupported language: {language}")
124
+ if task not in ALLOWED_TASKS:
125
+ raise ValueError(f"Unsupported task: {task}")
126
  self.sample_rate = sample_rate
127
  self.model_name = model_name
128
  self.device = device
129
+ # None => let whisper detect the language per window.
130
+ self.language = None if language == "auto" else language
131
+ self.task = task
132
  self.buffer = np.array([], dtype=np.float32)
133
  self.processed_until = 0
134
  # Absolute sample index of buffer[0]. Grows as _trim_buffer() discards
 
174
  def _transcribe_words(self, audio, time_offset):
175
  """Transcribe audio, returning [(start, end, text), ...] in absolute time."""
176
  segments, _ = self.model.transcribe(
177
+ audio,
178
+ beam_size=1,
179
+ vad_filter=True,
180
+ language=self.language,
181
+ task=self.task,
182
+ word_timestamps=True,
183
  )
184
  words = []
185
  for seg in segments:
 
260
  self.buffer = np.array([], dtype=np.float32)
261
  import gc
262
  gc.collect()
263
+
264
+
265
+ class IndicStreamingSTT:
266
+ """Streaming wrapper around the AI4Bharat cascade.
267
+
268
+ Whisper emits English tokens directly, so its output can be committed
269
+ word-by-word. This cascade cannot: Hindi is verb-final, so a partial clause
270
+ translates to something that the rest of the clause would invalidate.
271
+ Instead, source-script text is shown as *tentative* the moment it is
272
+ recognised, and a clause is only translated and *committed* once it is
273
+ closed - detected either by trailing silence or by hitting the max window.
274
+ """
275
+
276
+ min_chunk = 2.0 # seconds of audio before any tentative output
277
+ max_window = 8.0 # force a commit rather than growing without bound
278
+ silence_tail = 0.6 # seconds of quiet that count as a clause boundary
279
+ silence_rms = 0.012 # amplitude below which a frame is considered silent
280
+
281
+ def __init__(self, model_name=None, device="cpu", sample_rate=16000,
282
+ language="hi", task="translate"):
283
+ if language in (None, "", "auto"):
284
+ raise ValueError("The indic engine needs an explicit language (no auto-detect)")
285
+
286
+ from stt.indic import LANG_TAGS
287
+ if language not in LANG_TAGS:
288
+ raise ValueError(f"Unsupported language for the indic engine: {language}")
289
+ if task not in ALLOWED_TASKS:
290
+ raise ValueError(f"Unsupported task: {task}")
291
+
292
+ self.sample_rate = sample_rate
293
+ self.device = device
294
+ self.language = language
295
+ self.task = task
296
+ self.src_tag = LANG_TAGS[language]
297
+ self.buffer = np.array([], dtype=np.float32)
298
+ self.processed_until = 0
299
+ self.buffer_start = 0
300
+ self.is_finalized = False
301
+ self._incoming = queue.Queue()
302
+
303
+ # Reuse the batch engine so the model-loading and translation logic
304
+ # lives in exactly one place.
305
+ from stt.indic import IndicSTTProcessor
306
+ self.engine = IndicSTTProcessor(device=device)
307
+ self.engine.language = language
308
+ self.engine.task = task
309
+ if task == "translate":
310
+ self.engine._load_translator()
311
+
312
+ def add_audio(self, audio_bytes: bytes):
313
+ audio_float = (
314
+ np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
315
+ )
316
+ self._incoming.put(audio_float)
317
+
318
+ def _drain_incoming(self):
319
+ chunks = []
320
+ while True:
321
+ try:
322
+ chunks.append(self._incoming.get_nowait())
323
+ except queue.Empty:
324
+ break
325
+ if chunks:
326
+ self.buffer = np.append(self.buffer, np.concatenate(chunks))
327
+
328
+ def _trim_buffer(self):
329
+ max_buffered = self.sample_rate * 120
330
+ if len(self.buffer) > max_buffered:
331
+ trim_to = self.processed_until - self.sample_rate * 5
332
+ if trim_to > 0:
333
+ self.buffer = self.buffer[trim_to:]
334
+ self.processed_until -= trim_to
335
+ self.buffer_start += trim_to
336
+
337
+ def _ends_in_silence(self, audio):
338
+ tail = audio[-int(self.silence_tail * self.sample_rate):]
339
+ if len(tail) < self.silence_tail * self.sample_rate:
340
+ return False
341
+ return float(np.sqrt(np.mean(tail ** 2))) < self.silence_rms
342
+
343
+ def _transcribe(self, audio):
344
+ import torch
345
+ wav = torch.from_numpy(audio).unsqueeze(0).to(self.device)
346
+ with torch.inference_mode():
347
+ text = self.engine.model(wav, self.language, "ctc")
348
+ if isinstance(text, (list, tuple)):
349
+ text = " ".join(str(t) for t in text)
350
+ return (text or "").strip()
351
+
352
+ def _to_english(self, text):
353
+ if self.task != "translate" or not text:
354
+ return text
355
+ sentences = self.engine._split_sentences(text)
356
+ return " ".join(self.engine._translate(sentences, self.src_tag))
357
+
358
+ def _commit(self, text, span_samples):
359
+ start = (self.buffer_start + self.processed_until) / self.sample_rate
360
+ end = start + span_samples / self.sample_rate
361
+ self.processed_until += span_samples
362
+ self._trim_buffer()
363
+ return {
364
+ "start": round(start, 2),
365
+ "end": round(end, 2),
366
+ "text": self._to_english(text),
367
+ }
368
+
369
+ def process(self):
370
+ if self.is_finalized:
371
+ return None
372
+
373
+ self._drain_incoming()
374
+
375
+ unprocessed = self.buffer[self.processed_until:]
376
+ if len(unprocessed) < self.min_chunk * self.sample_rate:
377
+ return None
378
+
379
+ try:
380
+ text = self._transcribe(unprocessed)
381
+ except Exception as e:
382
+ logger.error(f"[IndicStreamingSTT] process error: {e}")
383
+ return None
384
+
385
+ at_boundary = (
386
+ len(unprocessed) >= self.max_window * self.sample_rate
387
+ or self._ends_in_silence(unprocessed)
388
+ )
389
+
390
+ if not text:
391
+ # Nothing recognised; drop silent audio so the window doesn't grow.
392
+ if at_boundary:
393
+ self.processed_until += len(unprocessed)
394
+ self._trim_buffer()
395
+ return {"commit": None, "tentative": ""}
396
+
397
+ if at_boundary:
398
+ try:
399
+ return {"commit": self._commit(text, len(unprocessed)), "tentative": ""}
400
+ except Exception as e:
401
+ logger.error(f"[IndicStreamingSTT] translate error: {e}")
402
+ return None
403
+
404
+ # Clause still open - show the source script so there is live feedback.
405
+ return {"commit": None, "tentative": text}
406
+
407
+ def flush(self):
408
+ if self.is_finalized:
409
+ return None
410
+ self.is_finalized = True
411
+
412
+ self._drain_incoming()
413
+
414
+ unprocessed = self.buffer[self.processed_until:]
415
+ if len(unprocessed) < 0.3 * self.sample_rate:
416
+ return {"commit": None}
417
+
418
+ try:
419
+ text = self._transcribe(unprocessed)
420
+ if not text:
421
+ return {"commit": None}
422
+ return {"commit": self._commit(text, len(unprocessed))}
423
+ except Exception as e:
424
+ logger.error(f"[IndicStreamingSTT] flush error: {e}")
425
+ return {"commit": None}
426
+
427
+ def cleanup(self):
428
+ if self.engine is not None:
429
+ self.engine.cleanup()
430
+ self.engine = None
431
+ self.buffer = np.array([], dtype=np.float32)
432
+ import gc
433
+ gc.collect()
434
+
435
+
436
+ def create_streaming_stt(engine, model_name=None, device="cpu", language="en",
437
+ task="transcribe"):
438
+ """Build the streaming implementation for a registry engine name."""
439
+ from stt.registry import get_engine
440
+
441
+ spec = get_engine(engine)
442
+ if not spec["streaming"]:
443
+ raise ValueError(f"Engine '{engine}' does not support live streaming")
444
+
445
+ if engine == "fasterwhispher":
446
+ return StreamingSTT(
447
+ model_name=model_name or spec["default_size"] or "base",
448
+ device=device,
449
+ language=language,
450
+ task=task,
451
+ )
452
+ if engine == "indic":
453
+ return IndicStreamingSTT(device=device, language=language, task=task)
454
+
455
+ raise ValueError(f"Engine '{engine}' is marked streaming but has no implementation")
app/services/worker.py CHANGED
@@ -40,16 +40,39 @@ async def worker_loop():
40
  task_id = row['id']
41
  filepath = row['filepath']
42
  filename = row['filename']
43
-
44
- logger.info(f"\n{'='*60}\nProcessing: {filename}\nID: {task_id}\n{'='*60}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  await crud.update_status(task_id, 'processing')
47
 
48
  try:
49
  await crud.update_progress(task_id, 5, "Starting STT...")
50
 
51
- command = f"cd {settings.CWD} && {settings.PYTHON_PATH} --input {shlex.quote(os.path.abspath(filepath))} --model {settings.STT_MODEL_NAME}"
52
-
 
 
 
 
 
 
53
  logger.debug(f"Executing command: {command}")
54
 
55
  process = await asyncio.create_subprocess_shell(
@@ -60,8 +83,7 @@ async def worker_loop():
60
  env={
61
  **os.environ,
62
  'PYTHONUNBUFFERED': '1',
63
- 'CUDA_LAUNCH_BLOCKING': '1',
64
- 'USE_CPU_IF_POSSIBLE': 'true'
65
  }
66
  )
67
 
 
40
  task_id = row['id']
41
  filepath = row['filepath']
42
  filename = row['filename']
43
+ language = row['language'] or 'auto'
44
+ stt_task = row['task'] or 'transcribe'
45
+ engine = row['engine']
46
+
47
+ if not engine:
48
+ # Legacy rows (queued before the engine column existed) have
49
+ # no choice recorded, so fall back to capability routing:
50
+ # parakeet is English-only, anything else needs whisper.
51
+ needs_multilingual = stt_task == 'translate' or language not in ('auto', 'en')
52
+ engine = (
53
+ settings.STT_MULTILINGUAL_MODEL_NAME
54
+ if needs_multilingual
55
+ else settings.STT_MODEL_NAME
56
+ )
57
+
58
+ logger.info(
59
+ f"\n{'='*60}\nProcessing: {filename}\nID: {task_id}\n"
60
+ f"Engine: {engine} | language: {language} | task: {stt_task}\n{'='*60}"
61
+ )
62
 
63
  await crud.update_status(task_id, 'processing')
64
 
65
  try:
66
  await crud.update_progress(task_id, 5, "Starting STT...")
67
 
68
+ command = (
69
+ f"cd {settings.CWD} && {settings.PYTHON_PATH} "
70
+ f"--input {shlex.quote(os.path.abspath(filepath))} "
71
+ f"--model {shlex.quote(engine)} "
72
+ f"--language {shlex.quote(language)} "
73
+ f"--task {shlex.quote(stt_task)}"
74
+ )
75
+
76
  logger.debug(f"Executing command: {command}")
77
 
78
  process = await asyncio.create_subprocess_shell(
 
83
  env={
84
  **os.environ,
85
  'PYTHONUNBUFFERED': '1',
86
+ 'STT_WHISPER_MODEL': settings.STT_WHISPER_MODEL,
 
87
  }
88
  )
89
 
index.html CHANGED
@@ -534,6 +534,18 @@
534
  class="px-4 py-1.5 bg-surface border-[2px] border-crayon-red text-crayon-red text-lg font-bold rounded-xl organic-shape -rotate-1">FLAC</span>
535
  </div>
536
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
537
  <input type="file" id="fileInput" hidden accept="audio/*">
538
  </div>
539
  </section>
@@ -655,14 +667,14 @@
655
  </div>
656
  <div class="modal-body">
657
  <div class="flex items-center gap-6 mb-6 flex-wrap">
658
- <label class="text-label-sm text-[#4b5563] font-bold">Model:</label>
659
- <select id="liveModelSelect">
660
- <option value="tiny">tiny</option>
661
- <option value="base" selected>base</option>
662
- <option value="small">small</option>
663
- <option value="medium">medium</option>
664
- <option value="large-v3">large-v3</option>
665
- </select>
666
  <button id="startLiveBtn"
667
  class="flex items-center gap-2 px-8 py-3 bg-crayon-green text-white text-headline-md font-bold crayon-button border-crayon-green shadow-md">
668
  <span class="material-symbols-outlined text-3xl">play_arrow</span>
@@ -764,6 +776,9 @@
764
 
765
  const formData = new FormData();
766
  formData.append('audio', file);
 
 
 
767
 
768
  try {
769
  const res = await fetch(`${API_BASE}/tasks/upload`, { method: 'POST', body: formData });
@@ -915,6 +930,84 @@
915
  const liveBtn = document.getElementById('liveBtn');
916
  const liveModal = document.getElementById('liveModal');
917
  const liveModelSelect = document.getElementById('liveModelSelect');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
918
  const startLiveBtn = document.getElementById('startLiveBtn');
919
  const stopLiveBtn = document.getElementById('stopLiveBtn');
920
  const liveTranscript = document.getElementById('liveTranscript');
@@ -1021,14 +1114,17 @@
1021
  isLiveStreaming = true;
1022
 
1023
  // Connect WebSocket
 
1024
  const model = liveModelSelect.value;
 
 
1025
  const wsUrl = `${window.location.origin.replace(/^http/, 'ws')}/ws/transcribe`;
1026
 
1027
  liveWs = new WebSocket(wsUrl);
1028
  liveWs.binaryType = 'arraybuffer';
1029
 
1030
  liveWs.onopen = () => {
1031
- liveWs.send(JSON.stringify({ model }));
1032
  };
1033
 
1034
  liveWs.onmessage = (e) => {
@@ -1055,6 +1151,9 @@
1055
  renderLiveTranscript();
1056
  } else if (msg.type === 'done') {
1057
  teardownLiveStreaming();
 
 
 
1058
  } else if (msg.type === 'error') {
1059
  liveTranscript.innerText = '❌ Error: ' + msg.message;
1060
  }
@@ -1075,6 +1174,9 @@
1075
  stopLiveBtn.classList.remove('hidden');
1076
  liveStatusBadge.classList.remove('hidden');
1077
  liveModelSelect.disabled = true;
 
 
 
1078
 
1079
  } catch (err) {
1080
  liveTranscript.innerText = '❌ Error: ' + err.message;
@@ -1098,6 +1200,9 @@
1098
  stopLiveBtn.disabled = false;
1099
  liveStatusBadge.classList.add('hidden');
1100
  liveModelSelect.disabled = false;
 
 
 
1101
  }
1102
 
1103
  // Immediate teardown: closes the socket, stops the mic, resets the UI.
@@ -1138,6 +1243,7 @@
1138
  stopLiveBtn.onclick = stopLiveStreaming;
1139
 
1140
  // --- Lifecycle ---
 
1141
  loadTasks();
1142
  setInterval(loadTasks, 5000);
1143
  setInterval(checkHealth, 10000);
 
534
  class="px-4 py-1.5 bg-surface border-[2px] border-crayon-red text-crayon-red text-lg font-bold rounded-xl organic-shape -rotate-1">FLAC</span>
535
  </div>
536
  </div>
537
+ <!-- stopPropagation so using these controls doesn't open the file picker -->
538
+ <div class="flex items-center justify-center gap-4 mt-6 flex-wrap"
539
+ onclick="event.stopPropagation()">
540
+ <label class="text-label-sm text-[#4b5563] font-bold">Model:</label>
541
+ <select id="uploadEngineSelect"></select>
542
+ <label class="text-label-sm text-[#4b5563] font-bold">Spoken:</label>
543
+ <select id="uploadLanguageSelect"></select>
544
+ <label class="text-label-sm text-[#4b5563] font-bold">Output:</label>
545
+ <select id="uploadTaskSelect"></select>
546
+ </div>
547
+ <p id="uploadEngineNote" class="text-label-sm text-[#6b7280] text-center mt-2"
548
+ onclick="event.stopPropagation()"></p>
549
  <input type="file" id="fileInput" hidden accept="audio/*">
550
  </div>
551
  </section>
 
667
  </div>
668
  <div class="modal-body">
669
  <div class="flex items-center gap-6 mb-6 flex-wrap">
670
+ <label class="text-label-sm text-[#4b5563] font-bold">Engine:</label>
671
+ <select id="liveEngineSelect"></select>
672
+ <label id="liveModelLabel" class="text-label-sm text-[#4b5563] font-bold">Size:</label>
673
+ <select id="liveModelSelect"></select>
674
+ <label class="text-label-sm text-[#4b5563] font-bold">Speaking:</label>
675
+ <select id="liveLanguageSelect"></select>
676
+ <label class="text-label-sm text-[#4b5563] font-bold">Output:</label>
677
+ <select id="liveTaskSelect"></select>
678
  <button id="startLiveBtn"
679
  class="flex items-center gap-2 px-8 py-3 bg-crayon-green text-white text-headline-md font-bold crayon-button border-crayon-green shadow-md">
680
  <span class="material-symbols-outlined text-3xl">play_arrow</span>
 
776
 
777
  const formData = new FormData();
778
  formData.append('audio', file);
779
+ formData.append('engine', uploadEngineSelect.value);
780
+ formData.append('language', uploadLanguageSelect.value);
781
+ formData.append('task', uploadTaskSelect.value);
782
 
783
  try {
784
  const res = await fetch(`${API_BASE}/tasks/upload`, { method: 'POST', body: formData });
 
930
  const liveBtn = document.getElementById('liveBtn');
931
  const liveModal = document.getElementById('liveModal');
932
  const liveModelSelect = document.getElementById('liveModelSelect');
933
+ const liveModelLabel = document.getElementById('liveModelLabel');
934
+ const liveEngineSelect = document.getElementById('liveEngineSelect');
935
+ const liveLanguageSelect = document.getElementById('liveLanguageSelect');
936
+ const liveTaskSelect = document.getElementById('liveTaskSelect');
937
+ const uploadEngineSelect = document.getElementById('uploadEngineSelect');
938
+ const uploadLanguageSelect = document.getElementById('uploadLanguageSelect');
939
+ const uploadTaskSelect = document.getElementById('uploadTaskSelect');
940
+ const uploadEngineNote = document.getElementById('uploadEngineNote');
941
+
942
+ // --- Engine registry (server is the single source of truth) ---
943
+ // Every dropdown below is built from /api/engines, so adding an engine
944
+ // server-side makes it selectable here with no changes to this file.
945
+ let REGISTRY = null;
946
+
947
+ const TASK_LABELS = { transcribe: 'Same language', translate: 'English' };
948
+
949
+ function fillSelect(select, items, selected) {
950
+ select.innerHTML = items.map(i =>
951
+ `<option value="${i.value}"${i.value === selected ? ' selected' : ''}>${i.label}</option>`
952
+ ).join('');
953
+ }
954
+
955
+ // Repopulate language/task/size to match the chosen engine, keeping the
956
+ // user's current picks whenever the new engine still supports them.
957
+ function syncEngineOptions(engineSelect, languageSelect, taskSelect, sizeSelect, note) {
958
+ if (!REGISTRY) return;
959
+ const spec = REGISTRY.engines[engineSelect.value];
960
+ if (!spec) return;
961
+
962
+ const prevLang = languageSelect.value;
963
+ const prevTask = taskSelect.value;
964
+
965
+ const langs = spec.languages.map(l => ({ value: l.code, label: l.name }));
966
+ fillSelect(languageSelect, langs,
967
+ langs.some(l => l.value === prevLang) ? prevLang : langs[0].value);
968
+
969
+ const tasks = spec.tasks.map(t => ({ value: t, label: TASK_LABELS[t] || t }));
970
+ fillSelect(taskSelect, tasks,
971
+ tasks.some(t => t.value === prevTask) ? prevTask : tasks[0].value);
972
+
973
+ if (sizeSelect) {
974
+ const hasSizes = spec.sizes && spec.sizes.length > 0;
975
+ const sizes = (spec.sizes || []).map(s => ({ value: s, label: s }));
976
+ if (hasSizes) fillSelect(sizeSelect, sizes, spec.default_size || sizes[0].value);
977
+ sizeSelect.style.display = hasSizes ? '' : 'none';
978
+ if (liveModelLabel && sizeSelect === liveModelSelect) {
979
+ liveModelLabel.style.display = hasSizes ? '' : 'none';
980
+ }
981
+ }
982
+
983
+ if (note) note.textContent = spec.notes || '';
984
+ }
985
+
986
+ async function loadRegistry() {
987
+ try {
988
+ const res = await fetch(`${API_BASE}/engines`);
989
+ REGISTRY = await res.json();
990
+ } catch (err) {
991
+ console.error('Could not load engine registry:', err);
992
+ return;
993
+ }
994
+
995
+ const all = Object.entries(REGISTRY.engines)
996
+ .map(([value, spec]) => ({ value, label: spec.label }));
997
+ fillSelect(uploadEngineSelect, all, REGISTRY.default_engine);
998
+ syncEngineOptions(uploadEngineSelect, uploadLanguageSelect, uploadTaskSelect,
999
+ null, uploadEngineNote);
1000
+ uploadEngineSelect.onchange = () => syncEngineOptions(
1001
+ uploadEngineSelect, uploadLanguageSelect, uploadTaskSelect, null, uploadEngineNote);
1002
+
1003
+ // Live only offers engines that actually implement streaming.
1004
+ const streaming = all.filter(e => REGISTRY.streaming_engines.includes(e.value));
1005
+ fillSelect(liveEngineSelect, streaming, streaming[0] && streaming[0].value);
1006
+ syncEngineOptions(liveEngineSelect, liveLanguageSelect, liveTaskSelect,
1007
+ liveModelSelect, null);
1008
+ liveEngineSelect.onchange = () => syncEngineOptions(
1009
+ liveEngineSelect, liveLanguageSelect, liveTaskSelect, liveModelSelect, null);
1010
+ }
1011
  const startLiveBtn = document.getElementById('startLiveBtn');
1012
  const stopLiveBtn = document.getElementById('stopLiveBtn');
1013
  const liveTranscript = document.getElementById('liveTranscript');
 
1114
  isLiveStreaming = true;
1115
 
1116
  // Connect WebSocket
1117
+ const engine = liveEngineSelect.value;
1118
  const model = liveModelSelect.value;
1119
+ const language = liveLanguageSelect.value;
1120
+ const task = liveTaskSelect.value;
1121
  const wsUrl = `${window.location.origin.replace(/^http/, 'ws')}/ws/transcribe`;
1122
 
1123
  liveWs = new WebSocket(wsUrl);
1124
  liveWs.binaryType = 'arraybuffer';
1125
 
1126
  liveWs.onopen = () => {
1127
+ liveWs.send(JSON.stringify({ engine, model, language, task }));
1128
  };
1129
 
1130
  liveWs.onmessage = (e) => {
 
1151
  renderLiveTranscript();
1152
  } else if (msg.type === 'done') {
1153
  teardownLiveStreaming();
1154
+ } else if (msg.type === 'warning') {
1155
+ console.warn('[live-stt]', msg.message);
1156
+ liveTranscript.innerText = '⚠️ ' + msg.message;
1157
  } else if (msg.type === 'error') {
1158
  liveTranscript.innerText = '❌ Error: ' + msg.message;
1159
  }
 
1174
  stopLiveBtn.classList.remove('hidden');
1175
  liveStatusBadge.classList.remove('hidden');
1176
  liveModelSelect.disabled = true;
1177
+ liveEngineSelect.disabled = true;
1178
+ liveLanguageSelect.disabled = true;
1179
+ liveTaskSelect.disabled = true;
1180
 
1181
  } catch (err) {
1182
  liveTranscript.innerText = '❌ Error: ' + err.message;
 
1200
  stopLiveBtn.disabled = false;
1201
  liveStatusBadge.classList.add('hidden');
1202
  liveModelSelect.disabled = false;
1203
+ liveEngineSelect.disabled = false;
1204
+ liveLanguageSelect.disabled = false;
1205
+ liveTaskSelect.disabled = false;
1206
  }
1207
 
1208
  // Immediate teardown: closes the socket, stops the mic, resets the UI.
 
1243
  stopLiveBtn.onclick = stopLiveStreaming;
1244
 
1245
  // --- Lifecycle ---
1246
+ loadRegistry();
1247
  loadTasks();
1248
  setInterval(loadTasks, 5000);
1249
  setInterval(checkHealth, 10000);
pyproject.toml CHANGED
@@ -17,6 +17,7 @@ dependencies = [
17
  "custom_logger @ git+https://github.com/jebin2/custom_logger.git",
18
  "stt-runner[parakeet] @ git+https://github.com/jebin2/STT.git",
19
  "faster-whisper",
 
20
  ]
21
 
22
  [project.scripts]
 
17
  "custom_logger @ git+https://github.com/jebin2/custom_logger.git",
18
  "stt-runner[parakeet] @ git+https://github.com/jebin2/STT.git",
19
  "faster-whisper",
20
+ "jebin-lib[all] @ git+https://github.com/jebin2/lib.git",
21
  ]
22
 
23
  [project.scripts]