Align Qwen realtime ASR with FunASR flow
parent
fd621ab66e
commit
c07a51a010
11
.env.example
11
.env.example
|
|
@ -118,6 +118,17 @@ QWEN3_ASR_MODEL=qwen3-asr-0.6b
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# 调优参数。除非你在做特定瓶颈测试,否则建议保持默认。
|
# 调优参数。除非你在做特定瓶颈测试,否则建议保持默认。
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
# REALTIME_STREAM_CHUNK_SEC:Qwen 流式解码间隔;0.6 秒参考 FunASR 每 10 个 60ms 音频帧解码一次。
|
||||||
|
# Qwen 每次解码会复用累计音频,缩短该值会降低显示延迟,也会增加推理开销。
|
||||||
|
# REALTIME_STREAM_CHUNK_SEC=0.6
|
||||||
|
# REALTIME_STREAM_MAX_PENDING_CHUNKS:Qwen 流式状态保留的待修订块数。
|
||||||
|
# REALTIME_STREAM_MAX_PENDING_CHUNKS=3
|
||||||
|
# REALTIME_ENABLE_VAD:启用 FSMN-VAD 流式端点和气泡时间边界。
|
||||||
|
# REALTIME_ENABLE_VAD=true
|
||||||
|
# REALTIME_VAD_CHUNK_MS:每次送入 FSMN-VAD 的音频块长度;参考 FunASR 示例使用 200ms。
|
||||||
|
# REALTIME_VAD_CHUNK_MS=200
|
||||||
|
# REALTIME_VAD_PRE_ROLL_MS:检测到语音起点时保留的前置音频,避免切掉首字。
|
||||||
|
# REALTIME_VAD_PRE_ROLL_MS=600
|
||||||
# ASR_BATCH_SIZE:批处理大小,表示一次并行推理的片段数。
|
# ASR_BATCH_SIZE:批处理大小,表示一次并行推理的片段数。
|
||||||
# ASR_BATCH_SIZE=4
|
# ASR_BATCH_SIZE=4
|
||||||
# ASR_ENABLE_WORD_TIMESTAMPS:是否启用字词级时间戳。
|
# ASR_ENABLE_WORD_TIMESTAMPS:是否启用字词级时间戳。
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,9 @@ class Settings:
|
||||||
REALTIME_MIN_PARTIAL_SEC: float = 0.45
|
REALTIME_MIN_PARTIAL_SEC: float = 0.45
|
||||||
REALTIME_PARTIAL_EMIT_INTERVAL_SEC: float = 0.25
|
REALTIME_PARTIAL_EMIT_INTERVAL_SEC: float = 0.25
|
||||||
REALTIME_PARTIAL_WINDOW_SEC: float = 8.0
|
REALTIME_PARTIAL_WINDOW_SEC: float = 8.0
|
||||||
REALTIME_STREAM_CHUNK_SEC: float = 1.2
|
# Match FunASR's roughly 600 ms online decode cadence; Qwen re-decodes the
|
||||||
|
# accumulated stream, so this stays configurable for server-side tuning.
|
||||||
|
REALTIME_STREAM_CHUNK_SEC: float = 0.6
|
||||||
REALTIME_STREAM_MAX_PENDING_CHUNKS: int = 3
|
REALTIME_STREAM_MAX_PENDING_CHUNKS: int = 3
|
||||||
REALTIME_STREAM_WINDOW_SEC: float = 8.0
|
REALTIME_STREAM_WINDOW_SEC: float = 8.0
|
||||||
REALTIME_STREAM_STABLE_TAIL_CHARS: int = 8
|
REALTIME_STREAM_STABLE_TAIL_CHARS: int = 8
|
||||||
|
|
@ -119,6 +121,9 @@ class Settings:
|
||||||
REALTIME_LONGFORM_OVERLAP_SEC: float = 1.2
|
REALTIME_LONGFORM_OVERLAP_SEC: float = 1.2
|
||||||
REALTIME_VAD_CHECK_INTERVAL_SEC: float = 0.8
|
REALTIME_VAD_CHECK_INTERVAL_SEC: float = 0.8
|
||||||
REALTIME_VAD_FINALIZE_SILENCE_SEC: float = 0.6
|
REALTIME_VAD_FINALIZE_SILENCE_SEC: float = 0.6
|
||||||
|
REALTIME_ENABLE_VAD: bool = True
|
||||||
|
REALTIME_VAD_CHUNK_MS: int = 200
|
||||||
|
REALTIME_VAD_PRE_ROLL_MS: int = 600
|
||||||
REALTIME_FINAL_SEGMENT_SOFT_LIMIT_SEC: float = 8.0
|
REALTIME_FINAL_SEGMENT_SOFT_LIMIT_SEC: float = 8.0
|
||||||
REALTIME_FINAL_SEGMENT_HARD_LIMIT_SEC: float = 12.0
|
REALTIME_FINAL_SEGMENT_HARD_LIMIT_SEC: float = 12.0
|
||||||
REALTIME_ENABLE_DIARIZATION: bool = True
|
REALTIME_ENABLE_DIARIZATION: bool = True
|
||||||
|
|
@ -383,6 +388,16 @@ class Settings:
|
||||||
str(self.REALTIME_VAD_FINALIZE_SILENCE_SEC),
|
str(self.REALTIME_VAD_FINALIZE_SILENCE_SEC),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.REALTIME_ENABLE_VAD = (
|
||||||
|
os.getenv("REALTIME_ENABLE_VAD", str(self.REALTIME_ENABLE_VAD)).lower()
|
||||||
|
== "true"
|
||||||
|
)
|
||||||
|
self.REALTIME_VAD_CHUNK_MS = int(
|
||||||
|
os.getenv("REALTIME_VAD_CHUNK_MS", str(self.REALTIME_VAD_CHUNK_MS))
|
||||||
|
)
|
||||||
|
self.REALTIME_VAD_PRE_ROLL_MS = int(
|
||||||
|
os.getenv("REALTIME_VAD_PRE_ROLL_MS", str(self.REALTIME_VAD_PRE_ROLL_MS))
|
||||||
|
)
|
||||||
self.REALTIME_FINAL_SEGMENT_SOFT_LIMIT_SEC = float(
|
self.REALTIME_FINAL_SEGMENT_SOFT_LIMIT_SEC = float(
|
||||||
os.getenv(
|
os.getenv(
|
||||||
"REALTIME_FINAL_SEGMENT_SOFT_LIMIT_SEC",
|
"REALTIME_FINAL_SEGMENT_SOFT_LIMIT_SEC",
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,8 @@ def _handle_asr_error(operation: str):
|
||||||
@dataclass
|
@dataclass
|
||||||
class Qwen3StreamingState:
|
class Qwen3StreamingState:
|
||||||
internal_state: Any
|
internal_state: Any
|
||||||
chunk_size_sec: float = 1.2
|
chunk_size_sec: float = 0.6
|
||||||
unfixed_chunk_num: int = 2
|
unfixed_chunk_num: int = 3
|
||||||
unfixed_token_num: int = 5
|
unfixed_token_num: int = 5
|
||||||
max_new_tokens: int = 32
|
max_new_tokens: int = 32
|
||||||
language: Optional[str] = None
|
language: Optional[str] = None
|
||||||
|
|
@ -576,8 +576,8 @@ class Qwen3ASREngine(BaseASREngine):
|
||||||
if self._backend == "rust":
|
if self._backend == "rust":
|
||||||
if context:
|
if context:
|
||||||
logger.debug("QwenASR Rust backend ignores streaming context hints")
|
logger.debug("QwenASR Rust backend ignores streaming context hints")
|
||||||
chunk_size_sec = float(kwargs.get("chunk_size_sec", 1.2))
|
chunk_size_sec = float(kwargs.get("chunk_size_sec", 0.6))
|
||||||
unfixed_chunk_num = int(kwargs.get("unfixed_chunk_num", 2))
|
unfixed_chunk_num = int(kwargs.get("unfixed_chunk_num", 3))
|
||||||
unfixed_token_num = int(kwargs.get("unfixed_token_num", 5))
|
unfixed_token_num = int(kwargs.get("unfixed_token_num", 5))
|
||||||
max_new_tokens = int(kwargs.get("max_new_tokens", 32))
|
max_new_tokens = int(kwargs.get("max_new_tokens", 32))
|
||||||
stream_handle = self.model.create_stream(
|
stream_handle = self.model.create_stream(
|
||||||
|
|
@ -607,8 +607,8 @@ class Qwen3ASREngine(BaseASREngine):
|
||||||
)
|
)
|
||||||
return Qwen3StreamingState(
|
return Qwen3StreamingState(
|
||||||
internal_state=streaming_state,
|
internal_state=streaming_state,
|
||||||
chunk_size_sec=float(kwargs.get("chunk_size_sec", 1.2)),
|
chunk_size_sec=float(kwargs.get("chunk_size_sec", 0.6)),
|
||||||
unfixed_chunk_num=int(kwargs.get("unfixed_chunk_num", 2)),
|
unfixed_chunk_num=int(kwargs.get("unfixed_chunk_num", 3)),
|
||||||
unfixed_token_num=int(kwargs.get("unfixed_token_num", 5)),
|
unfixed_token_num=int(kwargs.get("unfixed_token_num", 5)),
|
||||||
max_new_tokens=int(kwargs.get("max_new_tokens", 32)),
|
max_new_tokens=int(kwargs.get("max_new_tokens", 32)),
|
||||||
language=language,
|
language=language,
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,15 @@ class ConnectionContext:
|
||||||
engine_lease: Optional[RuntimeEngineLease] = None
|
engine_lease: Optional[RuntimeEngineLease] = None
|
||||||
engine: Optional[Qwen3ASREngine] = None
|
engine: Optional[Qwen3ASREngine] = None
|
||||||
pre_roll_audio: np.ndarray = field(default_factory=lambda: np.array([], dtype=np.float32))
|
pre_roll_audio: np.ndarray = field(default_factory=lambda: np.array([], dtype=np.float32))
|
||||||
|
pre_roll_start_sample: int = 0
|
||||||
|
audio_samples_received: int = 0
|
||||||
|
segment_buffer_start_sample: int = 0
|
||||||
|
# FSMN-VAD cache and pending samples are private to this WebSocket stream.
|
||||||
|
vad_cache: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
vad_pending_audio: np.ndarray = field(default_factory=lambda: np.array([], dtype=np.float32))
|
||||||
|
vad_speech_active: bool = False
|
||||||
|
vad_speech_start_sample: Optional[int] = None
|
||||||
|
vad_failed: bool = False
|
||||||
# segment_audio_buffer 保留“当前断句以来”的完整音频,用于断句时提取声纹。
|
# segment_audio_buffer 保留“当前断句以来”的完整音频,用于断句时提取声纹。
|
||||||
segment_audio_buffer: np.ndarray = field(default_factory=lambda: np.array([], dtype=np.float32))
|
segment_audio_buffer: np.ndarray = field(default_factory=lambda: np.array([], dtype=np.float32))
|
||||||
# stream_window_buffer 保留最近窗口,供 realtime partial 在 native stream 失效时兜底重转写。
|
# stream_window_buffer 保留最近窗口,供 realtime partial 在 native stream 失效时兜底重转写。
|
||||||
|
|
@ -266,6 +275,7 @@ class ConnectionContext:
|
||||||
last_partial_chunk_id: int = 0
|
last_partial_chunk_id: int = 0
|
||||||
last_partial_raw_text: str = ""
|
last_partial_raw_text: str = ""
|
||||||
last_partial_display_text: str = ""
|
last_partial_display_text: str = ""
|
||||||
|
partial_preview_text: str = ""
|
||||||
stable_partial_prefix: str = ""
|
stable_partial_prefix: str = ""
|
||||||
pending_partial_revision_text: str = ""
|
pending_partial_revision_text: str = ""
|
||||||
pending_partial_revision_rounds: int = 0
|
pending_partial_revision_rounds: int = 0
|
||||||
|
|
@ -1070,7 +1080,163 @@ class Qwen3ASRService:
|
||||||
return text[-limit:]
|
return text[-limit:]
|
||||||
|
|
||||||
def _enable_realtime_vad_split(self, ctx: ConnectionContext) -> bool:
|
def _enable_realtime_vad_split(self, ctx: ConnectionContext) -> bool:
|
||||||
return bool(ctx.params.get("enable_realtime_vad_split", False))
|
return bool(
|
||||||
|
ctx.params.get(
|
||||||
|
"enable_realtime_vad_split",
|
||||||
|
getattr(settings, "REALTIME_ENABLE_VAD", True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _enable_realtime_vad(self, ctx: ConnectionContext) -> bool:
|
||||||
|
if str(ctx.params.get("format", "pcm")).lower() == "wav":
|
||||||
|
# Uploaded WAV is delivered as one complete message, not live frames.
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
ctx.params.get(
|
||||||
|
"enable_realtime_vad",
|
||||||
|
getattr(settings, "REALTIME_ENABLE_VAD", True),
|
||||||
|
)
|
||||||
|
) and not ctx.vad_failed
|
||||||
|
|
||||||
|
async def _warm_realtime_vad(self, ctx: ConnectionContext) -> None:
|
||||||
|
"""Load the shared FSMN-VAD model before the first speech boundary is needed."""
|
||||||
|
if not self._enable_realtime_vad(ctx):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await run_sync(get_global_vad_model, settings.DEVICE)
|
||||||
|
except Exception as exc:
|
||||||
|
# Realtime energy gating remains available if the optional VAD model fails.
|
||||||
|
ctx.vad_failed = True
|
||||||
|
logger.warning("Realtime FSMN-VAD unavailable; using energy fallback: %s", exc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_realtime_vad_chunks(
|
||||||
|
chunks: List[np.ndarray],
|
||||||
|
cache: Dict[str, Any],
|
||||||
|
chunk_size_ms: int,
|
||||||
|
*,
|
||||||
|
is_final: bool = False,
|
||||||
|
) -> List[List[List[int]]]:
|
||||||
|
"""Run cached FSMN-VAD inference over fixed 200 ms mono audio frames."""
|
||||||
|
vad_model = get_global_vad_model(settings.DEVICE)
|
||||||
|
results: List[List[List[int]]] = []
|
||||||
|
with get_vad_inference_lock():
|
||||||
|
for index, chunk in enumerate(chunks):
|
||||||
|
output = vad_model.generate(
|
||||||
|
input=np.asarray(chunk, dtype=np.float32),
|
||||||
|
cache=cache,
|
||||||
|
is_final=is_final and index == len(chunks) - 1,
|
||||||
|
chunk_size=chunk_size_ms,
|
||||||
|
disable_pbar=True,
|
||||||
|
)
|
||||||
|
values = output[0].get("value", []) if output else []
|
||||||
|
results.append(values or [])
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def _push_realtime_vad_audio(
|
||||||
|
self,
|
||||||
|
ctx: ConnectionContext,
|
||||||
|
audio: np.ndarray,
|
||||||
|
) -> List[tuple[int, int]]:
|
||||||
|
"""Feed each stream's FSMN-VAD cache and return completed absolute boundaries."""
|
||||||
|
if audio.size == 0 or not self._enable_realtime_vad(ctx):
|
||||||
|
return []
|
||||||
|
|
||||||
|
chunk_size_ms = max(
|
||||||
|
int(getattr(settings, "REALTIME_VAD_CHUNK_MS", 200) or 200),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
chunk_samples = max(int(chunk_size_ms * 16), 1600)
|
||||||
|
ctx.vad_pending_audio = np.concatenate([ctx.vad_pending_audio, audio])
|
||||||
|
complete_count = int(ctx.vad_pending_audio.size // chunk_samples)
|
||||||
|
if complete_count <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
split_at = complete_count * chunk_samples
|
||||||
|
chunks = [
|
||||||
|
np.asarray(ctx.vad_pending_audio[offset:offset + chunk_samples], dtype=np.float32)
|
||||||
|
for offset in range(0, split_at, chunk_samples)
|
||||||
|
]
|
||||||
|
ctx.vad_pending_audio = np.asarray(ctx.vad_pending_audio[split_at:], dtype=np.float32)
|
||||||
|
|
||||||
|
try:
|
||||||
|
outputs = await run_sync(
|
||||||
|
self._generate_realtime_vad_chunks,
|
||||||
|
chunks,
|
||||||
|
ctx.vad_cache,
|
||||||
|
chunk_size_ms,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
ctx.vad_failed = True
|
||||||
|
ctx.vad_speech_active = False
|
||||||
|
logger.warning("Realtime FSMN-VAD inference failed; using energy fallback: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
completed: List[tuple[int, int]] = []
|
||||||
|
for values in outputs:
|
||||||
|
for value in values:
|
||||||
|
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
||||||
|
continue
|
||||||
|
start_ms, end_ms = int(value[0]), int(value[1])
|
||||||
|
if start_ms >= 0:
|
||||||
|
ctx.vad_speech_start_sample = start_ms * 16
|
||||||
|
ctx.vad_speech_active = True
|
||||||
|
if end_ms >= 0:
|
||||||
|
start_sample = (
|
||||||
|
start_ms * 16
|
||||||
|
if start_ms >= 0
|
||||||
|
else ctx.vad_speech_start_sample
|
||||||
|
)
|
||||||
|
if start_sample is not None and end_ms * 16 > start_sample:
|
||||||
|
completed.append((int(start_sample), end_ms * 16))
|
||||||
|
ctx.vad_speech_active = False
|
||||||
|
ctx.vad_speech_start_sample = None
|
||||||
|
return completed
|
||||||
|
|
||||||
|
async def _flush_realtime_vad(self, ctx: ConnectionContext) -> List[tuple[int, int]]:
|
||||||
|
"""Flush the final short VAD frame so stop can use its last boundary."""
|
||||||
|
if not self._enable_realtime_vad(ctx) or ctx.vad_pending_audio.size == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
chunk_size_ms = max(
|
||||||
|
int(getattr(settings, "REALTIME_VAD_CHUNK_MS", 200) or 200),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
final_chunk = np.asarray(ctx.vad_pending_audio, dtype=np.float32)
|
||||||
|
ctx.vad_pending_audio = np.array([], dtype=np.float32)
|
||||||
|
try:
|
||||||
|
outputs = await run_sync(
|
||||||
|
self._generate_realtime_vad_chunks,
|
||||||
|
[final_chunk],
|
||||||
|
ctx.vad_cache,
|
||||||
|
chunk_size_ms,
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
ctx.vad_failed = True
|
||||||
|
logger.warning("Realtime FSMN-VAD flush failed; using energy fallback: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
completed: List[tuple[int, int]] = []
|
||||||
|
for values in outputs:
|
||||||
|
for value in values:
|
||||||
|
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
||||||
|
continue
|
||||||
|
start_ms, end_ms = int(value[0]), int(value[1])
|
||||||
|
if start_ms >= 0:
|
||||||
|
ctx.vad_speech_start_sample = start_ms * 16
|
||||||
|
ctx.vad_speech_active = True
|
||||||
|
if end_ms >= 0:
|
||||||
|
start_sample = (
|
||||||
|
start_ms * 16
|
||||||
|
if start_ms >= 0
|
||||||
|
else ctx.vad_speech_start_sample
|
||||||
|
)
|
||||||
|
if start_sample is not None and end_ms * 16 > start_sample:
|
||||||
|
completed.append((int(start_sample), end_ms * 16))
|
||||||
|
ctx.vad_speech_active = False
|
||||||
|
ctx.vad_speech_start_sample = None
|
||||||
|
return completed
|
||||||
|
|
||||||
def _enable_realtime_longform(self, ctx: ConnectionContext) -> bool:
|
def _enable_realtime_longform(self, ctx: ConnectionContext) -> bool:
|
||||||
return bool(ctx.params.get("enable_realtime_longform", False))
|
return bool(ctx.params.get("enable_realtime_longform", False))
|
||||||
|
|
@ -1285,25 +1451,47 @@ class Qwen3ASRService:
|
||||||
)
|
)
|
||||||
return fallback, ""
|
return fallback, ""
|
||||||
|
|
||||||
def _append_pre_roll(self, ctx: ConnectionContext, audio: np.ndarray) -> None:
|
def _append_pre_roll(
|
||||||
|
self,
|
||||||
|
ctx: ConnectionContext,
|
||||||
|
audio: np.ndarray,
|
||||||
|
*,
|
||||||
|
start_sample: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
if audio.size == 0:
|
if audio.size == 0:
|
||||||
return
|
return
|
||||||
|
if ctx.pre_roll_audio.size == 0 and start_sample is not None:
|
||||||
|
ctx.pre_roll_start_sample = int(start_sample)
|
||||||
ctx.pre_roll_audio = np.concatenate([ctx.pre_roll_audio, audio])
|
ctx.pre_roll_audio = np.concatenate([ctx.pre_roll_audio, audio])
|
||||||
max_samples = self._pre_roll_samples(ctx)
|
max_samples = self._pre_roll_samples(ctx)
|
||||||
if max_samples <= 0:
|
if max_samples <= 0:
|
||||||
ctx.pre_roll_audio = np.array([], dtype=np.float32)
|
ctx.pre_roll_audio = np.array([], dtype=np.float32)
|
||||||
|
if start_sample is not None:
|
||||||
|
ctx.pre_roll_start_sample = int(start_sample) + int(audio.size)
|
||||||
elif ctx.pre_roll_audio.size > max_samples:
|
elif ctx.pre_roll_audio.size > max_samples:
|
||||||
ctx.pre_roll_audio = ctx.pre_roll_audio[-max_samples:]
|
removed = int(ctx.pre_roll_audio.size - max_samples)
|
||||||
|
ctx.pre_roll_audio = ctx.pre_roll_audio[removed:]
|
||||||
|
ctx.pre_roll_start_sample += removed
|
||||||
|
|
||||||
def _start_turn(self, ctx: ConnectionContext, audio: np.ndarray) -> None:
|
def _start_turn(
|
||||||
|
self,
|
||||||
|
ctx: ConnectionContext,
|
||||||
|
audio: np.ndarray,
|
||||||
|
*,
|
||||||
|
start_sample: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
parts: List[np.ndarray] = []
|
parts: List[np.ndarray] = []
|
||||||
|
turn_start_sample = int(start_sample or 0)
|
||||||
if ctx.pre_roll_audio.size > 0:
|
if ctx.pre_roll_audio.size > 0:
|
||||||
parts.append(np.asarray(ctx.pre_roll_audio, dtype=np.float32))
|
parts.append(np.asarray(ctx.pre_roll_audio, dtype=np.float32))
|
||||||
|
turn_start_sample = int(ctx.pre_roll_start_sample)
|
||||||
parts.append(np.asarray(audio, dtype=np.float32))
|
parts.append(np.asarray(audio, dtype=np.float32))
|
||||||
ctx.segment_audio_buffer = (
|
ctx.segment_audio_buffer = (
|
||||||
np.concatenate(parts) if len(parts) > 1 else np.asarray(parts[0], dtype=np.float32)
|
np.concatenate(parts) if len(parts) > 1 else np.asarray(parts[0], dtype=np.float32)
|
||||||
)
|
)
|
||||||
|
ctx.segment_buffer_start_sample = turn_start_sample
|
||||||
ctx.pre_roll_audio = np.array([], dtype=np.float32)
|
ctx.pre_roll_audio = np.array([], dtype=np.float32)
|
||||||
|
ctx.pre_roll_start_sample = int(turn_start_sample + ctx.segment_audio_buffer.size)
|
||||||
window_samples = self._stream_window_samples()
|
window_samples = self._stream_window_samples()
|
||||||
ctx.stream_window_buffer = np.asarray(
|
ctx.stream_window_buffer = np.asarray(
|
||||||
ctx.segment_audio_buffer[-window_samples:],
|
ctx.segment_audio_buffer[-window_samples:],
|
||||||
|
|
@ -1350,6 +1538,7 @@ class Qwen3ASRService:
|
||||||
ctx.last_partial_chunk_id = 0
|
ctx.last_partial_chunk_id = 0
|
||||||
ctx.last_partial_raw_text = ""
|
ctx.last_partial_raw_text = ""
|
||||||
ctx.last_partial_display_text = ""
|
ctx.last_partial_display_text = ""
|
||||||
|
ctx.partial_preview_text = ""
|
||||||
ctx.stable_partial_prefix = ""
|
ctx.stable_partial_prefix = ""
|
||||||
ctx.pending_partial_revision_text = ""
|
ctx.pending_partial_revision_text = ""
|
||||||
ctx.pending_partial_revision_rounds = 0
|
ctx.pending_partial_revision_rounds = 0
|
||||||
|
|
@ -1828,6 +2017,20 @@ class Qwen3ASRService:
|
||||||
stable_prefix = stripped[:-holdback_chars].rstrip()
|
stable_prefix = stripped[:-holdback_chars].rstrip()
|
||||||
return stable_prefix or stripped
|
return stable_prefix or stripped
|
||||||
|
|
||||||
|
def _append_only_partial_preview(
|
||||||
|
self,
|
||||||
|
ctx: ConnectionContext,
|
||||||
|
text: str,
|
||||||
|
) -> str:
|
||||||
|
"""Expose only newly stabilized prefixes; the final event carries revisions."""
|
||||||
|
candidate = self._partial_display_text(ctx, text)
|
||||||
|
previous = ctx.partial_preview_text
|
||||||
|
if not previous or candidate.startswith(previous):
|
||||||
|
ctx.partial_preview_text = candidate
|
||||||
|
# Qwen may rewrite its rolling snapshot. Keep the already shown prefix
|
||||||
|
# fixed until finalization so the browser never retracts earlier words.
|
||||||
|
return ctx.partial_preview_text
|
||||||
|
|
||||||
def _is_suspicious_segment_text(
|
def _is_suspicious_segment_text(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
|
|
@ -1924,25 +2127,22 @@ class Qwen3ASRService:
|
||||||
if audio.size == 0:
|
if audio.size == 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
temp_path: Optional[str] = None
|
|
||||||
try:
|
try:
|
||||||
temp_path = get_speaker_registry_service().save_audio_array_to_temp(
|
|
||||||
audio,
|
|
||||||
sample_rate=16000,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _run_vad() -> list[list[int]]:
|
def _run_vad() -> list[list[int]]:
|
||||||
vad_model = get_global_vad_model(settings.DEVICE)
|
vad_model = get_global_vad_model(settings.DEVICE)
|
||||||
with get_vad_inference_lock():
|
with get_vad_inference_lock():
|
||||||
result = vad_model.generate(input=temp_path, cache={})
|
# FunASR's FSMN-VAD accepts mono float32 NumPy audio directly.
|
||||||
|
result = vad_model.generate(
|
||||||
|
input=np.asarray(audio, dtype=np.float32),
|
||||||
|
cache={},
|
||||||
|
disable_pbar=True,
|
||||||
|
)
|
||||||
return result[0].get("value", []) if result else []
|
return result[0].get("value", []) if result else []
|
||||||
|
|
||||||
return await run_sync(_run_vad)
|
return await run_sync(_run_vad)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Realtime voiced-duration VAD failed: %s", exc)
|
logger.debug("Realtime voiced-duration VAD failed: %s", exc)
|
||||||
return None
|
return None
|
||||||
finally:
|
|
||||||
get_speaker_registry_service().cleanup_file(temp_path)
|
|
||||||
|
|
||||||
async def _split_silence_audio_by_vad(
|
async def _split_silence_audio_by_vad(
|
||||||
self,
|
self,
|
||||||
|
|
@ -2389,6 +2589,8 @@ class Qwen3ASRService:
|
||||||
*,
|
*,
|
||||||
finalized_audio_override: Optional[np.ndarray] = None,
|
finalized_audio_override: Optional[np.ndarray] = None,
|
||||||
carry_audio_override: Optional[np.ndarray] = None,
|
carry_audio_override: Optional[np.ndarray] = None,
|
||||||
|
segment_start_ms_override: Optional[int] = None,
|
||||||
|
segment_end_ms_override: Optional[int] = None,
|
||||||
emit_segment_start: bool = True,
|
emit_segment_start: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
engine = await self._ensure_engine(ctx)
|
engine = await self._ensure_engine(ctx)
|
||||||
|
|
@ -2500,8 +2702,18 @@ class Qwen3ASRService:
|
||||||
|
|
||||||
is_valid = self._is_valid_committed_segment_text(segment_text, duration_sec)
|
is_valid = self._is_valid_committed_segment_text(segment_text, duration_sec)
|
||||||
segment_duration_ms = int(finalized_audio.size / 16)
|
segment_duration_ms = int(finalized_audio.size / 16)
|
||||||
segment_start_ms = int(ctx.timeline_cursor_ms)
|
segment_start_ms = int(
|
||||||
segment_end_ms = int(segment_start_ms + segment_duration_ms)
|
ctx.timeline_cursor_ms
|
||||||
|
if segment_start_ms_override is None
|
||||||
|
else segment_start_ms_override
|
||||||
|
)
|
||||||
|
segment_end_ms = int(
|
||||||
|
segment_start_ms + segment_duration_ms
|
||||||
|
if segment_end_ms_override is None
|
||||||
|
else segment_end_ms_override
|
||||||
|
)
|
||||||
|
if segment_end_ms < segment_start_ms:
|
||||||
|
segment_end_ms = segment_start_ms + segment_duration_ms
|
||||||
|
|
||||||
if is_valid:
|
if is_valid:
|
||||||
segment_payload = {
|
segment_payload = {
|
||||||
|
|
@ -2560,6 +2772,13 @@ class Qwen3ASRService:
|
||||||
ctx.segment_index += 1
|
ctx.segment_index += 1
|
||||||
|
|
||||||
ctx.timeline_cursor_ms = segment_end_ms
|
ctx.timeline_cursor_ms = segment_end_ms
|
||||||
|
if carry_audio.size > 0:
|
||||||
|
ctx.segment_buffer_start_sample += max(
|
||||||
|
int(current_audio.size - carry_audio.size),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ctx.segment_buffer_start_sample = int(ctx.audio_samples_received)
|
||||||
ctx.segment_audio_buffer = np.asarray(carry_audio, dtype=np.float32)
|
ctx.segment_audio_buffer = np.asarray(carry_audio, dtype=np.float32)
|
||||||
ctx.stream_window_buffer = np.asarray(carry_audio, dtype=np.float32)
|
ctx.stream_window_buffer = np.asarray(carry_audio, dtype=np.float32)
|
||||||
ctx.sentence_active = bool(carry_audio.size > 0)
|
ctx.sentence_active = bool(carry_audio.size > 0)
|
||||||
|
|
@ -2574,6 +2793,52 @@ class Qwen3ASRService:
|
||||||
np.asarray(carry_audio, dtype=np.float32),
|
np.asarray(carry_audio, dtype=np.float32),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _commit_vad_boundary(
|
||||||
|
self,
|
||||||
|
websocket: WebSocket,
|
||||||
|
ctx: ConnectionContext,
|
||||||
|
task_id: str,
|
||||||
|
boundary: tuple[int, int],
|
||||||
|
*,
|
||||||
|
reason: str = "vad_end",
|
||||||
|
emit_segment_start: bool = True,
|
||||||
|
) -> bool:
|
||||||
|
"""Trim one turn to FSMN-VAD's absolute start/end samples before commit."""
|
||||||
|
current_audio = np.asarray(ctx.segment_audio_buffer, dtype=np.float32)
|
||||||
|
turn_start_sample = int(ctx.segment_buffer_start_sample)
|
||||||
|
start_sample = max(int(boundary[0]), turn_start_sample)
|
||||||
|
stream_end_sample = turn_start_sample + int(current_audio.size)
|
||||||
|
end_sample = min(int(boundary[1]), stream_end_sample)
|
||||||
|
if end_sample <= start_sample:
|
||||||
|
return False
|
||||||
|
|
||||||
|
start_offset = start_sample - turn_start_sample
|
||||||
|
end_offset = end_sample - turn_start_sample
|
||||||
|
finalized_audio = np.asarray(current_audio[start_offset:end_offset], dtype=np.float32)
|
||||||
|
trailing_audio = np.asarray(current_audio[end_offset:], dtype=np.float32)
|
||||||
|
if finalized_audio.size == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await self._commit_retranscribe_turn(
|
||||||
|
websocket,
|
||||||
|
ctx,
|
||||||
|
task_id,
|
||||||
|
reason,
|
||||||
|
finalized_audio_override=finalized_audio,
|
||||||
|
segment_start_ms_override=int(start_sample / 16),
|
||||||
|
segment_end_ms_override=int(end_sample / 16),
|
||||||
|
emit_segment_start=emit_segment_start,
|
||||||
|
)
|
||||||
|
# Preserve post-end audio as pre-roll for a possible next utterance in
|
||||||
|
# the same network frame, while the bubble duration follows VAD bounds.
|
||||||
|
self._append_pre_roll(
|
||||||
|
ctx,
|
||||||
|
trailing_audio,
|
||||||
|
start_sample=end_sample,
|
||||||
|
)
|
||||||
|
ctx.vad_speech_start_sample = None
|
||||||
|
return True
|
||||||
|
|
||||||
async def _send_error(
|
async def _send_error(
|
||||||
self,
|
self,
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
|
|
@ -2678,7 +2943,10 @@ class Qwen3ASRService:
|
||||||
"partial_window_sec",
|
"partial_window_sec",
|
||||||
settings.REALTIME_PARTIAL_WINDOW_SEC,
|
settings.REALTIME_PARTIAL_WINDOW_SEC,
|
||||||
),
|
),
|
||||||
"pre_roll_ms": payload.get("pre_roll_ms", 240),
|
"pre_roll_ms": payload.get(
|
||||||
|
"pre_roll_ms",
|
||||||
|
settings.REALTIME_VAD_PRE_ROLL_MS,
|
||||||
|
),
|
||||||
"max_sentence_count": payload.get("max_sentence_count", 8),
|
"max_sentence_count": payload.get("max_sentence_count", 8),
|
||||||
"max_partial_text_chars": payload.get(
|
"max_partial_text_chars": payload.get(
|
||||||
"max_partial_text_chars",
|
"max_partial_text_chars",
|
||||||
|
|
@ -2702,7 +2970,14 @@ class Qwen3ASRService:
|
||||||
settings.REALTIME_ENABLE_SEGMENT_REFINE,
|
settings.REALTIME_ENABLE_SEGMENT_REFINE,
|
||||||
),
|
),
|
||||||
"enable_realtime_longform": payload.get("enable_realtime_longform", False),
|
"enable_realtime_longform": payload.get("enable_realtime_longform", False),
|
||||||
"enable_realtime_vad_split": payload.get("enable_realtime_vad_split", False),
|
"enable_realtime_vad": payload.get(
|
||||||
|
"enable_realtime_vad",
|
||||||
|
settings.REALTIME_ENABLE_VAD,
|
||||||
|
),
|
||||||
|
"enable_realtime_vad_split": payload.get(
|
||||||
|
"enable_realtime_vad_split",
|
||||||
|
settings.REALTIME_ENABLE_VAD,
|
||||||
|
),
|
||||||
"force_stable_segment_sec": payload.get(
|
"force_stable_segment_sec": payload.get(
|
||||||
"force_stable_segment_sec",
|
"force_stable_segment_sec",
|
||||||
settings.REALTIME_FORCE_STABLE_SEGMENT_SEC,
|
settings.REALTIME_FORCE_STABLE_SEGMENT_SEC,
|
||||||
|
|
@ -2724,7 +2999,16 @@ class Qwen3ASRService:
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.vad_cache = {}
|
||||||
|
ctx.vad_pending_audio = np.array([], dtype=np.float32)
|
||||||
|
ctx.vad_speech_active = False
|
||||||
|
ctx.vad_speech_start_sample = None
|
||||||
|
ctx.vad_failed = False
|
||||||
|
ctx.audio_samples_received = 0
|
||||||
|
ctx.segment_buffer_start_sample = 0
|
||||||
|
|
||||||
await self._ensure_engine(ctx)
|
await self._ensure_engine(ctx)
|
||||||
|
await self._warm_realtime_vad(ctx)
|
||||||
ctx.stream_window_buffer = np.array([], dtype=np.float32)
|
ctx.stream_window_buffer = np.array([], dtype=np.float32)
|
||||||
self._reset_partial_state(ctx)
|
self._reset_partial_state(ctx)
|
||||||
|
|
||||||
|
|
@ -2775,13 +3059,56 @@ class Qwen3ASRService:
|
||||||
if audio is None:
|
if audio is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
has_voice = self._has_voice(audio)
|
audio_start_sample = int(ctx.audio_samples_received)
|
||||||
|
ctx.audio_samples_received += int(audio.size)
|
||||||
|
# The bundled demo sends a whole WAV in one frame; keep it
|
||||||
|
# intact for final ASR instead of treating it as microphone pre-roll.
|
||||||
|
bulk_audio_frame = (
|
||||||
|
str(ctx.params.get("format", "pcm")).lower() == "wav"
|
||||||
|
or audio.size > int(1.5 * 16000)
|
||||||
|
)
|
||||||
|
vad_boundaries = (
|
||||||
|
[]
|
||||||
|
if str(ctx.params.get("format", "pcm")).lower() == "wav"
|
||||||
|
else await self._push_realtime_vad_audio(ctx, audio)
|
||||||
|
)
|
||||||
|
energy_voice = self._has_voice(audio)
|
||||||
|
has_voice = (
|
||||||
|
bulk_audio_frame
|
||||||
|
or energy_voice
|
||||||
|
or ctx.vad_speech_active
|
||||||
|
or bool(vad_boundaries)
|
||||||
|
)
|
||||||
|
|
||||||
if not ctx.sentence_active:
|
if not ctx.sentence_active:
|
||||||
if not has_voice:
|
if not has_voice:
|
||||||
self._append_pre_roll(ctx, audio)
|
self._append_pre_roll(
|
||||||
|
ctx,
|
||||||
|
audio,
|
||||||
|
start_sample=audio_start_sample,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
self._start_turn(ctx, audio)
|
if bulk_audio_frame:
|
||||||
|
self._start_turn(
|
||||||
|
ctx,
|
||||||
|
audio,
|
||||||
|
start_sample=audio_start_sample,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._append_pre_roll(
|
||||||
|
ctx,
|
||||||
|
audio,
|
||||||
|
start_sample=audio_start_sample,
|
||||||
|
)
|
||||||
|
self._start_turn(
|
||||||
|
ctx,
|
||||||
|
(
|
||||||
|
np.array([], dtype=np.float32)
|
||||||
|
if ctx.pre_roll_audio.size > 0
|
||||||
|
else audio
|
||||||
|
),
|
||||||
|
start_sample=audio_start_sample,
|
||||||
|
)
|
||||||
engine = await self._ensure_engine(ctx)
|
engine = await self._ensure_engine(ctx)
|
||||||
await self._push_realtime_stream_audio(
|
await self._push_realtime_stream_audio(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
@ -2789,7 +3116,7 @@ class Qwen3ASRService:
|
||||||
np.asarray(ctx.segment_audio_buffer, dtype=np.float32),
|
np.asarray(ctx.segment_audio_buffer, dtype=np.float32),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._append_turn_audio(ctx, audio, has_voice=has_voice)
|
self._append_turn_audio(ctx, audio, has_voice=energy_voice)
|
||||||
engine = await self._ensure_engine(ctx)
|
engine = await self._ensure_engine(ctx)
|
||||||
await self._push_realtime_stream_audio(ctx, engine, audio)
|
await self._push_realtime_stream_audio(ctx, engine, audio)
|
||||||
|
|
||||||
|
|
@ -2823,7 +3150,11 @@ class Qwen3ASRService:
|
||||||
ctx,
|
ctx,
|
||||||
visible_observed,
|
visible_observed,
|
||||||
)
|
)
|
||||||
partial_display = self._clip_partial_text(visible_observed, ctx)
|
partial_display = self._append_only_partial_preview(
|
||||||
|
ctx,
|
||||||
|
visible_observed,
|
||||||
|
)
|
||||||
|
partial_display = self._clip_partial_text(partial_display, ctx)
|
||||||
if (
|
if (
|
||||||
partial_display
|
partial_display
|
||||||
and (
|
and (
|
||||||
|
|
@ -2842,11 +3173,26 @@ class Qwen3ASRService:
|
||||||
replace_snapshot=has_native_stream_snapshot,
|
replace_snapshot=has_native_stream_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if bulk_audio_frame:
|
||||||
|
partial_start_sample = ctx.segment_buffer_start_sample
|
||||||
|
partial_end_sample = ctx.audio_samples_received
|
||||||
|
else:
|
||||||
|
partial_start_sample = ctx.vad_speech_start_sample
|
||||||
|
if partial_start_sample is None and vad_boundaries:
|
||||||
|
partial_start_sample = vad_boundaries[0][0]
|
||||||
|
if partial_start_sample is None:
|
||||||
|
partial_start_sample = ctx.segment_buffer_start_sample
|
||||||
|
partial_end_sample = (
|
||||||
|
vad_boundaries[0][1]
|
||||||
|
if vad_boundaries
|
||||||
|
else ctx.audio_samples_received
|
||||||
|
)
|
||||||
|
|
||||||
sentence_payload = self._build_tencent_sentence(
|
sentence_payload = self._build_tencent_sentence(
|
||||||
{
|
{
|
||||||
"index": ctx.segment_index,
|
"index": ctx.segment_index,
|
||||||
"start_ms": int(ctx.timeline_cursor_ms),
|
"start_ms": int(partial_start_sample / 16),
|
||||||
"end_ms": int(ctx.timeline_cursor_ms + (ctx.segment_audio_buffer.size / 16)),
|
"end_ms": int(partial_end_sample / 16),
|
||||||
"text": partial_display,
|
"text": partial_display,
|
||||||
"speaker_id": -1,
|
"speaker_id": -1,
|
||||||
"sentence_type": 0,
|
"sentence_type": 0,
|
||||||
|
|
@ -2890,13 +3236,63 @@ class Qwen3ASRService:
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if vad_boundaries and ctx.sentence_active and not bulk_audio_frame:
|
||||||
|
if await self._commit_vad_boundary(
|
||||||
|
websocket,
|
||||||
|
ctx,
|
||||||
|
task_id,
|
||||||
|
vad_boundaries[0],
|
||||||
|
):
|
||||||
|
ctx.state = ConnectionState.STREAMING
|
||||||
|
continue
|
||||||
|
|
||||||
silence_threshold = self._get_dynamic_silence_threshold_samples(ctx)
|
silence_threshold = self._get_dynamic_silence_threshold_samples(ctx)
|
||||||
|
if self._enable_realtime_vad(ctx) and ctx.vad_speech_active:
|
||||||
|
# Give FSMN-VAD time to confirm an endpoint before the
|
||||||
|
# legacy energy-based safety fallback closes the turn.
|
||||||
|
vad_fallback_sec = max(
|
||||||
|
float(settings.REALTIME_VAD_FINALIZE_SILENCE_SEC) + 0.8,
|
||||||
|
1.2,
|
||||||
|
)
|
||||||
|
silence_threshold = max(
|
||||||
|
silence_threshold,
|
||||||
|
int(vad_fallback_sec * 16000),
|
||||||
|
)
|
||||||
if ctx.silence_samples >= silence_threshold:
|
if ctx.silence_samples >= silence_threshold:
|
||||||
|
if self._enable_realtime_vad(ctx):
|
||||||
|
fallback_segments = await self._run_vad_segments(
|
||||||
|
np.asarray(ctx.segment_audio_buffer, dtype=np.float32)
|
||||||
|
)
|
||||||
|
valid_segments = [
|
||||||
|
(int(item[0]), int(item[1]))
|
||||||
|
for item in (fallback_segments or [])
|
||||||
|
if len(item) >= 2 and int(item[1]) > int(item[0])
|
||||||
|
]
|
||||||
|
if valid_segments:
|
||||||
|
fallback_boundary = (
|
||||||
|
int(ctx.segment_buffer_start_sample + valid_segments[0][0] * 16),
|
||||||
|
int(ctx.segment_buffer_start_sample + valid_segments[-1][1] * 16),
|
||||||
|
)
|
||||||
|
if await self._commit_vad_boundary(
|
||||||
|
websocket,
|
||||||
|
ctx,
|
||||||
|
task_id,
|
||||||
|
fallback_boundary,
|
||||||
|
reason="vad_fallback",
|
||||||
|
):
|
||||||
|
ctx.state = ConnectionState.STREAMING
|
||||||
|
continue
|
||||||
|
|
||||||
await self._commit_retranscribe_turn(
|
await self._commit_retranscribe_turn(
|
||||||
websocket,
|
websocket,
|
||||||
ctx,
|
ctx,
|
||||||
task_id,
|
task_id,
|
||||||
"silence",
|
"silence",
|
||||||
|
finalized_audio_override=np.asarray(
|
||||||
|
ctx.segment_audio_buffer,
|
||||||
|
dtype=np.float32,
|
||||||
|
),
|
||||||
|
carry_audio_override=np.array([], dtype=np.float32),
|
||||||
)
|
)
|
||||||
ctx.state = ConnectionState.STREAMING
|
ctx.state = ConnectionState.STREAMING
|
||||||
continue
|
continue
|
||||||
|
|
@ -2959,14 +3355,41 @@ class Qwen3ASRService:
|
||||||
task_id: str,
|
task_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
|
final_vad_boundaries = await self._flush_realtime_vad(ctx)
|
||||||
if ctx.sentence_active and ctx.segment_audio_buffer.size > 0:
|
if ctx.sentence_active and ctx.segment_audio_buffer.size > 0:
|
||||||
await self._commit_retranscribe_turn(
|
final_boundary = (
|
||||||
|
final_vad_boundaries[-1]
|
||||||
|
if final_vad_boundaries
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
final_boundary is None
|
||||||
|
and self._enable_realtime_vad(ctx)
|
||||||
|
and ctx.vad_speech_active
|
||||||
|
and ctx.vad_speech_start_sample is not None
|
||||||
|
):
|
||||||
|
final_boundary = (
|
||||||
|
int(ctx.vad_speech_start_sample),
|
||||||
|
int(ctx.audio_samples_received),
|
||||||
|
)
|
||||||
|
|
||||||
|
if final_boundary is not None and await self._commit_vad_boundary(
|
||||||
websocket,
|
websocket,
|
||||||
ctx,
|
ctx,
|
||||||
task_id,
|
task_id,
|
||||||
"final",
|
final_boundary,
|
||||||
|
reason="final",
|
||||||
emit_segment_start=False,
|
emit_segment_start=False,
|
||||||
)
|
):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await self._commit_retranscribe_turn(
|
||||||
|
websocket,
|
||||||
|
ctx,
|
||||||
|
task_id,
|
||||||
|
"final",
|
||||||
|
emit_segment_start=False,
|
||||||
|
)
|
||||||
|
|
||||||
# Drain pending diarization before the final event; the browser closes
|
# Drain pending diarization before the final event; the browser closes
|
||||||
# the socket on `end`, so later speaker updates would otherwise be lost.
|
# the socket on `end`, so later speaker updates would otherwise be lost.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue