External STT 클라이언트 (WS·prebuffer) — 코드레벨 동작 흐름 P1코드레벨

마지막 업데이트 2026-07-22

작성일: 2026-06-14 대상: 개발자 — 브라우저측 외부 STT 스트리밍 파악 핵심 파일: entities/guest-session/lib/external-stt-observer.ts

개요 · 범위

아동 마이크를 16kHz Int16 PCM으로 가공해 Go STT 서버(wss://.../ws/stt)로 스트리밍하고, 배치 전사 결과를 콜백으로 돌려받는 브라우저측 WebSocket 클라이언트. 서버측(배치/화자분리/dedup)은 #4 STT 배치 서버.

OpenAI와 병렬: External STT는 OpenAI Realtime 내장 전사와 독립적으로 동작한다. 결과(batch_result)는 use-ai-session이 받아 세션 로그 저장 + (waitForSttResponse 모드면) 수동 response.create 트리거에 쓴다. 모드 전환·소비는 stt-expert 영역.

구성

레이어역할파일
ExternalSTTObserverWS 연결·오디오 그래프·prebuffer·송수신(클래스, 551L)entities/guest-session/lib/external-stt-observer.ts
useExternalSttReact 래퍼: start/stop/requestFinalize/requestReset + 콜백 배선(244L)hooks/use-external-stt.ts

오디오 파이프라인 startAudioStreaming L290–398

AudioContext(48kHz) // 다른 컨텍스트와 동일 SR → Chrome 리샘플링 간섭 방지 source → HPF(100Hz Butterworth Q0.707) → Limiter(-3dBFS, 20:1, 1ms/10ms) → ScriptProcessor(4096) → destination onaudioprocess: 48k→16k 다운샘플 (3:1, 매 3번째 샘플) Float32 → Int16(LINEAR16) 중복 chunk(signature) skip // 에코 방지 hasAiSpokenOnce 전이면 skip !userTalking → prebuffer 축적 / userTalking → (flush 후) sendAudio("user")
  • 전용 DSP: 메인 오디오 체인(#7)과 별개로 STT 전용 HPF 100Hz + Limiter -3dBFS를 건다(전사 정확도 목적).
  • 다운샘플: 48k에서 3:1 데시메이션으로 16k. (단순 추출 방식)
  • 중복 chunk 가드: 앞 4샘플+길이 signature가 직전과 같으면 skip — 에코/중복 송신 방지.

prebuffer — 발화 앞부분 보존 setUserTalking L108 / flushPrebuffer

VAD가 "아동 발화 시작"을 인지하는 시점은 실제 발화보다 약간 늦다. 그 사이 음성을 버리면 첫 음절이 잘린다. 그래서 발화 전 오디오를 prebuffer(~600ms)에 모아두다가, userTalking이 켜지는 순간 shouldFlushPrebuffer로 한 번에 flush한 뒤 이어서 실시간 전송한다.

  • setUserTalking(true) → flush 예약. !userTalking 동안은 addToPrebuffer로만 축적(한도 prebufferSamplesLimit).
  • hasAiSpokenOnce 게이트: AI가 한 번도 말하기 전(세션 초반)에는 전송하지 않음.

송신 프로토콜 sendAudio L61 / 제어 메서드

// 바이너리: [speaker byte] + Int16 PCM
header = [0] (user) | [1] (ai)  →  ws.send(header + int16)
메서드전송
sendAudio(data, "user")아동 마이크 PCM (0x00)
sendAIAudio(data)AI 음성 PCM (0x01)
requestFinalize(){type:"finalize"} → 배치 STT 트리거
requestAIAudioSTT(){type:"process_ai_audio"}
requestReset(){type:"reset"} (활동 전환)

수신 처리 onmessage L168–256

type콜백페이로드
monitortranscriptCallback(isFinal:false)중간 결과
finalizedtranscript + finalizedCallback최종(스트리밍 모드)
batch_resultfinalizedCallbacktranscript, originalText, wasDeduped, diarization, sttSessionId/requestId, latencyMs
ai_sttaiSttCallbacktranscript, confidence, history_count

latency는 finalizeRequestedAt/aiSttRequestedAt과의 차로 계산. wasDeduped+originalText는 세션 로그의 stt_dedup 타입 저장에 쓰인다(#4).

연결 생명주기 · 재연결

  • connectionGeneration: 매 connect마다 gen 증가. onopen/onmessage/onclose에서 gen !== current면 stale 이벤트로 무시 → StrictMode 이중 실행·재연결 레이스에서 옛 소켓 콜백이 새 연결을 망치는 것 방지.
  • 재연결: onclose 시 기존 오디오 그래프 정리 후 최대 maxReconnectAttempts회, 지수 백오프(1000 * attemptms).
  • 그래프 정리(stopAudioStreaming): idempotent·null-safe. 모든 종료/재시작 경로가 호출 → onaudioprocess 콜백 먼저 제거 후 disconnect(중복 그래프 생존 방지).
  • start 진입 가드: startAudioStreaming은 원인(reconnect/effect re-run/StrictMode) 불문 기존 그래프부터 폐기.

함정 · 주의

  • AudioContext 48kHz 고정: 다른 컨텍스트와 SR을 맞춰 Chrome 리샘플링 간섭을 피한다. 바꾸면 음질/싱크 문제 가능.
  • ScriptProcessor(레거시): deprecated API. AudioWorklet 이전 시 onaudioprocess 로직 전체 재작성 필요.
  • hasAiSpokenOnce/userTalking/prebuffer 의존: 발화 상태가 부정확하면 prebuffer flush·전송 타이밍이 어긋나 첫 음절 손실 또는 에코.
  • 중복 chunk skip은 signature 4샘플 기반: 드물게 실제 동일 파형을 오탐 skip할 수 있음(에코 방지와의 트레이드오프).
  • stale 이벤트 가드 필수: connectionGeneration 검사를 제거하면 재연결/StrictMode에서 유령 소켓이 콜백을 발화.

파일 · 라인 레퍼런스

파일/심볼역할
external-stt-observer.tsWS 클라이언트 본체
startAudioStreaming (L290)오디오 그래프·다운샘플·prebuffer 전송
connectWebSocket (L147)연결·gen 가드·재연결
onmessage (L168)monitor/finalized/batch_result/ai_stt 분기
hooks/use-external-stt.tsReact 래퍼·콜백 배선