| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150 |
- """
- Transcript Processor — deduplicates, stitches overlapping ASR windows,
- detects utterance boundaries, and formats streaming output for the client.
- Flow:
- raw ASR transcript (sliding window, every ~2 s)
- │
- ▼
- _stitch() — overlap-merge with accumulated buffer
- │
- ▼
- _detect_boundary() — same text repeated = silence = utterance end
- │
- ├── partial → {"type": "partial", "text": accumulated, "seq": N}
- │
- └── (after 3× same text + 3 s silence)
- utterance → {"type": "utterance", "text": full, "seq": N}
- """
- import time
- from dataclasses import dataclass, field
- from typing import Optional, Callable, Awaitable
- @dataclass
- class _Utterance:
- """Tracks a single utterance being built."""
- accumulated: str = ""
- last_raw: str = ""
- same_count: int = 0
- silence_since: float = 0.0
- finalized: bool = False
- seq: int = 0 # monotonic counter for this utterance
- class TranscriptProcessor:
- """
- Processes streaming ASR transcripts into clean, segmented utterances.
- Pipeline (extensible):
- 1. stitch — overlap-based deduplication
- 2. boundary — silence-based utterance segmentation
- 3. post — (future) LLM-based punctuation/correction
- """
- def __init__(
- self,
- silence_repeats: int = 3, # same-raw-text count before silence kicks in
- silence_seconds: float = 3.0, # timer after silence detection
- post_hook: Optional[Callable[[str], Awaitable[str]]] = None,
- ):
- self.silence_repeats = silence_repeats
- self.silence_seconds = silence_seconds
- self._post_hook = post_hook # LLM correction hook (future)
- self._current: Optional[_Utterance] = None
- self._global_seq = 0
- # ------------------------------------------------------------------
- # Public API
- # ------------------------------------------------------------------
- def feed(self, raw_text: str) -> dict | None:
- """
- Feed a raw ASR transcript. Returns a message dict to send to the
- client, or None if nothing should be sent this tick.
- Message shapes:
- {"type": "partial", "text": "...", "seq": N}
- {"type": "utterance", "text": "...", "seq": N}
- """
- raw_text = raw_text.strip()
- self._ensure_utterance()
- # ---- 1. stitch ----
- if self._current.accumulated:
- merged = self._stitch(self._current.accumulated, raw_text)
- else:
- merged = raw_text
- self._current.accumulated = merged
- # ---- 2. boundary detection ----
- if raw_text == self._current.last_raw:
- self._current.same_count += 1
- if self._current.same_count >= self.silence_repeats:
- if self._current.silence_since == 0.0:
- self._current.silence_since = time.monotonic()
- else:
- self._current.same_count = 0
- self._current.silence_since = 0.0
- self._global_seq += 1
- self._current.last_raw = raw_text
- return {"type": "partial", "text": merged, "seq": self._global_seq}
- # ---- 3. silence timer ----
- if (
- self._current.silence_since > 0
- and time.monotonic() - self._current.silence_since >= self.silence_seconds
- ):
- return self._finalize()
- return None
- def finalize(self) -> dict | None:
- """Force-finalize the current utterance (e.g. on pause/end)."""
- self._ensure_utterance()
- return self._finalize()
- # ------------------------------------------------------------------
- # Internals
- # ------------------------------------------------------------------
- def _ensure_utterance(self):
- if self._current is None:
- self._current = _Utterance(seq=self._global_seq)
- def _stitch(self, previous: str, next_text: str) -> str:
- """
- Stitch two overlapping transcripts by finding the longest suffix of
- *previous* that matches a prefix of *next_text*, then appending only
- the non-overlapping suffix. If there is no overlap, *next_text* is a
- fresh utterance — finalize current and return *next_text* alone.
- """
- if not next_text:
- return previous
- if next_text.startswith(previous):
- return next_text
- # Longest suffix–prefix overlap
- for n in range(len(next_text), 0, -1):
- needle = next_text[:n]
- if previous.endswith(needle):
- return previous + next_text[n:]
- # No overlap → new utterance
- self._finalize()
- self._current = _Utterance(seq=self._global_seq)
- return next_text
- def _finalize(self) -> dict | None:
- """Finalize current utterance and return the utterance message."""
- if self._current is None:
- return None
- text = self._current.accumulated.strip()
- self._current = None
- self._global_seq += 1
- if not text:
- return None
- return {"type": "utterance", "text": text, "seq": self._global_seq}
|