github-actions[bot] commited on
Commit ·
bacf22b
1
Parent(s): c7f658d
Auto-deploy from GitHub: 1ff9b762b3692b3fb6428694bf175fc54879d453
Browse files- app/api/routes.py +79 -11
- app/core/config.py +23 -0
- app/db/crud.py +7 -5
- app/db/database.py +15 -1
- app/services/streaming.py +220 -2
- app/services/worker.py +28 -6
- index.html +115 -9
- pyproject.toml +1 -0
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
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
)
|
| 158 |
-
await websocket.send_json({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
'
|
| 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">
|
| 659 |
-
<select id="
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 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]
|