shape.py
Language Python6.5 KB169 lines
"""From timed, speaker-tagged words (WhisperX segments) to sentences and speaker turns.A sentence starts at its first aligned word. With none (no alignment model for thelanguage, or a token such as a number that never aligns) its time is interpolatedacross the segment by character position. A sentence is also split where thespeaker changes, since speech is not always punctuated at a change of voice; a runshorter than MIN_RUN_WORDS counts as diarization noise. Raw labels become"Speaker 1", "Speaker 2", ... in order of first appearance."""from __future__ import annotationsfrom collections import defaultdictfrom .sentences import split_sentences# A speaker run shorter than this is treated as diarization noise.MIN_RUN_WORDS = 2def _num(x):try:v = float(x)except (TypeError, ValueError):return Nonereturn v if v == v else None # drop NaNdef _word_offsets(text: str, words: list[dict]) -> list[int | None]:"""Locate each word in the segment text, in order. None when not found."""offsets, cursor = [], 0low = text.lower()for w in words:token = str(w.get("word", "")).strip().lower()if not token:offsets.append(None)continueat = low.find(token, cursor)if at < 0:offsets.append(None)continueoffsets.append(at)cursor = at + len(token)return offsetsdef _majority_speaker(words: list[dict]) -> str | None:weight: dict[str, float] = defaultdict(float)first_seen: dict[str, int] = {}for i, w in enumerate(words):spk = w.get("speaker")if not spk:continues, e = _num(w.get("start")), _num(w.get("end"))weight[spk] += (e - s) if (s is not None and e is not None and e > s) else 0.01first_seen.setdefault(spk, i)if not weight:return None# Highest total duration wins; a tie goes to whoever spoke first.return max(weight, key=lambda k: (weight[k], -first_seen[k]))def _speaker_runs(items: list[tuple[int, dict]], min_words: int = MIN_RUN_WORDS) -> list[list]:"""Split (offset, word) pairs into runs of one speaker each.Words with no speaker join the run they sit in. A run shorter than`min_words` is folded into its neighbour, so a single misattributed worddoes not break a sentence in two."""runs: list[list] = []for off, w in items:spk = w.get("speaker")if runs and (spk is None or spk == runs[-1][0] or runs[-1][0] is None):if runs[-1][0] is None:runs[-1][0] = spkruns[-1][1].append((off, w))else:runs.append([spk, [(off, w)]])changed = Truewhile changed and len(runs) > 1:changed = Falsefor i, (_, words) in enumerate(runs):if len(words) < min_words:j = i - 1 if i > 0 else i + 1if i > 0:runs[j][1].extend(words)else:runs[j][1][:0] = wordsdel runs[i]# Neighbours may now share a speaker: merge them.k = 0while k + 1 < len(runs):if runs[k][0] == runs[k + 1][0]:runs[k][1].extend(runs.pop(k + 1)[1])else:k += 1changed = Truebreakreturn runsdef _piece(text: str, words: list[dict], start_fb: float, end_fb: float, speaker) -> dict:starts = [_num(w.get("start")) for w in words if _num(w.get("start")) is not None]ends = [_num(w.get("end")) for w in words if _num(w.get("end")) is not None]start, timed = (min(starts), True) if starts else (start_fb, False)end = max(ends) if ends else end_fbreturn {"text": text, "start": round(start, 3), "end": round(max(end, start), 3),"speaker": speaker, "timed": timed}def sentences_from_segments(segments: list[dict]) -> list[dict]:"""[{text, start, end, speaker (raw label or None), timed (bool)}] in order."""out: list[dict] = []for seg in segments:text = (seg.get("text") or "").strip()if not text:continueseg_start = _num(seg.get("start")) or 0.0seg_end = _num(seg.get("end"))span = seg_end - seg_start if (seg_end is not None and seg_end > seg_start) else 0.0words = seg.get("words") or []offsets = _word_offsets(text, words)sentences = split_sentences(text)def at(pos: int) -> float:return seg_start + span * min(pos, len(text)) / max(len(text), 1)for k, s in enumerate(sentences):lo = s["offset"]hi = sentences[k + 1]["offset"] if k + 1 < len(sentences) else len(text) + 1mine = [(off, w) for w, off in zip(words, offsets)if off is not None and lo <= off < hi]runs = _speaker_runs(mine) if any(w.get("speaker") for _, w in mine) else []if len(runs) <= 1:speaker = _majority_speaker([w for _, w in mine]) or seg.get("speaker")out.append(_piece(s["text"], [w for _, w in mine], at(lo),at(hi) if span else at(lo), speaker))continue# Cut the sentence where each later run's first word begins.cuts = [lo] + [r[1][0][0] for r in runs[1:]] + [lo + len(s["text"])]for (spk, run), a, b in zip(runs, cuts, cuts[1:]):piece = text[a:b].strip()if piece:out.append(_piece(piece, [w for _, w in run], at(a), at(b), spk))return outdef rename_speakers(sentences: list[dict]) -> tuple[list[dict], list[str]]:"""Relabel raw speakers as 'Speaker N' by first appearance. Returns (sentences, names)."""mapping: dict[str, str] = {}for s in sentences:raw = s.get("speaker")if raw and raw not in mapping:mapping[raw] = f"Speaker {len(mapping) + 1}"renamed = [{**s, "speaker": mapping.get(s.get("speaker"))} for s in sentences]return renamed, list(mapping.values())def turns(sentences: list[dict]) -> list[dict]:"""Merge consecutive sentences by the same speaker into turns."""out: list[dict] = []for s in sentences:if out and out[-1]["speaker"] == s["speaker"]:out[-1]["sentences"].append(s)out[-1]["end"] = s["end"]else:out.append({"speaker": s["speaker"], "start": s["start"], "end": s["end"],"sentences": [s]})return out