transcript_processor.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. """
  2. Transcript Processor — deduplicates, stitches overlapping ASR windows,
  3. detects utterance boundaries, and formats streaming output for the client.
  4. Flow:
  5. raw ASR transcript (sliding window, every ~2 s)
  6. _stitch() — overlap-merge with accumulated buffer
  7. _detect_boundary() — same text repeated = silence = utterance end
  8. ├── partial → {"type": "partial", "text": accumulated, "seq": N}
  9. └── (after 3× same text + 3 s silence)
  10. utterance → {"type": "utterance", "text": full, "seq": N}
  11. """
  12. import time
  13. from dataclasses import dataclass, field
  14. from typing import Optional, Callable, Awaitable
  15. @dataclass
  16. class _Utterance:
  17. """Tracks a single utterance being built."""
  18. accumulated: str = ""
  19. last_raw: str = ""
  20. same_count: int = 0
  21. silence_since: float = 0.0
  22. finalized: bool = False
  23. seq: int = 0 # monotonic counter for this utterance
  24. class TranscriptProcessor:
  25. """
  26. Processes streaming ASR transcripts into clean, segmented utterances.
  27. Pipeline (extensible):
  28. 1. stitch — overlap-based deduplication
  29. 2. boundary — silence-based utterance segmentation
  30. 3. post — (future) LLM-based punctuation/correction
  31. """
  32. def __init__(
  33. self,
  34. silence_repeats: int = 3, # same-raw-text count before silence kicks in
  35. silence_seconds: float = 3.0, # timer after silence detection
  36. post_hook: Optional[Callable[[str], Awaitable[str]]] = None,
  37. ):
  38. self.silence_repeats = silence_repeats
  39. self.silence_seconds = silence_seconds
  40. self._post_hook = post_hook # LLM correction hook (future)
  41. self._current: Optional[_Utterance] = None
  42. self._global_seq = 0
  43. # ------------------------------------------------------------------
  44. # Public API
  45. # ------------------------------------------------------------------
  46. def feed(self, raw_text: str) -> dict | None:
  47. """
  48. Feed a raw ASR transcript. Returns a message dict to send to the
  49. client, or None if nothing should be sent this tick.
  50. Message shapes:
  51. {"type": "partial", "text": "...", "seq": N}
  52. {"type": "utterance", "text": "...", "seq": N}
  53. """
  54. raw_text = raw_text.strip()
  55. self._ensure_utterance()
  56. # ---- 1. stitch ----
  57. if self._current.accumulated:
  58. merged = self._stitch(self._current.accumulated, raw_text)
  59. else:
  60. merged = raw_text
  61. self._current.accumulated = merged
  62. # ---- 2. boundary detection ----
  63. if raw_text == self._current.last_raw:
  64. self._current.same_count += 1
  65. if self._current.same_count >= self.silence_repeats:
  66. if self._current.silence_since == 0.0:
  67. self._current.silence_since = time.monotonic()
  68. else:
  69. self._current.same_count = 0
  70. self._current.silence_since = 0.0
  71. self._global_seq += 1
  72. self._current.last_raw = raw_text
  73. return {"type": "partial", "text": merged, "seq": self._global_seq}
  74. # ---- 3. silence timer ----
  75. if (
  76. self._current.silence_since > 0
  77. and time.monotonic() - self._current.silence_since >= self.silence_seconds
  78. ):
  79. return self._finalize()
  80. return None
  81. def finalize(self) -> dict | None:
  82. """Force-finalize the current utterance (e.g. on pause/end)."""
  83. self._ensure_utterance()
  84. return self._finalize()
  85. # ------------------------------------------------------------------
  86. # Internals
  87. # ------------------------------------------------------------------
  88. def _ensure_utterance(self):
  89. if self._current is None:
  90. self._current = _Utterance(seq=self._global_seq)
  91. def _stitch(self, previous: str, next_text: str) -> str:
  92. """
  93. Stitch two overlapping transcripts by finding the longest suffix of
  94. *previous* that matches a prefix of *next_text*, then appending only
  95. the non-overlapping suffix. If there is no overlap, *next_text* is a
  96. fresh utterance — finalize current and return *next_text* alone.
  97. """
  98. if not next_text:
  99. return previous
  100. if next_text.startswith(previous):
  101. return next_text
  102. # Longest suffix–prefix overlap
  103. for n in range(len(next_text), 0, -1):
  104. needle = next_text[:n]
  105. if previous.endswith(needle):
  106. return previous + next_text[n:]
  107. # No overlap → new utterance
  108. self._finalize()
  109. self._current = _Utterance(seq=self._global_seq)
  110. return next_text
  111. def _finalize(self) -> dict | None:
  112. """Finalize current utterance and return the utterance message."""
  113. if self._current is None:
  114. return None
  115. text = self._current.accumulated.strip()
  116. self._current = None
  117. self._global_seq += 1
  118. if not text:
  119. return None
  120. return {"type": "utterance", "text": text, "seq": self._global_seq}