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 :

  1. 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 ;

  2. 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 ;

  3. 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_update

Un 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_new

Si le centroïde le plus proche reste au-delà de IncrementalPolicy.delta_new en 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: object

Rattache 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) – k durées, en secondes, de la part de parole propre de chaque locuteur local — les trames où il est actif et seul. C’est ce que rho_update filtre.

Returns:

k étiquettes globales, dans l’ordre des lignes reçues.

Return type:

list of str

Raises:

ValueError – Si les deux entrées n’ont pas la même longueur.

centroids()[source]

Les centroïdes courants, (m, d) normalisés L2.

Return type:

ndarray[tuple[int, …], dtype[float32]]

policy: IncrementalPolicy
property speaker_count: int

Nombre de locuteurs globaux découverts jusqu’ici.

property speaker_labels: list[str]

Les étiquettes globales, dans l’ordre de leur découverte.

property updates: int

Nombre de mises à jour de centroïde acceptées par rho_update.

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: object

Diarisation 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:

list of tuple

property discovered_speakers: int

Nombre de locuteurs que le flux a découverts.

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:

list of WindowFeatures

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.

Parameters:
Returns:

[(t0, t1, locuteur), …] trié par début.

Return type:

list of tuple

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

property speaker_labels: list[str]

Les étiquettes globales, dans l’ordre de découverte.

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)]
Parameters:

duration_s (float)

Return type:

list[tuple[float, float]]

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: object

Les 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 (float)

  • rho_update_s (float)

  • delta_new (float)

  • delta_scale (float | None)

  • delta_quantile (float)

  • delta_position (float | None)

  • max_speakers (int | None)

  • ema_alpha (float | None)

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:

float

rho_update_s

Part de parole propre minimale, en secondes, pour qu’un locuteur local soit autorisé à mettre son centroïde à jour.

Type:

float

delta_new

Distance cosinus au-delà de laquelle on crée un locuteur global plutôt que de forcer une jointure. Ignoré si delta_scale est posé.

Type:

float

delta_quantile

Quantile des distances inter-locuteurs servant d’échelle, passé à inter_speaker_scale(). Sans effet si delta_scale est absent.

Type:

float

delta_position

Si posé, delta_new est pris entre l’échelle intra-locuteur et l’échelle inter-locuteurs de cet enregistrement, par separating_threshold() — la seule calibration mesurée qui n’ait besoin d’aucune constante choisie à la main. L’emporte sur delta_scale.

Type:

float, optional

delta_scale

Si posé, delta_new est recalculé pour chaque enregistrement comme delta_scale fois l’échelle inter-locuteurs mesurée par inter_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. None prend la moyenne courante exacte, ce que fait diart.

Type:

float

delta_new: float = 0.6
delta_position: float | None = None
delta_quantile: float = 0.5
delta_scale: float | None = None
ema_alpha: float | None = None
max_speakers: int | None = None
rho_update_s: float = 0.4
tau_active: float = 0.5
class vocal_helper.incremental.WindowFeatures(t0, local, embeddings, clean_durations, speakers)[source]

Bases: object

Une 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_new ou de rho_update_s cent fois moins cher, puisqu’il rejoue ces fenêtres en cache au lieu de refaire tourner les modèles.

Parameters:
t0

Début de la fenêtre dans le flux, en secondes.

Type:

float

local

La segmentation locale, en temps relatifs à la fenêtre.

Type:

list of tuple

embeddings

(k, d) — une empreinte par locuteur local retenu.

Type:

NDArray[np.float32]

clean_durations

k durées de part propre, dans l’ordre des empreintes.

Type:

list of float

speakers

k identifiants locaux, dans l’ordre des empreintes.

Type:

list of str

clean_durations: list[float]
embeddings: ndarray[tuple[int, ...], dtype[float32]]
local: list[tuple[float, float, str]]
speakers: list[str]
t0: float
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:
  • locals (NDArray[np.float32]) – (k, d) — une ligne par locuteur local de la fenêtre.

  • centroids (NDArray[np.float32]) – (m, d) — une ligne par locuteur global déjà connu.

  • locals_ (ndarray[tuple[int, ...], dtype[float32]])

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. nan si aucune fenêtre n’a vu deux locuteurs à la fois — un monologue, où la question ne se pose pas.

Return type:

float

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. nan si aucune paire n’a pu être appariée.

Return type:

float

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:
Returns:

Le seuil. nan si l’une des deux échelles n’a pas pu être mesurée — à l’appelant de retomber sur sa valeur par défaut.

Return type:

float

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