vocal_helper.incremental module
Regroupement incrémental sous contrainte des locuteurs d’un flux.
Pourquoi ce module existe
OnlineDiarStage rattache chaque segment voisé au
centroïde le plus proche sous un seuil unique, puis met ce centroïde à jour en
moyenne glissante. Ce schéma glouton a trois faiblesses, mesurées sur un mixage
synthétique de 4 locuteurs (studies/rediar_speaker_consistency.py), où il
s’effondre sur une seule étiquette pour les quatre voix :
il n’a aucune notion de simultanéité, donc rien n’interdit à deux voix qui parlent en même temps de tomber sur le même locuteur ;
il embarque un segment recouvert comme un tout, ce qui produit une empreinte mélangée qui tombe entre deux centroïdes et les rapproche ;
toute jointure met le centroïde à jour, même douteuse, et la moyenne glissante propage la contamination jusqu’à la fusion générale.
La réponse implémentée ici s’inspire de diart. L’algorithme est écrit sur place et non importé : diart est une dépendance lourde avec son propre environnement, alors que le principe tient en quelques dizaines de lignes de numpy et de scipy — testables sans audio, sans modèle et sans boucle d’événements, et transposables en Rust.
Les trois garde-fous
contrainte d'injectivitéL’affectation locaux → globaux passe par un algorithme hongrois (
scipy.optimize.linear_sum_assignment()), donc deux locuteurs actifs dans la même fenêtre ne peuvent pas recevoir la même étiquette globale. C’est ce qui rend l’effondrement impossible par construction.rho_updateUn locuteur local ne met son centroïde à jour que si sa part de parole propre dans la fenêtre atteint
IncrementalPolicy.rho_update_s. En dessous, il est rattaché mais n’écrit rien.delta_newSi le centroïde le plus proche reste au-delà de
IncrementalPolicy.delta_newen distance cosinus, on crée un nouveau locuteur global plutôt que de forcer une mauvaise jointure. C’est ainsi que le nombre de locuteurs se devine au fil du flux.
Écart assumé
L’inspiration calcule une empreinte pondérée par le masque d’activité du
locuteur, ce que ses encodeurs acceptent. Les nôtres prennent un simple (pcm, sr). On
obtient l’équivalent utile en n’embarquant que les trames où le locuteur local
est actif et seul : une empreinte propre plutôt qu’une empreinte pondérée.
La durée de cette part propre est précisément ce que rho_update filtre.
- class vocal_helper.incremental.IncrementalClusterer(policy=<factory>)[source]
Bases:
objectRattache les locuteurs locaux d’une fenêtre aux locuteurs globaux.
L’objet est volontairement sans audio et sans modèle : il reçoit des empreintes et des durées, il rend des étiquettes. Tout le comportement décrit dans l’en-tête du module se teste donc directement.
Examples
Deux voix orthogonales ouvrent le flux ; chacune reçoit son étiquette.
>>> import numpy as np >>> e0 = np.array([1.0, 0.0], dtype=np.float32) >>> e1 = np.array([0.0, 1.0], dtype=np.float32) >>> c = IncrementalClusterer() >>> c.assign(np.stack([e0, e1]), [1.0, 1.0]) ['S0', 'S1']
La fenêtre suivante les présente dans l’autre sens. La contrainte d’injectivité et l’algorithme hongrois les suivent sans les confondre — c’est ce qu’un plus-proche-centroïde glouton ne garantit pas.
>>> c.assign(np.stack([e1, e0]), [1.0, 1.0]) ['S1', 'S0']
Une empreinte mélangée, à mi-chemin des deux voix, reste sous
delta_new: elle est rattachée au plus proche. Mais sa part propre est trop courte, donc elle n’écrit dans aucun centroïde.>>> blend = np.array([0.7071, 0.7071], dtype=np.float32) >>> c.assign(blend[None, :], [0.1]) ['S0'] >>> c.updates 4
Une troisième voix, elle, est trop loin des deux premières : le flux découvre un locuteur de plus plutôt que de forcer la jointure.
>>> e2 = np.array([-1.0, 0.0], dtype=np.float32) >>> c.assign(e2[None, :], [1.0]) ['S2'] >>> c.speaker_count 3
Sous plafond, le même flux ne crée rien et se rabat sur le plus proche.
>>> capped = IncrementalClusterer(policy=IncrementalPolicy(max_speakers=2)) >>> capped.assign(np.stack([e0, e1]), [1.0, 1.0]) ['S0', 'S1'] >>> capped.assign(e2[None, :], [1.0]) ['S1'] >>> capped.speaker_count 2
- Parameters:
policy (IncrementalPolicy)
- assign(embeddings, clean_durations)[source]
Étiqueter les locuteurs locaux d’une fenêtre.
- Parameters:
embeddings (NDArray[np.float32]) –
(k, d)— une empreinte par locuteur local actif. Normalisée ici, l’appelant n’a pas à s’en soucier.clean_durations (list of float) –
kdurées, en secondes, de la part de parole propre de chaque locuteur local — les trames où il est actif et seul. C’est ce querho_updatefiltre.
- Returns:
kétiquettes globales, dans l’ordre des lignes reçues.- Return type:
- Raises:
ValueError – Si les deux entrées n’ont pas la même longueur.
- policy: IncrementalPolicy
- class vocal_helper.incremental.IncrementalDiarStage(*, segmenter='auto', embedder='auto', policy=None, window_s=5.0, step_s=2.5, local_max_speakers=3, min_embed_s=0.5)[source]
Bases:
objectDiarisation en ligne par fenêtre glissante, segmenteur au choix.
L’étage fait glisser une fenêtre sur le flux, fait segmenter localement chaque fenêtre par le backend qu’on lui donne, embarque la part propre de chaque locuteur local, puis confie le rattachement à
IncrementalClusterer. Ni le segmenteur ni l’encodeur ne sont imposés : c’est tout l’intérêt d’avoir porté l’algorithme plutôt que de dépendre de diart, dont le segmenteur local est câblé sur pyannote.- Parameters:
segmenter (str) – Backend de segmentation locale, passé à
OfflineDiarStage."auto"(défaut) prend le meilleur backend installé ;"sherpa"donne un chemin sans torch.embedder (str) – Backend d’empreinte vocale, passé à
OnlineDiarStage."auto"par défaut.policy (IncrementalPolicy, optional) – Les réglages du regroupement. Défauts de diart.
window_s (float) – Longueur de la fenêtre glissante. 5 s, la valeur de référence.
step_s (float) – Pas de la fenêtre. La méthode d’origine avance de 0,5 s parce que son segmenteur local est un petit modèle dédié ; les nôtres sont des diariseurs complets, donc le défaut est ici 2,5 s — un recouvrement de moitié, qui garde le vote de recouvrement sans rendre le RTF absurde.
local_max_speakers (int) – Plafond passé au segmenteur local. 3, la limite usuelle d’un modèle de segmentation locale, et un plafond est ce qui empêche la prolifération d’étiquettes.
min_embed_s (float) – Part propre minimale pour tenter une empreinte. Sous ce seuil, le locuteur local est ignoré : mieux vaut ne rien dire que de verser une empreinte de bruit dans le regroupement.
- diarize(pcm, sr, *, num_speakers=None, min_speakers=None, max_speakers=None)[source]
Diariser un tampon entier par fenêtres glissantes.
- Parameters:
pcm (NDArray[np.float32]) – Tampon mono.
sr (int) – Sa fréquence d’échantillonnage.
num_speakers (int, optional) – Acceptés pour la parité d’interface avec les autres étages. Le regroupement incrémental devine le compte par
delta_new; un compte exact n’a pas de sens au fil de l’eau, puisque les locuteurs arrivent les uns après les autres.min_speakers (int, optional) – Acceptés pour la parité d’interface avec les autres étages. Le regroupement incrémental devine le compte par
delta_new; un compte exact n’a pas de sens au fil de l’eau, puisque les locuteurs arrivent les uns après les autres.max_speakers (int, optional) – Plafond dur, qui l’emporte sur celui de la politique. C’est le réglage utile : la mesure du 2026-11-01 montre qu’un plafond vaut le compte exact sur le moteur différé.
- Returns:
[(t0, t1, locuteur), …]trié par début, la même forme que tous les autres étages de diarisation du paquet.- Return type:
- extract(pcm, sr)[source]
Segmenter et embarquer chaque fenêtre, sans encore regrouper.
C’est la moitié coûteuse du travail, et la seule qui touche aux modèles. Son résultat ne dépend d’aucun réglage du regroupement, donc il se garde et se rejoue — c’est ce qui rend un balayage abordable.
- Parameters:
pcm (NDArray[np.float32]) – Tampon mono.
sr (int) – Sa fréquence d’échantillonnage.
- Returns:
Une entrée par fenêtre ayant livré au moins une empreinte.
- Return type:
- label(windows, duration_s)[source]
Regrouper des fenêtres extraites en une diarisation.
La moitié bon marché : du numpy et un algorithme hongrois. Repart du regroupeur courant, donc appeler
reset()avant de rejouer les mêmes fenêtres sous un autre réglage.
- reset()[source]
Oublier les locuteurs découverts, en gardant la politique.
Examples
>>> import numpy as np >>> stage = IncrementalDiarStage() >>> stage.clusterer.assign(np.eye(2, dtype=np.float32), [1.0, 1.0]) ['S0', 'S1'] >>> stage.reset() >>> stage.discovered_speakers 0
La politique décide du regroupement : une politique par exemplaires donne un regroupement sans centroïde, à extraction identique, donc les deux sont directement comparables.
>>> from vocal_helper.exemplar import ExemplarPolicy >>> other = IncrementalDiarStage(policy=ExemplarPolicy()) >>> type(other.clusterer).__name__ 'ExemplarClusterer'
- Return type:
None
- windows(duration_s)[source]
Les bornes des fenêtres couvrant
duration_s.La dernière fenêtre est ramenée à la fin du flux plutôt que tronquée, pour que la fin de l’audio soit vue autant que le reste.
Examples
>>> stage = IncrementalDiarStage(window_s=5.0, step_s=2.5) >>> stage.windows(10.0) [(0.0, 5.0), (2.5, 7.5), (5.0, 10.0)] >>> stage.windows(6.0) [(0.0, 5.0), (1.0, 6.0)] >>> stage.windows(3.0) [(0.0, 3.0)]
- class vocal_helper.incremental.IncrementalPolicy(tau_active=0.5, rho_update_s=0.4, delta_new=0.6, delta_scale=None, delta_quantile=0.5, delta_position=None, max_speakers=None, ema_alpha=None)[source]
Bases:
objectLes réglages du regroupement incrémental.
Les valeurs par défaut reprennent celles de la méthode dont ce module s’inspire, hors
max_speakers, qui est un ajout.- Parameters:
- tau_active
Seuil d’activité de la segmentation locale. Sous ce seuil, un locuteur local n’est pas considéré comme parlant dans la fenêtre.
- Type:
- rho_update_s
Part de parole propre minimale, en secondes, pour qu’un locuteur local soit autorisé à mettre son centroïde à jour.
- Type:
- delta_new
Distance cosinus au-delà de laquelle on crée un locuteur global plutôt que de forcer une jointure. Ignoré si
delta_scaleest posé.- Type:
- delta_quantile
Quantile des distances inter-locuteurs servant d’échelle, passé à
inter_speaker_scale(). Sans effet sidelta_scaleest absent.- Type:
- delta_position
Si posé,
delta_newest pris entre l’échelle intra-locuteur et l’échelle inter-locuteurs de cet enregistrement, parseparating_threshold()— la seule calibration mesurée qui n’ait besoin d’aucune constante choisie à la main. L’emporte surdelta_scale.- Type:
float, optional
- delta_scale
Si posé,
delta_newest recalculé pour chaque enregistrement commedelta_scalefois l’échelle inter-locuteurs mesurée parinter_speaker_scale(). C’est la seule façon qu’on ait trouvée de tenir deux enregistrements différents avec un seul réglage : un seuil absolu ne transfère pas, une fraction de l’échelle locale peut.- Type:
float, optional
- max_speakers
Plafond dur du nombre de locuteurs globaux.
None(défaut) laisse le compte libre — c’est le mode « devine ». Un plafond est la garde la plus efficace contre la prolifération d’étiquettes : la mesure du 2026-11-01 montre qu’imposer un plafond au moteur différé vaut exactement le compte exact (DER 0,242 contre 0,299 en découverte libre).- Type:
int, optional
- ema_alpha
Coefficient de la moyenne glissante des centroïdes.
Noneprend la moyenne courante exacte, ce que fait diart.- Type:
- class vocal_helper.incremental.WindowFeatures(t0, local, embeddings, clean_durations, speakers)[source]
Bases:
objectUne fenêtre glissante, sa segmentation locale et ses empreintes.
L’extraction (segmenter, découper la part propre, embarquer) coûte des secondes par minute d’audio ; le regroupement coûte des microsecondes. Les séparer rend un balayage de
delta_newou derho_update_scent fois moins cher, puisqu’il rejoue ces fenêtres en cache au lieu de refaire tourner les modèles.- Parameters:
- embeddings
(k, d)— une empreinte par locuteur local retenu.- Type:
NDArray[np.float32]
- vocal_helper.incremental.cosine_distances(locals_, centroids)[source]
Matrice des distances cosinus entre empreintes locales et centroïdes.
Les deux entrées sont supposées normalisées L2, ce que
IncrementalClusterer.assign()garantit.- Parameters:
- Returns:
(k, m)de distances dans[0, 2].- Return type:
NDArray[np.float32]
Examples
>>> import numpy as np >>> e0 = np.array([[1.0, 0.0]], dtype=np.float32) >>> e1 = np.array([[0.0, 1.0]], dtype=np.float32) >>> cosine_distances(e0, np.concatenate([e0, e1])) array([[0., 1.]], dtype=float32)
- vocal_helper.incremental.inter_speaker_scale(windows, quantile=0.5)[source]
L’échelle des distances entre locuteurs différents, pour cet audio.
Un seuil cosinus absolu ne transfère pas d’un enregistrement à l’autre : la salle, le micro et la distance changent l’échelle même des distances. Deux réunions AMI de quatre personnes l’ont montré sans ambiguïté — le seuil qui donne le meilleur taux d’erreur sur l’une (0,8) fusionne deux vrais locuteurs sur l’autre, dérive 1,000.
La calibration est pourtant gratuite, et elle vient de la structure même du problème : deux locuteurs locaux présents dans la même fenêtre sont des personnes différentes par construction, puisque la segmentation locale les a distingués. La médiane de leurs distances donne donc l’échelle inter-locuteurs propre à cet enregistrement, sans vérité terrain, sans réglage et sans second passage.
- Parameters:
windows (list of WindowFeatures) – Ce que
IncrementalDiarStage.extract()a rendu.quantile (float) – Quel quantile de ces distances retenir. La médiane (0,5) décrit l’enregistrement en moyenne, et c’est insuffisant : deux réunions AMI ont rendu 0,834 et 0,847 — indiscernables — alors que leur seuil optimal diffère d’un tiers. Ce qui les sépare n’est pas l’échelle moyenne mais la paire la plus confusable, que la médiane noie. Un quantile bas la fait remonter.
- Returns:
La distance au quantile demandé entre locuteurs connus différents.
nansi aucune fenêtre n’a vu deux locuteurs à la fois — un monologue, où la question ne se pose pas.- Return type:
Examples
Deux voix orthogonales, vues ensemble : l’échelle est leur distance, 1,0.
>>> import numpy as np >>> pair = np.eye(2, dtype=np.float32) >>> w = WindowFeatures(t0=0.0, local=[], embeddings=pair, ... clean_durations=[1.0, 1.0], speakers=["A", "B"]) >>> float(inter_speaker_scale([w])) 1.0
Un monologue ne renseigne sur rien.
>>> lone = WindowFeatures(t0=0.0, local=[], embeddings=pair[:1], ... clean_durations=[1.0], speakers=["A"]) >>> bool(np.isnan(inter_speaker_scale([lone]))) True
- vocal_helper.incremental.intra_speaker_scale(windows, *, min_overlap_s=1.0, quantile=0.5)[source]
L’échelle des distances pour un même locuteur, pour cet audio.
Le pendant de
inter_speaker_scale(), et il s’obtient tout aussi gratuitement. Les fenêtres voisines se recouvrent — la moitié d’une fenêtre par défaut — donc un locuteur qui parle dans le recouvrement est embarqué deux fois, une fois par chaque fenêtre. Les étiquettes locales ne sont pas partagées d’une fenêtre à l’autre, mais le temps l’est : deux locuteurs locaux dont les intervalles absolus se chevauchent franchement sont presque sûrement la même personne.La distance entre ces deux empreintes mesure donc ce que l’enregistrement fait subir à une voix qui ne change pas : réverbération, mouvement, bruit. C’est l’information qui manquait pour poser un seuil, parce que ce qui décide d’une fusion n’est ni la distance inter-locuteurs ni la distance intra-locuteur seule, mais l’écart entre les deux.
- Parameters:
windows (list of WindowFeatures) – Ce que
IncrementalDiarStage.extract()a rendu, dans l’ordre.min_overlap_s (float) – Recouvrement temporel minimal pour admettre que c’est la même voix. Au-dessous, l’appariement devient douteux et polluerait la mesure.
quantile (float) – Quel quantile de ces distances retenir. La médiane par défaut : ici on veut le comportement courant d’une voix, pas son pire moment.
- Returns:
La distance au quantile demandé entre deux vues d’un même locuteur.
nansi aucune paire n’a pu être appariée.- Return type:
Examples
Le même locuteur vu par deux fenêtres voisines, avec une empreinte qui a légèrement bougé : l’échelle est ce déplacement.
>>> import numpy as np >>> a = np.array([[1.0, 0.0]], dtype=np.float32) >>> moved = np.array([[0.8, 0.6]], dtype=np.float32) >>> w0 = WindowFeatures(t0=0.0, local=[(1.0, 4.0, "A")], embeddings=a, ... clean_durations=[3.0], speakers=["A"]) >>> w1 = WindowFeatures(t0=2.5, local=[(0.0, 1.5, "B")], embeddings=moved, ... clean_durations=[1.5], speakers=["B"]) >>> round(intra_speaker_scale([w0, w1]), 3) 0.2
Sans recouvrement temporel, rien n’est apparié.
>>> far = WindowFeatures(t0=100.0, local=[(0.0, 1.5, "B")], embeddings=moved, ... clean_durations=[1.5], speakers=["B"]) >>> bool(np.isnan(intra_speaker_scale([w0, far]))) True
- vocal_helper.incremental.separating_threshold(windows, *, position=0.5)[source]
Le seuil qui sépare « même voix » de « voix différentes », pour cet audio.
Les deux échelles se mesurent sans vérité terrain : l’une sur des locuteurs simultanés, donc différents par construction, l’autre sur un locuteur vu par deux fenêtres voisines, donc identique par construction. Le seuil se pose entre les deux, et il suit automatiquement l’enregistrement — c’est ce qu’aucune constante ne savait faire, puisque le seuil optimal variait d’un tiers entre deux réunions du même corpus.
- Parameters:
windows (list of WindowFeatures) – Ce que
IncrementalDiarStage.extract()a rendu.position (float) – Où tomber entre l’intra et l’inter. 0 collerait à l’intra (fusionne tout), 1 collerait à l’inter (sépare tout) ; 0,5 est à mi-chemin.
- Returns:
Le seuil.
nansi l’une des deux échelles n’a pas pu être mesurée — à l’appelant de retomber sur sa valeur par défaut.- Return type:
Examples
>>> import numpy as np >>> same = np.array([[1.0, 0.0]], dtype=np.float32) >>> moved = np.array([[0.8, 0.6]], dtype=np.float32) >>> pair = np.eye(2, dtype=np.float32) >>> w0 = WindowFeatures(t0=0.0, local=[(1.0, 4.0, "A")], embeddings=same, ... clean_durations=[3.0], speakers=["A"]) >>> w1 = WindowFeatures(t0=2.5, local=[(0.0, 1.5, "B")], embeddings=moved, ... clean_durations=[1.5], speakers=["B"]) >>> w2 = WindowFeatures(t0=5.0, local=[(0.0, 1.0, "C"), (1.0, 2.0, "D")], ... embeddings=pair, clean_durations=[1.0, 1.0], ... speakers=["C", "D"]) >>> round(separating_threshold([w0, w1, w2]), 3) 0.6