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

sentences.py

Language Python2.9 KB86 lines

transcription_pipeline/sentences.py

  1. """Sentence splitting, ported from the original JavaScript splitter.
  2. Splits on . ? ! followed by whitespace, with carve-outs for abbreviations, single
  3. initials, decimals, closing quotes and brackets, and an ellipsis followed by a
  4. lowercase word (a pause mid-thought, not the end of a sentence). Each sentence
  5. comes back with the character offset where it starts, which is what lets the
  6. pipeline map sentences onto timed words. The output must match
  7. tests/fixtures/sentences-golden.json exactly.
  8. """
  9. from __future__ import annotations
  10. import re
  11. # Words that end in a period but do not end a sentence.
  12. ABBREVIATIONS = {
  13. "mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "mt",
  14. "inc", "ltd", "llc", "co", "corp", "dept", "est", "fig",
  15. "vs", "etc", "ie", "eg", "approx", "no", "vol", "jan", "feb",
  16. "mar", "apr", "jun", "jul", "aug", "sep", "sept", "oct", "nov", "dec",
  17. "a.m", "p.m", "u.s", "u.k",
  18. }
  19. TERMINATORS = ".?!"
  20. CLOSERS = "\"'”’)]"
  21. _WS = re.compile(r"\s")
  22. _LAST_WORD = re.compile(r"([A-Za-z][A-Za-z.]*)$")
  23. def split_sentences(text: str) -> list[dict]:
  24. """Return [{"text": sentence, "offset": start_index}, ...]."""
  25. out: list[dict] = []
  26. start = 0
  27. i = 0
  28. n = len(text)
  29. while i < n:
  30. ch = text[i]
  31. if ch not in TERMINATORS:
  32. i += 1
  33. continue
  34. # Absorb runs of terminators and any closing quote/bracket.
  35. end = i
  36. while end + 1 < n and text[end + 1] in TERMINATORS:
  37. end += 1
  38. while end + 1 < n and text[end + 1] in CLOSERS:
  39. end += 1
  40. # A sentence break needs whitespace (or end of text) after it.
  41. if end + 1 < n and not _WS.match(text[end + 1]):
  42. i = end + 1
  43. continue
  44. # A trailing ellipsis followed by a lowercase word is a pause, not an end.
  45. if end > i and text[i:end + 1] == "." * (end - i + 1):
  46. if re.match(r"\s*[a-z]", text[end + 1:]):
  47. i = end + 1
  48. continue
  49. if ch == ".":
  50. before = text[start:i]
  51. m = _LAST_WORD.search(before)
  52. if m:
  53. bare = m.group(1).lower()
  54. if bare.endswith("."):
  55. bare = bare[:-1]
  56. # Known abbreviation, or a single initial like "J."
  57. if bare in ABBREVIATIONS or re.fullmatch(r"[a-z]", bare):
  58. i = end + 1
  59. continue
  60. # A decimal point: a digit before and after.
  61. if re.search(r"\d$", before) and re.match(r"\s*\d", text[end + 1:]):
  62. i = end + 1
  63. continue
  64. raw = text[start:end + 1]
  65. trimmed = raw.strip()
  66. if trimmed:
  67. out.append({"text": trimmed, "offset": start + (len(raw) - len(raw.lstrip()))})
  68. start = end + 1
  69. i = end + 1
  70. tail = text[start:]
  71. if tail.strip():
  72. out.append({"text": tail.strip(), "offset": start + (len(tail) - len(tail.lstrip()))})
  73. return out