feat: split short speaker handoff gaps
parent
6243bb30f2
commit
0aa100f127
|
|
@ -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 会话编排。
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue