perf(bridge): lock STT to Korean + add per-stage turn timing

- transcribe() now passes language="ko" (STT_LANGUAGE env, default ko): skips
  Whisper auto-detect, fixing occasional Korean->Chinese mis-detection and
  shaving latency. LLM is already locked via OUTPUT_LANGUAGE=Korean; MeloTTS is
  Korean-only — so STT/LLM/TTS are all Korean now.
- converse_stream logs "⏱️ turn stt=.. think(LLM)=.. tts=.. total=.." so the
  ~30s voice-reply latency can be attributed to the real bottleneck stage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-13 21:53:47 +09:00
parent f2b43cb310
commit f12e6b28c2

View File

@@ -75,6 +75,11 @@ VAD_ENABLED = os.environ.get("VAD_ENABLED", "1") not in ("0", "false", "False")
VAD_THRESHOLD = float(os.environ.get("VAD_THRESHOLD", "0.4"))
VAD_MIN_SPEECH_MS = int(os.environ.get("VAD_MIN_SPEECH_MS", "200"))
# Lock STT to a single language (this deployment is Korean-only). Skipping
# Whisper's language auto-detect both fixes occasional mis-detection (e.g. a
# Korean phrase decoded as Chinese) and shaves a little latency. Empty = auto.
STT_LANGUAGE = os.environ.get("STT_LANGUAGE", "ko").strip() or None
# TTS engine: "melo" (MeloTTS Korean speaker, the warm worker) is the primary
# voice; Piper is kept as a fallback if the worker is unreachable. Set
# TTS_ENGINE=piper to disable MeloTTS entirely.
@@ -208,7 +213,7 @@ def transcribe(wav_bytes: bytes) -> dict:
print("[bridge] no speech detected (VAD) — skipping STT", flush=True)
return {"text": "", "language": None}
segments, info = _whisper.transcribe(audio, beam_size=1)
segments, info = _whisper.transcribe(audio, beam_size=1, language=STT_LANGUAGE)
# Second line of defence: drop non-speech / hallucinated segments by
# Whisper's own no_speech_prob. The no_speech_prob hard cutoff (plus the VAD
# pre-gate above) is what rejects noise/hallucinations. The avg_logprob
@@ -446,7 +451,10 @@ def http_converse_stream():
broadcasting = _coerce_bool(request.args.get("broadcasting"))
def gen():
import time
t0 = time.monotonic()
stt = transcribe(raw)
t_stt = time.monotonic()
transcript = stt.get("text", "")
if not transcript:
yield json.dumps({"type": "meta", "transcript": "", "language": stt.get("language"),
@@ -454,6 +462,7 @@ def http_converse_stream():
yield json.dumps({"type": "end"}) + "\n"
return
result = think(transcript, stt.get("language"), broadcasting)
t_think = time.monotonic()
reply = result.get("reply", "")
yield json.dumps({
"type": "meta",
@@ -463,8 +472,11 @@ def http_converse_stream():
"error": result.get("error"),
"broadcast_action": result.get("broadcast_action"),
}) + "\n"
tts_total = 0.0
for seq, sentence in enumerate(split_sentences(reply)):
ts = time.monotonic()
audio = synthesize(sentence)
tts_total += time.monotonic() - ts
if audio:
yield json.dumps({
"type": "audio",
@@ -472,6 +484,12 @@ def http_converse_stream():
"audio_b64": base64.b64encode(audio).decode("ascii"),
}) + "\n"
yield json.dumps({"type": "end"}) + "\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)} "
f"transcript={transcript[:40]!r}",
flush=True,
)
return Response(stream_with_context(gen()), mimetype="application/x-ndjson")