vocal_helper.turns module
vocal_helper.turns
Turning an engine’s raw output into rows a human can read and click.
Every transcriber returns something slightly wrong for display: Gladia merges same-speaker speech into blocks that play far ahead of where the reader’s eye sits, ElevenLabs stamps a whole overlapping interjection at one instant, sentence splitting on a lone quote mark leaves a row with no speech in it, and a diarizer discovers a “speaker” who turns out to be a waiter saying four words. None of that is an engine defect, and none of it is fixable upstream — it is the gap between what was said and a transcript somebody reads.
Extracted from notes_helper.cloud_transcribe (same author), where each rule
below was added in response to a real complaint about a real recording. It belongs
here because none of it is cloud-specific: the local path produces the same shapes
and deserves the same treatment.
The five rules, and the complaint behind each
A speaker who barely speaks is probably not a speaker. A hosted diarizer will happily discover a passer-by, a waiter, a crowd. Below a duration floor they are dropped, and the survivors are relabelled
S0,S1, … by descending talk time, so the ordering means something.Consecutive same-speaker speech merges, unless the previous row already ended a sentence. Without the sentence guard, a merge glues two thoughts together; without the merge, one sentence spreads over five rows.
An oversized row is split at a real pause. “The click lands late” is the complaint: a 28-second block starts playing long before the sentence the reader is looking at. Cuts use word timings and prefer a silence.
A row with no words in it is folded away. Sentence splitting on « or . can produce a row that seeks to silence, which reads as the timestamps being wrong.
A zero-width row is given a span. Overlapping speech makes some engines stamp an entire interjection at one instant. It still clicks to roughly the right place, but it can never highlight as it plays.
Order matters, and getting it wrong is silent
Merge first, so splitting sees whole thoughts. Split next. Then pad, then fold — in that order, and not the other way round.
Rules 4 and 5 contradict each other if folding runs first. Folding calls a row with no duration an artefact and merges it away; padding exists precisely to give a span to a row that has words and no duration. So when a block whose timestamps collapsed to one instant is split by word count — the case rule 3’s word-count cut was written for — every piece comes out zero-width, folding immediately merges them all back into one, and the split silently achieves nothing.
Padding first resolves it: a piece with real words gets its span and is no longer an artefact, while a piece that is genuinely only punctuation still has fewer than two alphanumerics and is still folded away. Both rules keep their meaning.
Usage example
>>> join_words([(0.0, 0.2, " C"), (0.2, 0.4, "'est"), (0.4, 0.6, " vrai")])
"C'est vrai"
>>> join_words([(0.0, 0.2, "Hello"), (0.2, 0.4, ","), (0.4, 0.6, "world")])
'Hello, world'
- vocal_helper.turns.SENTENCE_END = ('.', '?', '!', '…', '.»', '?»', '!»')
the speaker finished a thought.
- Type:
Endings that forbid a merge
- vocal_helper.turns.consolidate(utterances, *, min_speaker_s=120.0, merge_gap_s=3.0, max_turn_s=30.0, max_s=9.0, target_s=6.0, gap_s=0.3, max_words=45, pad_rate_s=0.34)[source]
Turn raw engine output into readable, clickable rows with stable speaker ids.
Applies the five rules of the module docstring, in the order that docstring explains, and relabels the surviving speakers
S0,S1, … by descending talk time.- Parameters:
utterances (list of CloudUtterance) – Raw rows from any engine, with whatever speaker labels it used.
min_speaker_s (float, keyword-only, optional) – A discovered speaker must exceed this total duration to survive. Two minutes by default. Lower it when a real but quiet participant matters; the default is tuned for a panel where a four-word voice is a passer-by.
merge_gap_s (float, keyword-only, optional) – Same-speaker rows closer than the gap merge, up to the cap.
max_turn_s (float, keyword-only, optional) – Same-speaker rows closer than the gap merge, up to the cap.
max_s (float and int, keyword-only, optional) – When a row is cut, and where: past
max_sit is split, preferring a silence ofgap_sonce the running row passestarget_s, and always beforemax_wordstokens.target_s (float and int, keyword-only, optional) – When a row is cut, and where: past
max_sit is split, preferring a silence ofgap_sonce the running row passestarget_s, and always beforemax_wordstokens.gap_s (float and int, keyword-only, optional) – When a row is cut, and where: past
max_sit is split, preferring a silence ofgap_sonce the running row passestarget_s, and always beforemax_wordstokens.max_words (float and int, keyword-only, optional) – When a row is cut, and where: past
max_sit is split, preferring a silence ofgap_sonce the running row passestarget_s, and always beforemax_wordstokens.pad_rate_s (float, keyword-only, optional) – Seconds per word given to a zero-width row. The default is a calm French rate, roughly three words a second.
- Returns:
(rows, speakers)wherespeakersmaps each new id to the original engine label it came from — kept so a mis-assignment can be traced back to what the engine actually said, rather than disappearing into a renaming.- Return type:
Examples
>>> raw = [ ... {"t0": 0.0, "t1": 5.0, "speaker": "spk_1", "text": "Bonjour à tous", ... "words": [(0.0, 5.0, "Bonjour à tous")], "language": "fr"}, ... {"t0": 5.0, "t1": 6.0, "speaker": "waiter", "text": "Un café", ... "words": [(5.0, 6.0, "Un café")], "language": "fr"}, ... ] >>> rows, speakers = consolidate(raw, min_speaker_s=2.0) >>> [(r["speaker"], r["text"]) for r in rows] [('S0', 'Bonjour à tous')] >>> speakers {'S0': 'spk_1'}
With no floor, the brief voice survives and is simply ranked second:
>>> rows, speakers = consolidate(raw, min_speaker_s=0.0) >>> [r["speaker"] for r in rows], speakers (['S0', 'S1'], {'S0': 'spk_1', 'S1': 'waiter'})
- vocal_helper.turns.join_words(words)[source]
Rebuild a row’s text from its word tokens, respecting each engine’s spacing.
Two conventions exist and they must not be mixed. Gladia and whisper encode a word boundary as a leading space on each token (
" Après"," C", then"'est"with none), so those are concatenated verbatim: inserting our own spaces would shatter elisions into"C 'est -à -dire". ElevenLabs tokens carry no leading space, so those are space-joined, with apostrophes, hyphens and trailing punctuation glued to the previous token.Which convention is in play is detected, not configured: a caller should not have to know, and a wrong setting is silently unreadable French.
- Parameters:
- Returns:
The row’s text, stripped.
- Return type:
Examples
>>> join_words([]) '' >>> join_words([(0.0, 1.0, " c"), (1.0, 2.0, "'est-à-dire")]) "c'est-à-dire" >>> join_words([(0.0, 1.0, "oui"), (1.0, 2.0, "!")]) 'oui!'