mattrguerra/transcription-pipelinemainPrivate on GitHub, published here in full

shape.py

Language Python6.5 KB169 lines

transcription_pipeline/shape.py

  1. """From timed, speaker-tagged words (WhisperX segments) to sentences and speaker turns.
  2. A sentence starts at its first aligned word. With none (no alignment model for the
  3. language, or a token such as a number that never aligns) its time is interpolated
  4. across the segment by character position. A sentence is also split where the
  5. speaker changes, since speech is not always punctuated at a change of voice; a run
  6. shorter than MIN_RUN_WORDS counts as diarization noise. Raw labels become
  7. "Speaker 1", "Speaker 2", ... in order of first appearance.
  8. """
  9. from __future__ import annotations
  10. from collections import defaultdict
  11. from .sentences import split_sentences
  12. # A speaker run shorter than this is treated as diarization noise.
  13. MIN_RUN_WORDS = 2
  14. def _num(x):
  15. try:
  16. v = float(x)
  17. except (TypeError, ValueError):
  18. return None
  19. return v if v == v else None # drop NaN
  20. def _word_offsets(text: str, words: list[dict]) -> list[int | None]:
  21. """Locate each word in the segment text, in order. None when not found."""
  22. offsets, cursor = [], 0
  23. low = text.lower()
  24. for w in words:
  25. token = str(w.get("word", "")).strip().lower()
  26. if not token:
  27. offsets.append(None)
  28. continue
  29. at = low.find(token, cursor)
  30. if at < 0:
  31. offsets.append(None)
  32. continue
  33. offsets.append(at)
  34. cursor = at + len(token)
  35. return offsets
  36. def _majority_speaker(words: list[dict]) -> str | None:
  37. weight: dict[str, float] = defaultdict(float)
  38. first_seen: dict[str, int] = {}
  39. for i, w in enumerate(words):
  40. spk = w.get("speaker")
  41. if not spk:
  42. continue
  43. s, e = _num(w.get("start")), _num(w.get("end"))
  44. weight[spk] += (e - s) if (s is not None and e is not None and e > s) else 0.01
  45. first_seen.setdefault(spk, i)
  46. if not weight:
  47. return None
  48. # Highest total duration wins; a tie goes to whoever spoke first.
  49. return max(weight, key=lambda k: (weight[k], -first_seen[k]))
  50. def _speaker_runs(items: list[tuple[int, dict]], min_words: int = MIN_RUN_WORDS) -> list[list]:
  51. """Split (offset, word) pairs into runs of one speaker each.
  52. Words with no speaker join the run they sit in. A run shorter than
  53. `min_words` is folded into its neighbour, so a single misattributed word
  54. does not break a sentence in two.
  55. """
  56. runs: list[list] = []
  57. for off, w in items:
  58. spk = w.get("speaker")
  59. if runs and (spk is None or spk == runs[-1][0] or runs[-1][0] is None):
  60. if runs[-1][0] is None:
  61. runs[-1][0] = spk
  62. runs[-1][1].append((off, w))
  63. else:
  64. runs.append([spk, [(off, w)]])
  65. changed = True
  66. while changed and len(runs) > 1:
  67. changed = False
  68. for i, (_, words) in enumerate(runs):
  69. if len(words) < min_words:
  70. j = i - 1 if i > 0 else i + 1
  71. if i > 0:
  72. runs[j][1].extend(words)
  73. else:
  74. runs[j][1][:0] = words
  75. del runs[i]
  76. # Neighbours may now share a speaker: merge them.
  77. k = 0
  78. while k + 1 < len(runs):
  79. if runs[k][0] == runs[k + 1][0]:
  80. runs[k][1].extend(runs.pop(k + 1)[1])
  81. else:
  82. k += 1
  83. changed = True
  84. break
  85. return runs
  86. def _piece(text: str, words: list[dict], start_fb: float, end_fb: float, speaker) -> dict:
  87. starts = [_num(w.get("start")) for w in words if _num(w.get("start")) is not None]
  88. ends = [_num(w.get("end")) for w in words if _num(w.get("end")) is not None]
  89. start, timed = (min(starts), True) if starts else (start_fb, False)
  90. end = max(ends) if ends else end_fb
  91. return {"text": text, "start": round(start, 3), "end": round(max(end, start), 3),
  92. "speaker": speaker, "timed": timed}
  93. def sentences_from_segments(segments: list[dict]) -> list[dict]:
  94. """[{text, start, end, speaker (raw label or None), timed (bool)}] in order."""
  95. out: list[dict] = []
  96. for seg in segments:
  97. text = (seg.get("text") or "").strip()
  98. if not text:
  99. continue
  100. seg_start = _num(seg.get("start")) or 0.0
  101. seg_end = _num(seg.get("end"))
  102. span = seg_end - seg_start if (seg_end is not None and seg_end > seg_start) else 0.0
  103. words = seg.get("words") or []
  104. offsets = _word_offsets(text, words)
  105. sentences = split_sentences(text)
  106. def at(pos: int) -> float:
  107. return seg_start + span * min(pos, len(text)) / max(len(text), 1)
  108. for k, s in enumerate(sentences):
  109. lo = s["offset"]
  110. hi = sentences[k + 1]["offset"] if k + 1 < len(sentences) else len(text) + 1
  111. mine = [(off, w) for w, off in zip(words, offsets)
  112. if off is not None and lo <= off < hi]
  113. runs = _speaker_runs(mine) if any(w.get("speaker") for _, w in mine) else []
  114. if len(runs) <= 1:
  115. speaker = _majority_speaker([w for _, w in mine]) or seg.get("speaker")
  116. out.append(_piece(s["text"], [w for _, w in mine], at(lo),
  117. at(hi) if span else at(lo), speaker))
  118. continue
  119. # Cut the sentence where each later run's first word begins.
  120. cuts = [lo] + [r[1][0][0] for r in runs[1:]] + [lo + len(s["text"])]
  121. for (spk, run), a, b in zip(runs, cuts, cuts[1:]):
  122. piece = text[a:b].strip()
  123. if piece:
  124. out.append(_piece(piece, [w for _, w in run], at(a), at(b), spk))
  125. return out
  126. def rename_speakers(sentences: list[dict]) -> tuple[list[dict], list[str]]:
  127. """Relabel raw speakers as 'Speaker N' by first appearance. Returns (sentences, names)."""
  128. mapping: dict[str, str] = {}
  129. for s in sentences:
  130. raw = s.get("speaker")
  131. if raw and raw not in mapping:
  132. mapping[raw] = f"Speaker {len(mapping) + 1}"
  133. renamed = [{**s, "speaker": mapping.get(s.get("speaker"))} for s in sentences]
  134. return renamed, list(mapping.values())
  135. def turns(sentences: list[dict]) -> list[dict]:
  136. """Merge consecutive sentences by the same speaker into turns."""
  137. out: list[dict] = []
  138. for s in sentences:
  139. if out and out[-1]["speaker"] == s["speaker"]:
  140. out[-1]["sentences"].append(s)
  141. out[-1]["end"] = s["end"]
  142. else:
  143. out.append({"speaker": s["speaker"], "start": s["start"], "end": s["end"],
  144. "sentences": [s]})
  145. return out