Files
voice-assistant/server/voice_server/speaker_attribution.py
T
Denis Defolie d20976fea2 Assistant vocal local ESP32 : étapes 1 à 15, et 15b écrite
État du projet au premier commit :
- firmware ESP32 : micro I2S, lecture anti-gigue, MQTT (état retenu, testament),
  mains libres, domotique de démonstration, chien de garde, présence du serveur ;
- serveur Python : sessions, VAD, faster-whisper, pyannote, identification,
  droits par profil, domotique, mot de réveil, Piper ;
- broker Mosquitto : configuration, comptes et ACL (non activés en développement) ;
- étape 15b écrite, en attente d'essai : agent MQTT pour machines Linux.

Étapes 1 à 15 validées. 203 tests, pyright sans erreur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:41:11 +02:00

101 lines
3.6 KiB
Python

"""Association locuteur + texte : qui a dit quoi (étape 10).
Whisper donne l'instant de chaque mot, la diarisation l'instant de chaque tour
de parole. Les deux ont reçu exactement le même audio (la parole trouvée par la
VAD) : leurs horloges coïncident. Chaque mot va au tour qui le recouvre le plus,
puis les mots consécutifs d'un même locuteur forment une intervention.
"""
from __future__ import annotations
import json
import math
from collections.abc import Sequence
from dataclasses import dataclass
from voice_server.diarization import SpeakerTurn
from voice_server.speaker_identifier import UNKNOWN, Identification
from voice_server.speech_to_text import Word
@dataclass(frozen=True)
class Utterance:
"""Une intervention : ce qu'une personne a dit d'un seul tenant."""
speaker: str # nom, ou « Inconnu »
text: str
start_s: float # instants dans la parole sans silences (ceux de Whisper et de la diarisation)
end_s: float
score: float | None # similarité avec le profil (None : pas d'identification)
label: str = "" # étiquette de diarisation (SPEAKER_00), vide sans diarisation
def turn_of(word: Word, turns: Sequence[SpeakerTurn]) -> SpeakerTurn | None:
"""Le tour qui recouvre le plus le mot ; à défaut (mot dans un blanc), le plus proche."""
if not turns:
return None
def overlap(turn: SpeakerTurn) -> float:
return min(word.end_s, turn.end_s) - max(word.start_s, turn.start_s)
best = max(turns, key=overlap)
if overlap(best) > 0:
return best
middle = (word.start_s + word.end_s) / 2
return min(turns, key=lambda turn: min(abs(middle - turn.start_s), abs(middle - turn.end_s)))
def attribute(words: Sequence[Word], turns: Sequence[SpeakerTurn],
verdicts: dict[str, Identification]) -> list[Utterance]:
"""Regroupe les mots en interventions, une par changement de locuteur.
Args:
words: mots horodatés de Whisper.
turns: tours de parole de la diarisation (vide : diarisation désactivée).
verdicts: identification de chaque étiquette de diarisation.
"""
utterances: list[Utterance] = []
group: list[Word] = []
current: str | None = None
def close() -> None:
if not group:
return
verdict = verdicts.get(current or "")
score = verdict.score if verdict is not None and not math.isnan(verdict.score) else None
utterances.append(Utterance(
speaker=verdict.label if verdict is not None else UNKNOWN,
text="".join(word.text for word in group).strip(),
start_s=group[0].start_s,
end_s=group[-1].end_s,
score=score,
label=current or "",
))
for word in words:
turn = turn_of(word, turns)
label = turn.speaker if turn is not None else None
if group and label != current:
close()
group = []
current = label
group.append(word)
close()
return utterances
def transcript_json(session: str, utterances: Sequence[Utterance]) -> bytes:
"""Message publié sur voice/<carte>/transcript.
{"session": "a1b2c3", "utterances": [{"speaker": "Denis", "text": "...",
"start": 0.0, "end": 1.66, "score": 0.74}, ...]}
"""
return json.dumps({
"session": session,
"utterances": [
{"speaker": u.speaker, "text": u.text, "start": round(u.start_s, 2), "end": round(u.end_s, 2),
"score": None if u.score is None else round(u.score, 2)}
for u in utterances
],
}, ensure_ascii=False).encode("utf-8")