|
@@ -17,25 +17,28 @@ logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
class STTSink(voice_recv.AudioSink):
|
|
class STTSink(voice_recv.AudioSink):
|
|
|
- def __init__(self, on_phrase, stt_url):
|
|
|
|
|
|
|
+ def __init__(self, on_phrase, stt_url, on_transcription=None):
|
|
|
super().__init__()
|
|
super().__init__()
|
|
|
self.on_phrase = on_phrase
|
|
self.on_phrase = on_phrase
|
|
|
|
|
+ self.on_transcription = on_transcription
|
|
|
|
|
+ self.pending = []
|
|
|
|
|
+ self.pending_event = asyncio.Event()
|
|
|
self.stt_url = stt_url
|
|
self.stt_url = stt_url
|
|
|
self.loop = asyncio.get_running_loop()
|
|
self.loop = asyncio.get_running_loop()
|
|
|
self.lock = threading.Lock()
|
|
self.lock = threading.Lock()
|
|
|
self.buffers = {}
|
|
self.buffers = {}
|
|
|
- self.busy = False
|
|
|
|
|
- self.ignored_speakers = {}
|
|
|
|
|
self.decoders = {}
|
|
self.decoders = {}
|
|
|
self.closed = False
|
|
self.closed = False
|
|
|
self.last_error = 0
|
|
self.last_error = 0
|
|
|
self.stats_lock = threading.Lock()
|
|
self.stats_lock = threading.Lock()
|
|
|
self.stats = Counter(audio=0, rtcp=0, pcm=0, audible=0, phrases=0, stt=0)
|
|
self.stats = Counter(audio=0, rtcp=0, pcm=0, audible=0, phrases=0, stt=0)
|
|
|
self.last_diagnostic = time.monotonic()
|
|
self.last_diagnostic = time.monotonic()
|
|
|
- # Uniquement le transfert vers le worker, aucune phrase en attente.
|
|
|
|
|
- self.queue = asyncio.Queue(maxsize=1)
|
|
|
|
|
|
|
+ # File audio bornée si Whisper ne suit plus le rythme du vocal.
|
|
|
|
|
+ self.queue = asyncio.Queue(maxsize=32)
|
|
|
self.flush_task = self.loop.create_task(self.flush_phrases())
|
|
self.flush_task = self.loop.create_task(self.flush_phrases())
|
|
|
self.worker_task = self.loop.create_task(self.transcribe_phrases())
|
|
self.worker_task = self.loop.create_task(self.transcribe_phrases())
|
|
|
|
|
+ self.reply_task = self.loop.create_task(self.respond_batches())
|
|
|
|
|
+ self.reply_task.add_done_callback(self.task_finished)
|
|
|
self.flush_task.add_done_callback(self.task_finished)
|
|
self.flush_task.add_done_callback(self.task_finished)
|
|
|
self.worker_task.add_done_callback(self.task_finished)
|
|
self.worker_task.add_done_callback(self.task_finished)
|
|
|
|
|
|
|
@@ -119,13 +122,6 @@ class STTSink(voice_recv.AudioSink):
|
|
|
"""Appelé sous le verrou ; PCM Discord = 48 kHz, stéréo, 16 bits."""
|
|
"""Appelé sous le verrou ; PCM Discord = 48 kHz, stéréo, 16 bits."""
|
|
|
audible = audioop.rms(pcm, 2) >= 200
|
|
audible = audioop.rms(pcm, 2) >= 200
|
|
|
self.count('audible' if audible else 'below_threshold')
|
|
self.count('audible' if audible else 'below_threshold')
|
|
|
- last_ignored = self.ignored_speakers.get(user.id)
|
|
|
|
|
- if self.busy or (last_ignored is not None and now - last_ignored < 0.8):
|
|
|
|
|
- if audible:
|
|
|
|
|
- self.ignored_speakers[user.id] = now
|
|
|
|
|
- self.count('busy_ignored')
|
|
|
|
|
- return
|
|
|
|
|
- self.ignored_speakers.pop(user.id, None)
|
|
|
|
|
if user.id not in self.buffers:
|
|
if user.id not in self.buffers:
|
|
|
if not audible:
|
|
if not audible:
|
|
|
return
|
|
return
|
|
@@ -146,19 +142,11 @@ class STTSink(voice_recv.AudioSink):
|
|
|
self.last_diagnostic = now
|
|
self.last_diagnostic = now
|
|
|
ready = []
|
|
ready = []
|
|
|
with self.lock:
|
|
with self.lock:
|
|
|
- if self.busy:
|
|
|
|
|
- continue
|
|
|
|
|
for user_id, (user, pcm, last_voice) in list(self.buffers.items()):
|
|
for user_id, (user, pcm, last_voice) in list(self.buffers.items()):
|
|
|
if now - last_voice >= 0.8 or len(pcm) >= 48000 * 4 * 20:
|
|
if now - last_voice >= 0.8 or len(pcm) >= 48000 * 4 * 20:
|
|
|
del self.buffers[user_id]
|
|
del self.buffers[user_id]
|
|
|
if len(pcm) >= 48000 * 4 * 0.25:
|
|
if len(pcm) >= 48000 * 4 * 0.25:
|
|
|
ready.append((user, bytes(pcm)))
|
|
ready.append((user, bytes(pcm)))
|
|
|
- self.busy = True
|
|
|
|
|
- # Oublier aussi les phrases déjà commencées par les autres.
|
|
|
|
|
- self.ignored_speakers.update(
|
|
|
|
|
- {uid: buffer[2] for uid, buffer in self.buffers.items()})
|
|
|
|
|
- self.buffers.clear()
|
|
|
|
|
- break
|
|
|
|
|
else:
|
|
else:
|
|
|
self.count('too_short')
|
|
self.count('too_short')
|
|
|
for item in ready:
|
|
for item in ready:
|
|
@@ -172,7 +160,7 @@ class STTSink(voice_recv.AudioSink):
|
|
|
logger.warning("File vocale pleine : une phrase a été ignorée")
|
|
logger.warning("File vocale pleine : une phrase a été ignorée")
|
|
|
|
|
|
|
|
async def transcribe_phrases(self):
|
|
async def transcribe_phrases(self):
|
|
|
- # Rester occupé jusqu'à la fin de la transcription ET de la réponse.
|
|
|
|
|
|
|
+ # La transcription continue pendant les réponses du LLM.
|
|
|
async with httpx.AsyncClient(timeout=60) as http:
|
|
async with httpx.AsyncClient(timeout=60) as http:
|
|
|
while not self.closed:
|
|
while not self.closed:
|
|
|
user, pcm = await self.queue.get()
|
|
user, pcm = await self.queue.get()
|
|
@@ -198,8 +186,14 @@ class STTSink(voice_recv.AudioSink):
|
|
|
if not text:
|
|
if not text:
|
|
|
self.count('empty_transcription')
|
|
self.count('empty_transcription')
|
|
|
if text and not self.closed:
|
|
if text and not self.closed:
|
|
|
- stage = 'réponse'
|
|
|
|
|
- await self.on_phrase(user, text)
|
|
|
|
|
|
|
+ try:
|
|
|
|
|
+ stage = 'affichage'
|
|
|
|
|
+ if self.on_transcription:
|
|
|
|
|
+ await self.on_transcription(user, text)
|
|
|
|
|
+ finally:
|
|
|
|
|
+ if not self.closed:
|
|
|
|
|
+ self.pending.append((user, text))
|
|
|
|
|
+ self.pending_event.set()
|
|
|
except asyncio.CancelledError:
|
|
except asyncio.CancelledError:
|
|
|
raise
|
|
raise
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
@@ -208,17 +202,28 @@ class STTSink(voice_recv.AudioSink):
|
|
|
logger.warning("Traitement vocal impossible : étape=%s erreur=%s HTTP=%s durée=%.2fs",
|
|
logger.warning("Traitement vocal impossible : étape=%s erreur=%s HTTP=%s durée=%.2fs",
|
|
|
stage, type(exc).__name__, status, time.monotonic() - started)
|
|
stage, type(exc).__name__, status, time.monotonic() - started)
|
|
|
finally:
|
|
finally:
|
|
|
- with self.lock:
|
|
|
|
|
- self.busy = False
|
|
|
|
|
self.queue.task_done()
|
|
self.queue.task_done()
|
|
|
|
|
|
|
|
|
|
+ async def respond_batches(self):
|
|
|
|
|
+ while not self.closed:
|
|
|
|
|
+ await self.pending_event.wait()
|
|
|
|
|
+ self.pending_event.clear()
|
|
|
|
|
+ batch, self.pending = self.pending, []
|
|
|
|
|
+ if not batch:
|
|
|
|
|
+ continue
|
|
|
|
|
+ try:
|
|
|
|
|
+ await self.on_phrase(batch)
|
|
|
|
|
+ except asyncio.CancelledError:
|
|
|
|
|
+ raise
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ logger.warning("Réponse au lot vocal impossible : %s", type(exc).__name__)
|
|
|
|
|
+
|
|
|
@voice_recv.AudioSink.listener()
|
|
@voice_recv.AudioSink.listener()
|
|
|
def on_voice_member_disconnect(self, member, ssrc):
|
|
def on_voice_member_disconnect(self, member, ssrc):
|
|
|
if member:
|
|
if member:
|
|
|
with self.lock:
|
|
with self.lock:
|
|
|
self.buffers.pop(member.id, None)
|
|
self.buffers.pop(member.id, None)
|
|
|
self.decoders.pop(member.id, None)
|
|
self.decoders.pop(member.id, None)
|
|
|
- self.ignored_speakers.pop(member.id, None)
|
|
|
|
|
|
|
|
|
|
def cleanup(self):
|
|
def cleanup(self):
|
|
|
# La bibliothèque appelle cleanup depuis son thread de réception.
|
|
# La bibliothèque appelle cleanup depuis son thread de réception.
|
|
@@ -228,17 +233,19 @@ class STTSink(voice_recv.AudioSink):
|
|
|
self.closed = True
|
|
self.closed = True
|
|
|
self.buffers.clear()
|
|
self.buffers.clear()
|
|
|
self.decoders.clear()
|
|
self.decoders.clear()
|
|
|
- self.ignored_speakers.clear()
|
|
|
|
|
|
|
+ self.pending.clear()
|
|
|
self.log_diagnostic('arrêt')
|
|
self.log_diagnostic('arrêt')
|
|
|
if not self.loop.is_closed():
|
|
if not self.loop.is_closed():
|
|
|
self.loop.call_soon_threadsafe(self.flush_task.cancel)
|
|
self.loop.call_soon_threadsafe(self.flush_task.cancel)
|
|
|
self.loop.call_soon_threadsafe(self.worker_task.cancel)
|
|
self.loop.call_soon_threadsafe(self.worker_task.cancel)
|
|
|
|
|
+ self.loop.call_soon_threadsafe(self.reply_task.cancel)
|
|
|
|
|
|
|
|
async def close(self):
|
|
async def close(self):
|
|
|
self.cleanup()
|
|
self.cleanup()
|
|
|
self.flush_task.cancel()
|
|
self.flush_task.cancel()
|
|
|
self.worker_task.cancel()
|
|
self.worker_task.cancel()
|
|
|
- await asyncio.gather(self.flush_task, self.worker_task, return_exceptions=True)
|
|
|
|
|
|
|
+ self.reply_task.cancel()
|
|
|
|
|
+ await asyncio.gather(self.flush_task, self.worker_task, self.reply_task, return_exceptions=True)
|
|
|
while not self.queue.empty():
|
|
while not self.queue.empty():
|
|
|
self.queue.get_nowait()
|
|
self.queue.get_nowait()
|
|
|
self.queue.task_done()
|
|
self.queue.task_done()
|