File size: 10,803 Bytes
9faedb3 437df61 9faedb3 437df61 bacf22b 437df61 bacf22b 437df61 f392205 9faedb3 437df61 bacf22b 437df61 bacf22b 437df61 bacf22b 437df61 bacf22b 437df61 bacf22b 437df61 bacf22b 437df61 bacf22b 437df61 bacf22b 437df61 9faedb3 437df61 9faedb3 bacf22b 9faedb3 bacf22b 9faedb3 bacf22b f392205 bacf22b f392205 bacf22b f392205 bacf22b f392205 bacf22b 9faedb3 c7f658d 9faedb3 c7f658d 9faedb3 f392205 9faedb3 f392205 9faedb3 f392205 9faedb3 c7f658d f392205 9faedb3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 | from fastapi import APIRouter, UploadFile, File, Form, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse
import os
import uuid
import json
import asyncio
import aiofiles
from app.core.config import settings, get_device
from custom_logger import logger_config as logger
from app.db import crud
from app.services.worker import start_worker, is_worker_running
from app.services.streaming import create_streaming_stt, ALLOWED_MODELS
from stt.registry import describe, supports, streaming_engines, get_engine
router = APIRouter()
# Per-process connection counter. With multiple uvicorn workers each process has
# its own counter, so the cap is per-worker, not global.
ACTIVE_WS_CONNECTIONS = 0
MAX_WS_CONNECTIONS = 4
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in settings.ALLOWED_EXTENSIONS
@router.get("/")
async def index():
return FileResponse('index.html')
@router.post("/api/tasks/upload")
async def upload_task(
audio: UploadFile = File(...),
hide_from_ui: str = Form(""),
language: str = Form("auto"),
task: str = Form("transcribe"),
engine: str = Form(""),
):
if not audio.filename:
raise HTTPException(status_code=400, detail="No file selected")
if not allowed_file(audio.filename):
raise HTTPException(status_code=400, detail="Invalid file type")
language = (language or "auto").strip().lower()
task = (task or "transcribe").strip().lower()
engine = (engine or "").strip() or settings.STT_MODEL_NAME
ok, reason = supports(engine, language, task)
if not ok:
raise HTTPException(status_code=400, detail=reason)
task_id = str(uuid.uuid4())
filename = audio.filename
filepath = os.path.join(settings.UPLOAD_FOLDER, f"{task_id}_{filename}")
try:
async with aiofiles.open(filepath, 'wb') as out_file:
content = await audio.read()
await out_file.write(content)
logger.info(f"File uploaded successfully: {filename} -> {filepath}")
except Exception as e:
logger.error(f"Error saving uploaded file {filename}: {e}")
raise HTTPException(status_code=500, detail="Could not save file")
hide_from_ui_val = 1 if hide_from_ui.lower() in ['true', '1'] else 0
await crud.insert_task(task_id, filename, filepath, 'not_started', hide_from_ui_val,
language=language, task=task, engine=engine)
await start_worker()
return JSONResponse(status_code=201, content={
'id': task_id,
'filename': filename,
'status': 'not_started',
'language': language,
'task': task,
'engine': engine,
'message': 'File uploaded successfully'
})
@router.get("/api/engines")
async def get_engines():
"""Capabilities of every engine, so the UI can build its own dropdowns."""
data = describe()
data["default_engine"] = settings.STT_MODEL_NAME
data["streaming_engines"] = sorted(streaming_engines())
return data
@router.get("/api/tasks")
async def get_tasks():
rows, queue_ids, processing_count, avg_time = await crud.get_all_tasks()
tasks = []
for row in rows:
queue_position = None
estimated_start_seconds = None
if row['status'] == 'not_started' and row['id'] in queue_ids:
queue_position = queue_ids.index(row['id']) + 1
tasks_ahead = queue_position - 1 + processing_count
estimated_start_seconds = round(tasks_ahead * avg_time)
tasks.append({
'id': row['id'],
'filename': row['filename'],
'status': row['status'],
'result': "HIDDEN_IN_LIST_VIEW",
'created_at': row['created_at'],
'processed_at': row['processed_at'],
'progress': row['progress'] or 0,
'progress_text': row['progress_text'],
'queue_position': queue_position,
'estimated_start_seconds': estimated_start_seconds
})
return tasks
@router.get("/api/tasks/{task_id}")
async def get_task(task_id: str):
result = await crud.get_task_by_id(task_id)
if not result:
raise HTTPException(status_code=404, detail="Task not found")
row, queue_position, estimated_start_seconds = result
return {
'id': row['id'],
'filename': row['filename'],
'status': row['status'],
'result': row['result'],
'language': row['language'],
'task': row['task'],
'engine': row['engine'],
'created_at': row['created_at'],
'processed_at': row['processed_at'],
'progress': row['progress'] or 0,
'progress_text': row['progress_text'],
'queue_position': queue_position,
'estimated_start_seconds': estimated_start_seconds
}
@router.get("/health")
async def health():
return {
'status': 'healthy',
'service': 'stt-backend',
'worker_running': is_worker_running(),
'ws_connections': ACTIVE_WS_CONNECTIONS,
}
@router.websocket("/ws/transcribe")
async def websocket_transcribe(websocket: WebSocket):
global ACTIVE_WS_CONNECTIONS
if ACTIVE_WS_CONNECTIONS >= MAX_WS_CONNECTIONS:
await websocket.accept()
await websocket.send_json({"type": "error", "message": "Server busy, try again later"})
await websocket.close()
return
await websocket.accept()
ACTIVE_WS_CONNECTIONS += 1
logger.info(f"WebSocket connected ({ACTIVE_WS_CONNECTIONS}/{MAX_WS_CONNECTIONS})")
stt = None
task = None
connected = True
try:
config_text = await websocket.receive_text()
config = json.loads(config_text)
engine = config.get("engine", "fasterwhispher")
model_name = config.get("model", "base")
language = config.get("language", "en") or "auto"
# Named stt_task, not task: `task` below is the asyncio background task.
stt_task = config.get("task", "transcribe")
ok, reason = supports(engine, language, stt_task)
if not ok:
await websocket.send_json({"type": "error", "message": reason})
await websocket.close()
return
if not get_engine(engine)["streaming"]:
await websocket.send_json(
{"type": "error", "message": f"Engine '{engine}' does not support live streaming"}
)
await websocket.close()
return
# Whisper sizes are only meaningful for the whisper engine.
if engine == "fasterwhispher" and model_name not in ALLOWED_MODELS:
await websocket.send_json(
{"type": "error", "message": f"Unsupported model: {model_name}"}
)
await websocket.close()
return
# tiny/base translate Hindi poorly enough to look broken; warn rather
# than silently returning garbage English.
if engine == "fasterwhispher" and stt_task == "translate" and model_name in ("tiny", "base"):
await websocket.send_json({
"type": "warning",
"message": f"'{model_name}' gives weak translation quality; "
"prefer small or larger, or switch to the indic engine.",
})
loop = asyncio.get_event_loop()
# WhisperModel construction downloads/loads weights synchronously; run it
# in an executor so it doesn't block the event loop (and every other
# connection) while the model loads.
device = get_device()
stt = await loop.run_in_executor(
None,
lambda: create_streaming_stt(
engine, model_name=model_name, device=device,
language=language, task=stt_task,
),
)
await websocket.send_json({
"type": "ready",
"sample_rate": stt.sample_rate,
"engine": engine,
"language": language,
"task": stt_task,
})
async def bg_process():
while True:
await asyncio.sleep(1.0)
try:
result = await loop.run_in_executor(None, stt.process)
if not result:
continue
try:
if result["commit"]:
await websocket.send_json({"type": "commit", **result["commit"]})
await websocket.send_json(
{"type": "tentative", "text": result["tentative"]}
)
except Exception:
return
except asyncio.CancelledError:
return
except Exception as e:
logger.error(f"bg_process error: {e}")
return
task = asyncio.create_task(bg_process())
while True:
message = await websocket.receive()
if message.get("type") == "websocket.disconnect":
raise WebSocketDisconnect()
if message.get("bytes") is not None:
stt.add_audio(message["bytes"])
elif message.get("text") is not None:
try:
msg = json.loads(message["text"])
except (ValueError, TypeError):
continue
if msg.get("type") == "stop":
# Client asked to finalize: stop reading and let the finally
# block flush the trailing audio while still connected.
break
except WebSocketDisconnect:
connected = False
logger.info("WebSocket disconnected")
except Exception as e:
connected = False
logger.error(f"WebSocket error: {e}")
finally:
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"bg_process cleanup error: {e}")
if stt:
if connected:
try:
remaining = stt.flush()
if remaining and remaining["commit"]:
await websocket.send_json(
{"type": "commit", **remaining["commit"], "is_final": True}
)
await websocket.send_json({"type": "done"})
await websocket.close()
except Exception:
pass
stt.cleanup()
ACTIVE_WS_CONNECTIONS -= 1
logger.info(f"WebSocket closed ({ACTIVE_WS_CONNECTIONS}/{MAX_WS_CONNECTIONS})")
|