|
|
@@ -53,6 +53,10 @@ class FrameSegmenter:
|
|
|
silence_ms: int,
|
|
|
min_utterance_ms: int,
|
|
|
max_utterance_s: float,
|
|
|
+ chunk_pause_ms: int = 350,
|
|
|
+ soft_max_s: float = 10.0,
|
|
|
+ rms_floor: float = 0.008,
|
|
|
+ trailing_trim_ms: int = 200,
|
|
|
):
|
|
|
"""Initialize the segmenter.
|
|
|
|
|
|
@@ -62,9 +66,13 @@ class FrameSegmenter:
|
|
|
vad: webrtcvad.Vad instance (already created).
|
|
|
pre_roll_ms: Duration of pre-roll buffer in ms.
|
|
|
speech_start_ms: Consecutive speech frames to confirm speech start.
|
|
|
- silence_ms: Consecutive silence frames to end an utterance.
|
|
|
- min_utterance_ms: Minimum utterance duration to keep (discard blips).
|
|
|
- max_utterance_s: Maximum utterance duration before force-finalize.
|
|
|
+ silence_ms: Consecutive silence frames that end a turn.
|
|
|
+ min_utterance_ms: Minimum actual speech to keep (discard blips).
|
|
|
+ max_utterance_s: Hard cap; force-finalize a very long utterance.
|
|
|
+ chunk_pause_ms: Shorter pause that may end a chunk once soft_max_s is reached.
|
|
|
+ soft_max_s: Speech duration after which chunk-ending pauses become eligible.
|
|
|
+ rms_floor: Discard utterances quieter than this RMS (noise that tripped the VAD).
|
|
|
+ trailing_trim_ms: Trailing silence to keep (the rest is trimmed before transcription).
|
|
|
"""
|
|
|
self.sample_rate = sample_rate
|
|
|
self.frame_ms = frame_ms
|
|
|
@@ -72,11 +80,16 @@ class FrameSegmenter:
|
|
|
|
|
|
# Frame-based counters and buffers
|
|
|
self.frame_bytes = round(sample_rate * frame_ms / 1000) * 2 # s16le = 2 bytes/sample
|
|
|
+ self.frame_samples = self.frame_bytes // 2
|
|
|
self.pre_roll_frames = max(1, round(pre_roll_ms / frame_ms))
|
|
|
self.start_frames = max(1, round(speech_start_ms / frame_ms))
|
|
|
self.silence_frames = max(1, round(silence_ms / frame_ms))
|
|
|
self.min_frames = max(1, round(min_utterance_ms / frame_ms))
|
|
|
self.max_frames = max(1, round(max_utterance_s * 1000 / frame_ms))
|
|
|
+ self.chunk_pause_frames = max(1, round(chunk_pause_ms / frame_ms))
|
|
|
+ self.soft_max_frames = max(1, round(soft_max_s * 1000 / frame_ms))
|
|
|
+ self.trailing_trim_frames = max(0, round(trailing_trim_ms / frame_ms))
|
|
|
+ self.rms_floor = rms_floor
|
|
|
|
|
|
# Ring buffer for pre-speech frames
|
|
|
self.ring: collections.deque[bytes] = collections.deque(maxlen=self.pre_roll_frames)
|
|
|
@@ -123,13 +136,20 @@ class FrameSegmenter:
|
|
|
self.silence_run += 1
|
|
|
|
|
|
if self.silence_run >= self.silence_frames:
|
|
|
- # Silence finalized
|
|
|
+ # End of turn: the speaker stopped.
|
|
|
return self._finish(keep_tail=True)
|
|
|
|
|
|
if len(self.segment) >= self.max_frames:
|
|
|
- # Max duration exceeded
|
|
|
+ # Hard cap (speaker never paused) - must cut somewhere.
|
|
|
return self._finish(keep_tail=False)
|
|
|
|
|
|
+ if (
|
|
|
+ len(self.segment) >= self.soft_max_frames
|
|
|
+ and self.silence_run >= self.chunk_pause_frames
|
|
|
+ ):
|
|
|
+ # Long speech: end a chunk at a natural pause instead of shipping a huge blob.
|
|
|
+ return self._finish(keep_tail=True)
|
|
|
+
|
|
|
return None
|
|
|
|
|
|
def _begin(self) -> None:
|
|
|
@@ -149,12 +169,13 @@ class FrameSegmenter:
|
|
|
return None
|
|
|
|
|
|
def _finish(self, keep_tail: bool) -> Utterance | None:
|
|
|
- """Finalize and emit an utterance."""
|
|
|
+ """Finalize and emit an utterance, or discard it if it is a blip or too quiet."""
|
|
|
frames = self.segment
|
|
|
+ trailing_silence = self.silence_run
|
|
|
|
|
|
# Optionally keep trailing silence frames to seed next pre-roll
|
|
|
- if keep_tail and self.silence_run > 0:
|
|
|
- tail = frames[-self.silence_run :]
|
|
|
+ if keep_tail and trailing_silence > 0:
|
|
|
+ tail = frames[-trailing_silence:]
|
|
|
else:
|
|
|
tail = []
|
|
|
|
|
|
@@ -175,8 +196,20 @@ class FrameSegmenter:
|
|
|
return None
|
|
|
|
|
|
# Convert frames to audio
|
|
|
- raw = b"".join(frames)
|
|
|
- audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
|
|
|
+ audio = np.frombuffer(b"".join(frames), dtype=np.int16).astype(np.float32) / 32768.0
|
|
|
+
|
|
|
+ # Trim most of the trailing silence: pure padding invites Whisper hallucinations.
|
|
|
+ dropped = max(0, trailing_silence - self.trailing_trim_frames)
|
|
|
+ if dropped:
|
|
|
+ audio = audio[: max(0, len(audio) - dropped * self.frame_samples)]
|
|
|
+
|
|
|
+ if audio.size == 0:
|
|
|
+ return None
|
|
|
+
|
|
|
+ # Drop segments too quiet to be real speech (noise that tripped the VAD).
|
|
|
+ rms = float(np.sqrt(np.mean(audio**2)))
|
|
|
+ if rms < self.rms_floor:
|
|
|
+ return None
|
|
|
|
|
|
return Utterance(
|
|
|
audio=audio,
|