# 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", "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="fr", task="transcribe", beam_size=5, best_of=5, condition_on_previous_text=True, initial_prompt="Transcription en français. L'utilisateur parle français." ) 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")