feat: show per-stage timing (듣기/LLM/TTS) in the transcript channel

The transcript channel only showed STT and LLM seconds. Add wall-clock
start/end times and durations for listening, LLM and TTS so it's obvious
what takes long; STT surfaces as the gap between listening end and LLM start.

- bridge: emit llm_start_ms/llm_end_ms on meta and tts_*_ms on the end event
- bot: capture the listening window, assemble full timing after the stream,
  and render a per-stage breakdown in the transcript message

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-13 23:58:49 +09:00
parent ddebdd7542
commit de5384d166
6 changed files with 242 additions and 34 deletions

View File

@@ -453,6 +453,12 @@ def http_converse_stream():
def gen():
import time
def now_ms() -> int:
# Wall-clock epoch ms so the Node side can line these up against its
# own Date.now() capture timestamps (same host, same clock).
return int(time.time() * 1000)
t0 = time.monotonic()
stt = transcribe(raw)
t_stt = time.monotonic()
@@ -464,8 +470,10 @@ def http_converse_stream():
"stt_sec": round(t_stt - t0, 1), "broadcast_action": None}) + "\n"
yield json.dumps({"type": "end"}) + "\n"
return
llm_start_ms = now_ms()
result = think(transcript, stt.get("language"), broadcasting)
t_think = time.monotonic()
llm_end_ms = now_ms()
reply = result.get("reply", "")
yield json.dumps({
"type": "meta",
@@ -476,20 +484,37 @@ def http_converse_stream():
"note": "ok" if reply.strip() else "답변 없음",
"stt_sec": round(t_stt - t0, 1),
"think_sec": round(t_think - t_stt, 1),
# Wall-clock LLM window (epoch ms) for the transcript-channel timing
# breakdown. STT shows up as the gap between the Node-side capture
# end and llm_start_ms.
"llm_start_ms": llm_start_ms,
"llm_end_ms": llm_end_ms,
"broadcast_action": result.get("broadcast_action"),
}) + "\n"
tts_total = 0.0
tts_start_ms = None
tts_end_ms = None
for seq, sentence in enumerate(split_sentences(reply)):
ts = time.monotonic()
if tts_start_ms is None:
tts_start_ms = now_ms()
audio = synthesize(sentence)
tts_total += time.monotonic() - ts
tts_end_ms = now_ms()
if audio:
yield json.dumps({
"type": "audio",
"seq": seq,
"audio_b64": base64.b64encode(audio).decode("ascii"),
}) + "\n"
yield json.dumps({"type": "end"}) + "\n"
# The end event carries TTS timing because synthesis happens AFTER the
# meta line (it is pipelined sentence-by-sentence).
yield json.dumps({
"type": "end",
"tts_sec": round(tts_total, 1),
"tts_start_ms": tts_start_ms,
"tts_end_ms": tts_end_ms,
}) + "\n"
print(
f"[bridge] ⏱️ turn stt={t_stt - t0:.1f}s think(LLM)={t_think - t_stt:.1f}s "
f"tts={tts_total:.1f}s total={time.monotonic() - t0:.1f}s replylen={len(reply)} "