From 04e9ba2fb9c9b6bfe8b48219cf7536c6b053c2ea Mon Sep 17 00:00:00 2001 From: buyun14 <2135481470@qq.com> Date: Tue, 1 Sep 2026 16:23:25 +0800 Subject: [PATCH] feat: add STT latency and RTF tracking metrics Track speech-to-text call timing and real-time factor (RTF) in both the pipeline preprocess stage and the web chat live chat service, and surface the metrics in the Live Mode dashboard. - preprocess_stage: probe audio duration via get_media_duration, measure STT duration, log provider/model/duration/RTF, and record into event.trace for observability. - live_chat_service: emit stt_total_time / stt_audio_duration / stt_rtf over the live chat websocket. - LiveMode.vue: render the new STT latency metrics. --- .../core/pipeline/preprocess_stage/stage.py | 46 +++++++++++++++++-- .../dashboard/services/live_chat_service.py | 30 ++++++++++++ dashboard/src/components/chat/LiveMode.vue | 8 ++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/astrbot/core/pipeline/preprocess_stage/stage.py b/astrbot/core/pipeline/preprocess_stage/stage.py index a090ccb197..36d4e29160 100644 --- a/astrbot/core/pipeline/preprocess_stage/stage.py +++ b/astrbot/core/pipeline/preprocess_stage/stage.py @@ -1,5 +1,6 @@ import asyncio import random +import time import traceback from collections.abc import AsyncGenerator from pathlib import Path @@ -13,6 +14,7 @@ ensure_jpeg, ensure_wav, file_uri_to_path, + get_media_duration, is_file_uri, ) @@ -182,20 +184,29 @@ async def process( async def _stt_record(record_comp: Record, is_reply: bool = False): """对单个 Record 组件执行语音转文本,成功返回 Plain,失败返回 None。""" prefix = "referenced " if is_reply else "" + suffix = " (referenced message)" if is_reply else "" try: path = await record_comp.convert_to_file_path() except Exception as e: logger.warning(f"Failed to resolve the {prefix}voice path: {e}") return None + # 探测音频时长(毫秒),用于计算实时率 RTF;失败时为 None + try: + audio_duration_ms = await get_media_duration(path) + except Exception: + audio_duration_ms = None + + stt_start = time.time() + status = "failed" retry = 5 for i in range(retry): try: result = await stt_provider.get_text(audio_url=path) if result: - suffix = " (referenced message)" if is_reply else "" + status = "success" logger.info(f"Speech-to-text{suffix} result: " + result) - return Plain(result) + break break except FileNotFoundError: # napcat workaround: file may not be ready immediately @@ -206,9 +217,38 @@ async def _stt_record(record_comp: Record, is_reply: bool = False): continue except BaseException as e: logger.error(traceback.format_exc()) - suffix = " (referenced message)" if is_reply else "" logger.error(f"Speech-to-text{suffix} failed: {e}") break + + stt_duration = time.time() - stt_start + rtf = ( + stt_duration / (audio_duration_ms / 1000.0) + if audio_duration_ms + else None + ) + + provider_type = getattr(stt_provider.meta(), "type", "") + provider_model = stt_provider.get_model() + logger.info( + f"Speech-to-text{suffix} stats: provider={provider_type} " + f"model={provider_model} duration={stt_duration * 1000:.0f}ms " + f"audio={audio_duration_ms if audio_duration_ms is not None else 'N/A'}ms " + f"rtf={rtf if rtf is not None else 'N/A'} status={status}" + ) + event.trace.record( + "stt", + provider=provider_type, + model=provider_model, + status=status, + referenced=is_reply, + audio_duration_ms=audio_duration_ms, + stt_duration=stt_duration, + rtf=rtf, + audio=Path(path).name, + ) + + if status == "success": + return Plain(result) return None message_chain = event.get_messages() diff --git a/astrbot/dashboard/services/live_chat_service.py b/astrbot/dashboard/services/live_chat_service.py index 16b7eed0ad..7d6359aeaf 100644 --- a/astrbot/dashboard/services/live_chat_service.py +++ b/astrbot/dashboard/services/live_chat_service.py @@ -29,6 +29,7 @@ from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path from astrbot.core.utils.datetime_utils import generate_timestamp_id, to_utc_isoformat +from astrbot.core.utils.media_utils import get_media_duration from astrbot.dashboard.services.chat_service import ( BotMessageAccumulator, build_bot_history_content, @@ -884,7 +885,36 @@ async def process_audio( await send_json({"t": "metrics", "data": {"stt": stt_provider.meta().type}}) + stt_start = time.time() user_text = await stt_provider.get_text(audio_path) + stt_duration = time.time() - stt_start + + # 探测音频时长(毫秒)并计算实时率(RTF),探测失败时优雅降级 + try: + audio_duration_ms = await get_media_duration(audio_path) + except Exception: + audio_duration_ms = None + audio_duration_sec = ( + audio_duration_ms / 1000.0 if audio_duration_ms else None + ) + rtf = stt_duration / audio_duration_sec if audio_duration_sec else None + logger.info( + f"[Live Chat] STT stats: provider={stt_provider.meta().type} " + f"model={stt_provider.get_model()} duration={stt_duration * 1000:.0f}ms " + f"audio={audio_duration_ms if audio_duration_ms is not None else 'N/A'}ms " + f"rtf={rtf if rtf is not None else 'N/A'}" + ) + await send_json( + { + "t": "metrics", + "data": { + "stt_total_time": stt_duration, + "stt_audio_duration": audio_duration_sec, + "stt_rtf": rtf, + }, + } + ) + if not user_text: logger.warning("[Live Chat] STT 识别结果为空") return diff --git a/dashboard/src/components/chat/LiveMode.vue b/dashboard/src/components/chat/LiveMode.vue index 654ffb49f4..a0356f9461 100644 --- a/dashboard/src/components/chat/LiveMode.vue +++ b/dashboard/src/components/chat/LiveMode.vue @@ -44,6 +44,11 @@ 1000).toFixed(0) }}ms Speak -> End: {{ (metrics.wav_to_tts_total_time * 1000).toFixed(0) }}ms + STT Total Latency: {{ (metrics.stt_total_time * + 1000).toFixed(0) }}ms + STT Audio Duration: {{ (metrics.stt_audio_duration * + 1000).toFixed(0) }}ms + STT RTF: {{ metrics.stt_rtf.toFixed(2) }} STT Provider: {{ metrics.stt }} TTS Provider: {{ metrics.tts }} Chat Model: {{ metrics.chat_model }} @@ -108,6 +113,9 @@ interface LiveMetrics { tts_first_frame_time?: number; tts_total_time?: number; wav_to_tts_total_time?: number; + stt_total_time?: number; + stt_audio_duration?: number; + stt_rtf?: number; stt?: string; tts?: string; chat_model?: string;