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})")