- Switched from large-v3 (1.56B) to distil-large-v3 (~756M) — 2x faster transcription - Increased CPU threads from 4 to 8 (Ryzen 5800X3D has 8c/16t) - French language quality preserved (~5% accuracy tradeoff for 2x speed) - Expected pipeline latency: 4-5s total (down from ~15s)
195 lines
6.2 KiB
Python
195 lines
6.2 KiB
Python
# Effi-STT - Speech-to-Text pour Effi
|
|
# API OpenAI-compatible avec faster-whisper
|
|
# Inclut déchargement automatique après inactivité
|
|
|
|
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
|
|
from fastapi.responses import JSONResponse
|
|
import uvicorn
|
|
from faster_whisper import WhisperModel
|
|
import tempfile
|
|
import os
|
|
from typing import Optional
|
|
import logging
|
|
import threading
|
|
import time
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = FastAPI(
|
|
title="Effi-STT",
|
|
description="Speech-to-Text API compatible OpenAI pour Effi",
|
|
version="1.1.0"
|
|
)
|
|
|
|
MODEL_SIZE = os.getenv("MODEL_SIZE", "distil-large-v3")
|
|
DEVICE = os.getenv("DEVICE", "cpu")
|
|
COMPUTE_TYPE = os.getenv("COMPUTE_TYPE", "int8")
|
|
LANGUAGE = os.getenv("LANGUAGE", "fr")
|
|
INACTIVITY_TIMEOUT = int(os.getenv("INACTIVITY_TIMEOUT", "300"))
|
|
PORT = int(os.getenv("PORT", "8200"))
|
|
HOST = os.getenv("HOST", "0.0.0.0")
|
|
|
|
model = None
|
|
model_loaded = False
|
|
model_lock = threading.Lock()
|
|
last_request_time = time.time()
|
|
unload_timer = None
|
|
|
|
def load_model():
|
|
global model, model_loaded
|
|
with model_lock:
|
|
if not model_loaded:
|
|
logger.info(f"Chargement du modèle Whisper: {MODEL_SIZE}")
|
|
logger.info(f" Device: {DEVICE}, Compute type: {COMPUTE_TYPE}")
|
|
model = WhisperModel(
|
|
MODEL_SIZE,
|
|
device=DEVICE,
|
|
compute_type=COMPUTE_TYPE,
|
|
cpu_threads=int(os.getenv("CPU_THREADS", "8"))
|
|
)
|
|
model_loaded = True
|
|
logger.info("✅ Modèle chargé avec succès!")
|
|
|
|
def unload_model():
|
|
global model, model_loaded
|
|
with model_lock:
|
|
if model_loaded:
|
|
logger.info("🕐 Inactivité détectée - Déchargement du modèle Whisper...")
|
|
del model
|
|
model = None
|
|
model_loaded = False
|
|
import gc
|
|
gc.collect()
|
|
logger.info("✅ Modèle déchargé, mémoire libérée")
|
|
|
|
def reset_unload_timer():
|
|
global last_request_time, unload_timer
|
|
last_request_time = time.time()
|
|
if unload_timer is not None:
|
|
unload_timer.cancel()
|
|
if model_loaded:
|
|
timer = threading.Timer(INACTIVITY_TIMEOUT, unload_model)
|
|
timer.daemon = True
|
|
timer.start()
|
|
unload_timer = timer
|
|
logger.debug(f"⏱️ Timer déchargement programmé dans {INACTIVITY_TIMEOUT}s")
|
|
|
|
def check_and_load_model():
|
|
global model_loaded
|
|
if not model_loaded:
|
|
logger.info("📥 Chargement du modèle Whisper à la demande...")
|
|
load_model()
|
|
reset_unload_timer()
|
|
|
|
@app.on_event("startup")
|
|
async def startup():
|
|
logger.info(f"🚀 Démarrage effi-stt v1.1.0 sur http://{HOST}:{PORT}")
|
|
logger.info(f" Timeout d'inactivité: {INACTIVITY_TIMEOUT}s")
|
|
load_model()
|
|
reset_unload_timer()
|
|
|
|
@app.on_event("shutdown")
|
|
async def shutdown():
|
|
if unload_timer:
|
|
unload_timer.cancel()
|
|
unload_model()
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
check_and_load_model()
|
|
return {
|
|
"status": "healthy",
|
|
"model": MODEL_SIZE,
|
|
"model_loaded": model_loaded,
|
|
"inactivity_timeout": INACTIVITY_TIMEOUT
|
|
}
|
|
|
|
@app.post("/v1/audio/transcriptions")
|
|
async def transcribe(
|
|
file: UploadFile = File(..., description="Fichier audio à transcrire"),
|
|
model_param: Optional[str] = Form(None, alias="model"),
|
|
language: Optional[str] = Form(LANGUAGE, description="Code langue (fr, en, auto)"),
|
|
prompt: Optional[str] = Form(None, description="Prompt optionnel"),
|
|
response_format: Optional[str] = Form("json", description="Format de réponse")
|
|
):
|
|
try:
|
|
check_and_load_model()
|
|
reset_unload_timer()
|
|
logger.info(f"📝 Transcription: lang={language}, file={file.filename}")
|
|
|
|
suffix = os.path.splitext(file.filename)[1] or ".mp3"
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
|
content = await file.read()
|
|
tmp.write(content)
|
|
tmp_path = tmp.name
|
|
logger.info(f" Fichier: {tmp_path} ({len(content)} bytes)")
|
|
|
|
try:
|
|
with model_lock:
|
|
segments, info = model.transcribe(
|
|
tmp_path,
|
|
language=language if language != "auto" else None,
|
|
task="transcribe",
|
|
beam_size=5,
|
|
best_of=5,
|
|
condition_on_previous_text=True
|
|
)
|
|
|
|
text_parts = []
|
|
for segment in segments:
|
|
text_parts.append(segment.text)
|
|
|
|
full_text = " ".join(text_parts).strip()
|
|
logger.info(f"✅ Transcription: '{full_text[:50]}...'")
|
|
logger.info(f" Langue: {info.language} ({info.language_probability:.2f})")
|
|
|
|
if response_format == "text":
|
|
return full_text
|
|
else:
|
|
return JSONResponse(content={"text": full_text})
|
|
|
|
finally:
|
|
os.unlink(tmp_path)
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Erreur: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.post("/unload")
|
|
async def unload():
|
|
unload_model()
|
|
return {"status": "unloaded"}
|
|
|
|
@app.post("/reload")
|
|
async def reload():
|
|
load_model()
|
|
reset_unload_timer()
|
|
return {"status": "loaded"}
|
|
|
|
@app.get("/v1/models")
|
|
async def list_models():
|
|
return {
|
|
"data": [{"id": "whisper-1", "object": "model", "created": 1677610602, "owned_by": "openai", "permission": [], "root": "whisper-1", "parent": None}],
|
|
"object": "list"
|
|
}
|
|
|
|
@app.get("/stats")
|
|
async def stats():
|
|
return {
|
|
"model_loaded": model_loaded,
|
|
"last_request_age_seconds": int(time.time() - last_request_time),
|
|
"inactivity_timeout": INACTIVITY_TIMEOUT
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
logger.info(f"🚀 Serveur sur http://{HOST}:{PORT}")
|
|
logger.info(f" Endpoints:")
|
|
logger.info(f" - GET /health")
|
|
logger.info(f" - POST /v1/audio/transcriptions")
|
|
logger.info(f" - GET /v1/models")
|
|
logger.info(f" - POST /unload (déchargement manuel)")
|
|
logger.info(f" - POST /reload (rechargement manuel)")
|
|
logger.info(f" - GET /stats")
|
|
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
|