feat: split short speaker handoff gaps

main
Bifang 2026-09-10 16:00:36 +08:00
parent 6243bb30f2
commit 0aa100f127
3 changed files with 79 additions and 2 deletions

View File

@ -105,6 +105,7 @@ python server.py --no-browser
"enable_native_partial_stream": true,
"partial_interval_ms": 1200,
"max_segment_sec": 12,
"speaker_gap_ms": 400,
"display_merge": true
}
```
@ -120,6 +121,10 @@ python server.py --no-browser
`sentence_id` 必须覆盖更新而不是追加。`sentence_strategy=0` 使用约 800ms
静音切句,`sentence_strategy=1` 使用约 1400ms 静音切段,更适合段落模式。
说话人模式下,`speaker_gap_ms`(默认 400ms会在有效语音达到 800ms 后
提前结束短停顿话轮,用于拆开 A→B 的交接;它不触发额外声纹请求,同一说话人的
相邻片段仍由辅助服务聚类为同一说话人。服务未就绪时该策略会自动关闭。
## 目录
- `server.py`:本地 HTTP 页面和 WebSocket 会话编排。

View File

@ -41,6 +41,10 @@ VAD_SILENCE_MS = 800
PARAGRAPH_SILENCE_MS = 1400
VAD_RMS_THRESHOLD = 450
MIN_SPEAKER_VOICE_MS = 800
# 说话人分离开启时,用比普通 VAD 更短的静音作为候选话轮边界。
# 这个边界只负责把 A→B 的短交接停顿拆开,不调用声纹模型,因此不会
# 把窗口推理延迟叠加到音频帧处理路径;同一说话人的拆分片段仍由聚类合并。
SPEAKER_GAP_MS = max(20, int(os.getenv("SPEAKER_GAP_MS", "400")))
LOGGER = logging.getLogger(__name__)
@ -148,6 +152,16 @@ class RealtimeSession:
self.wav_data_remaining: int | None = None
self.speaker_warning_sent = False
self.speaker_enabled = self._parse_flag(start.get("speaker_diarization"), True)
# 没有辅助服务时不提前切段,避免服务不可用时把一段语音拆成许多未知片段。
self.speaker_gap_enabled = self.speaker_enabled and auxiliary_service is not None
raw_speaker_gap = start.get("speaker_gap_ms")
if raw_speaker_gap is None:
self.speaker_gap_ms = SPEAKER_GAP_MS
else:
try:
self.speaker_gap_ms = max(20, int(float(raw_speaker_gap)))
except (TypeError, ValueError, OverflowError):
self.speaker_gap_ms = SPEAKER_GAP_MS
self.input_stopped = False
@staticmethod
@ -422,8 +436,20 @@ class RealtimeSession:
if text:
await self._emit_transcription(text, 0, self.segment_start_ms + self._duration_ms())
last_partial_bytes = len(self.segment_audio)
if self.silence_ms >= self.silence_limit_ms or self._duration_ms() >= self.max_segment_sec * 1000:
await self._commit_segment("silence" if self.silence_ms >= self.silence_limit_ms else "max_duration")
# 短交接停顿优先于普通 800/1400ms 静音切段,但必须先有
# 至少 800ms 有效语音,避免把咳嗽、噪声或极短插话送去聚类。
short_speaker_gap = (
self.speaker_gap_enabled
and self.voiced_ms >= MIN_SPEAKER_VOICE_MS
and self.silence_ms >= self.speaker_gap_ms
)
if short_speaker_gap or self.silence_ms >= self.silence_limit_ms or self._duration_ms() >= self.max_segment_sec * 1000:
reason = (
"speaker_gap" if short_speaker_gap
else "silence" if self.silence_ms >= self.silence_limit_ms
else "max_duration"
)
await self._commit_segment(reason)
last_partial_bytes = 0
else:
# 参考原 WebSocket 保留 200ms 前滚,减少首字低能量音素被裁掉。
@ -571,6 +597,10 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
speaker_health_error = "辅助模型服务未就绪,请检查 /health 返回的 models 状态"
except Exception as exc:
speaker_health_error = f"说话人辅助服务不可用:{exc}"
if speaker_health_error:
# 辅助服务未就绪时先保留原始 VAD 切段,避免在没有声纹结果的
# 情况下增加大量短片段;服务恢复后由新的会话重新启用。
session.speaker_gap_enabled = False
await session.emit(
{
"type": "start",
@ -583,6 +613,8 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
"speaker_diarization_enabled": session.speaker_enabled,
"speaker_service_url": getattr(auxiliary_config, "base_url", None),
"speaker_service_health": speaker_health,
"speaker_gap_enabled": session.speaker_gap_enabled,
"speaker_gap_ms": session.speaker_gap_ms,
"sentence_strategy": session.sentence_strategy,
"silence_limit_ms": session.silence_limit_ms,
"display_state_supported": True,

View File

@ -140,6 +140,45 @@ class WebSocketFlowTests(AioHTTPTestCase):
self.assertEqual(final["sentences"][0]["end_time"], 1000)
self.assertEqual(len(final["display_blocks"]), 2)
async def test_short_speaker_gap_splits_turn_before_next_speaker(self):
"""说话人模式在短交接停顿处切段,保持每段独立进入在线聚类。"""
ws = await self.client.ws_connect("/ws")
await ws.send_json(
{
"type": "start",
"source": "mic",
"speaker_diarization": 1,
"speaker_gap_ms": 400,
"partial_interval_ms": 1200,
}
)
self.assertEqual((await ws.receive_json())["type"], "start")
voiced = b"\xe8\x03" * 16000 # 每位说话人一秒有效语音
handoff_gap = b"\x00\x00" * 6400 # 400ms低于原始 800ms 切段阈值
trailing_silence = b"\x00\x00" * 12800
await ws.send_bytes(voiced + handoff_gap + voiced + trailing_silence)
await ws.send_json({"type": "eof"})
events: list[dict[str, object]] = []
while True:
message = await ws.receive_json()
events.append(message)
if message["type"] == "end":
break
final_sentences = [
message["sentences"][0]
for message in events
if message["type"] == "sentences" and message["sentences"][0]["sentence_type"] == 1
]
latest_by_id = {int(item["sentence_id"]): item for item in final_sentences}
latest = [latest_by_id[index] for index in sorted(latest_by_id)]
self.assertEqual(len(latest), 2)
self.assertEqual([item["speaker_id"] for item in latest], [0, 1])
self.assertEqual(self.app[AUXILIARY_SERVICE_KEY].calls, 2)
await ws.close()
async def test_stop_waits_for_slow_speaker_and_includes_final_snapshot(self):
"""在 end 前必须收到所有声纹结果,不能复现页面原先五秒断开的行为。"""
auxiliary = self.app[AUXILIARY_SERVICE_KEY]
@ -306,6 +345,7 @@ class WebSocketFlowTests(AioHTTPTestCase):
self.assertEqual(start["type"], "start")
self.assertFalse(start["speaker_service_health"]["ready"])
self.assertFalse(start["speaker_gap_enabled"])
self.assertEqual(warning["type"], "speaker_warning")
self.assertIn("未就绪", warning["message"])
await ws.close()