app.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import tempfile
  2. import re
  3. import unicodedata
  4. import os
  5. import numpy as np
  6. import time
  7. import logging
  8. import os
  9. import sys
  10. import asyncio
  11. from threading import Lock
  12. from fastapi import FastAPI, UploadFile, File, WebSocket, WebSocketDisconnect, HTTPException
  13. from faster_whisper import WhisperModel
  14. def env_bool(name: str, default: bool = False) -> bool:
  15. return os.getenv(name, str(default)).lower() in ("1", "true", "yes", "on")
  16. LOG_LEVEL = os.getenv("WHISPER_LOG_LEVEL", "INFO").upper()
  17. VAD_ENABLED = env_bool("WHISPER_VAD", True)
  18. MODEL_SIZE = str(os.getenv("MODEL_SIZE", "small").lower())
  19. COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float32").strip().lower()
  20. BEAM_SIZE = int(os.getenv("WHISPER_BEAM_SIZE", "1"))
  21. HOTWORDS = os.getenv("WHISPER_HOTWORDS", "Satoko").strip()
  22. if BEAM_SIZE < 1:
  23. raise ValueError("WHISPER_BEAM_SIZE doit être supérieur ou égal à 1")
  24. MIN_AUDIO_SEC = float(os.getenv("MIN_AUDIO_SEC", "2.0"))
  25. MAX_AUDIO_SEC = float(os.getenv("MAX_AUDIO_SEC", "10.0"))
  26. logging.basicConfig(
  27. level=getattr(logging, LOG_LEVEL, logging.INFO),
  28. format="%(asctime)s [%(levelname)s] [%(name)s] %(message)s",
  29. stream=sys.stdout,
  30. force=True
  31. )
  32. logger = logging.getLogger("whisper-ws")
  33. logger.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
  34. logger.info(f"Log level = {LOG_LEVEL}")
  35. logger.info(f"VAD enabled = {VAD_ENABLED}")
  36. logger.info("Model = %s, compute type = %s", MODEL_SIZE, COMPUTE_TYPE)
  37. logger.info("Beam size = %d", BEAM_SIZE)
  38. app = FastAPI()
  39. # =========================
  40. # Whisper model (shared)
  41. # =========================
  42. model = WhisperModel(MODEL_SIZE, device="cuda", compute_type=COMPUTE_TYPE)
  43. # Vérifier les kernels CUDA avant de déclarer le service prêt.
  44. list(model.transcribe(np.zeros(16000, dtype=np.float32), language="fr", beam_size=BEAM_SIZE)[0])
  45. model_lock = Lock()
  46. def is_subtitle_artifact(text):
  47. normalized = unicodedata.normalize("NFKD", text.lower())
  48. normalized = "".join(c for c in normalized if not unicodedata.combining(c))
  49. normalized = re.sub(r"[^a-z0-9]+", " ", normalized).strip()
  50. return bool(re.fullmatch(
  51. r"(?:sous titrage|sous titres?) (?:st ?501|mfp|www amara org|amara org)"
  52. r"|sous titres? (?:realises? par la communaute )?d? ?amara org",
  53. normalized,
  54. ))
  55. def transcribe_audio(audio):
  56. # Consommer le générateur sous le verrou, hors de la boucle asyncio.
  57. with model_lock:
  58. segments, info = model.transcribe(
  59. audio, language="fr", vad_filter=VAD_ENABLED,
  60. beam_size=BEAM_SIZE, condition_on_previous_text=False,
  61. hotwords=HOTWORDS or None,
  62. )
  63. texts = []
  64. for segment in segments:
  65. if is_subtitle_artifact(segment.text):
  66. logger.info("Segment parasite de sous-titrage ignoré")
  67. else:
  68. texts.append(segment.text)
  69. return " ".join(texts).strip(), info.language
  70. @app.get("/healthz")
  71. def healthz():
  72. return {"status": "ready", "model": MODEL_SIZE, "compute_type": COMPUTE_TYPE, "beam_size": BEAM_SIZE}
  73. # =========================
  74. # REST API (Swagger / tests)
  75. # =========================
  76. @app.post("/transcribe")
  77. def transcribe(file: UploadFile = File(...)):
  78. # Discord envoie au maximum 20 secondes de WAV stéréo 48 kHz.
  79. with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
  80. tmp_path = tmp.name
  81. try:
  82. data = file.file.read(8 * 1024 * 1024 + 1)
  83. if len(data) > 8 * 1024 * 1024:
  84. raise HTTPException(413, "Audio trop volumineux")
  85. tmp.write(data)
  86. tmp.flush()
  87. text, language = transcribe_audio(tmp_path)
  88. return {"text": text, "language": language}
  89. finally:
  90. os.remove(tmp_path)
  91. # =========================
  92. # WebSocket STT (streaming)
  93. # =========================
  94. SAMPLE_RATE = 16000
  95. @app.websocket("/ws/transcribe")
  96. async def ws_transcribe(ws: WebSocket):
  97. await ws.accept()
  98. logger.info("WebSocket connecté !")
  99. try:
  100. while True:
  101. data = await ws.receive_bytes()
  102. logger.debug(f"Chunk reçu: {len(data)} bytes")
  103. pcm = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
  104. energy = float(np.abs(pcm).mean())
  105. logger.debug(f"Énergie audio = {energy:.6f}")
  106. # silence gate léger
  107. if np.abs(pcm).mean() < 0.00005:
  108. logger.debug("Chunk silencieux ignoré")
  109. continue
  110. duration = len(pcm) / SAMPLE_RATE
  111. logger.info(f"Transcription chunk {duration:.2f}s")
  112. text, _ = await asyncio.to_thread(transcribe_audio, pcm)
  113. logger.info(f"Texte: '{text}'")
  114. if text:
  115. await ws.send_json({
  116. "type": "final",
  117. "text": text
  118. })
  119. except WebSocketDisconnect:
  120. logger.info("WebSocket déconnecté")
  121. except Exception:
  122. logger.exception("Erreur WebSocket")
  123. await ws.close()