STT 배치 서버 (Go) — 화자분리·dedup 코드레벨 동작 흐름 P0코드레벨

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

작성일: 2026-06-14 대상: 개발자 — 외부 STT Go 서버 배치 처리 흐름 파악 핵심 파일: apps/stt/

개요 · 범위

PPI는 3계층 STT(External STT / OpenAI Realtime 내장 전사 / Web Speech 폴백)를 쓴다. 이 문서는 그중 Primary인 External STT의 서버측 = Go 배치 서버(apps/stt)를 다룬다. 아동 마이크 + AI 음성을 받아 Google Cloud Speech v2(Chirp 3)로 배치 인식하고, 화자분리로 아동 발화만 뽑아 dedup·환각감지 후 돌려준다.

범위 분리: 브라우저측 WebSocket 클라이언트(external-stt-observer.ts)·오디오 캡처·waitForSttResponse 모드는 로드맵 #9(External STT 클라이언트)에서 별도. 이 문서는 wss://.../ws/stt 너머 Go 서버 내부.

전체 흐름

[클라] mic(0x00)/ai(0x01) PCM16 ──WS binary──▶ WriteAudioChunk → CurrentTurn.{User,AI}Audio [클라] {type:"finalize"} ──WS text──▶ handleBatchFinalize └─ debounce 200ms → IsLatest? → SnapshotAndResetCurrentTurn → trim(user leading/trailing, AI silence) → S3 업로드(req{N}_*.wav) → BuildCombinedAudioWithCurrent(history + 현재턴, AI 1.5x) → AddTurnToHistory → RecognizeBatch(Chirp3, 30s) → filterValidWords → extractUserUtterance (First-Speaker Rule 화자분리 → user 단어 → dedup → 환각감지) → IsDuplicateResult? → History.AddUser → batch_result 전송

WebSocket 프로토콜 handler/websocket.go

GET /ws/stt?userId=&lessonIndex= → Upgrade(L59) → Session 생성 → 메시지 루프(binary=오디오 / text=제어).

바이너리(오디오) handleBinaryMessage L100

[Byte 0: speaker] + [PCM Int16 16kHz]
  0x00 = user(마이크)   0x01 = ai(AI 발화)
→ sess.WriteAudioChunk(speaker, audioData)  // CurrentTurn 버퍼 누적

제어/응답 메시지

방향메시지의미
C→Sfinalize배치 STT 트리거(debounce)
C→Sprocess_ai_audioAI 오디오만 전사
C→Sreset진행 중 finalize 무효화 + 턴/히스토리 초기화(활동 전환)
S→Cbatch_result{transcript, originalText, wasDeduped, diarization, turnCount, ...}
S→Cai_stt{transcript, confidence, history_count}

세션 · 턴 모델 session/session.go, history.go

구조역할
CurrentTurn {UserAudio, AIAudio}현재 턴 PCM 버퍼(bytes.Buffer)
TurnHistory []*Turn최근 MAX_BATCH_TURNS(기본 1)개 턴 — combined audio 컨텍스트 윈도우
History화자별 텍스트 히스토리(기본 3) — 화자분리 컨텍스트
AIContext {LastAITranscript, AISpeakerLabel}AI 화자 매칭 캐시
BatchDebouncefinalize 디바운스 + 최신요청 식별(requestID)

handleBatchFinalize 흐름 websocket.go:170–360

  1. 1requestID 발급(BatchDebounce.NewRequest) → goroutine 시작.
  2. 2debounce 대기(기본 200ms). 도중 cancel/Done이면 종료. 깨어나면 IsLatest 1차 확인(stale면 폐기).
  3. 3SnapshotAndResetCurrentTurn — API 호출 전에 현재 턴을 스냅샷+리셋(비동기 STT 중 다음 턴 오디오가 섞이는 것 방지).
  4. 4무음 trim. user leading(TrimLeadingSilencePCM) + user trailing(silence_duration_ms*0.75, ≤1500ms 동적) + AI leading/trailing.
  5. 5S3 업로드(비동기 goroutine): req{N}_ai.wav, _user.wav.
  6. 6combined audio 빌드(BuildCombinedAudioWithCurrent): history 윈도우 + 현재 턴, AI 오디오 1.5x 가속. → req{N}_combined.wav도 S3.
  7. 7IsLatest 2차 확인(snapshot 후) → AddTurnToHistory(다음 요청 컨텍스트로).
  8. 8RecognizeBatch(30s 타임아웃) → IsLatest 3차 확인(stale 결과 폐기).
  9. 9중복/성공 처리: IsDuplicateResult면 빈 결과. 아니면 SetLastResult + History.AddUser + batch_result 전송.
3중 IsLatest 가드: debounce 후 / snapshot 후 / API 후 — 빠른 연속 finalize에서 오래된 요청 결과가 새 결과를 덮어쓰지 않도록 단계마다 최신성 재확인.

배치 처리 stt/batch.go: Process L43

  1. RecognizeBatch(combinedAudio) → Google Cloud Speech v2(Chirp 3 한국어 인식기), 단어별 timestamp + SpeakerLabel.
  2. filterValidWords(L108): 오디오 길이를 초과하는 단어 제거(0.1s 허용) — 환각 1차 필터.
  3. extractUserUtterance(L120): 화자분리로 아동 발화만 추출(아래). AI 오디오 없으면 전체 텍스트 + dedup.

반환 BatchResult { UserText, FullText, Confidence, Latency, SuppressUser, OriginalText, WasDeduped, Diarization }.

화자분리(diarization) diarization/speaker.go

First-Speaker Rule가 1차 규칙(MatchAISpeaker L19): combined audio는 AI 오디오를 먼저 붙이므로, 배치 결과의 첫 화자 = AI로 본다(decision="first_speaker"). 나머지 화자가 아동(user).
  • 보조: MatchAISpeakerByText(L42)는 알려진 AI 전사와 각 화자 텍스트를 Jaro-Winkler 유사도로 비교(텍스트 기반 폴백). ⚠️ 설정 변수명은 AI_SPEAKER_MIN_JACCARD 등 Jaccard 잔재이나 실제 함수는 Jaro-Winkler.
  • collectTrailingUserWords(batch.go L390): 끝에서부터 연속된 user 단어 수집(가장 최근 발화).
  • 초기 2턴 억제: turnIndex < 2suppressUser(화자 매칭 데이터 수집 기간 — 오인식 방지).

dedup · 환각 감지 stt/dedup.go

함수동작
CollapseConsecutiveWords(L10)연속 동일 단어를 maxConsecutive(2)회로 축소. ["안녕"×5]→["안녕"×2], removed=3
CollapseRepeatedPattern(L55)1~3자 반복 패턴("ㅋ"×8)을 maxRepeat(3)회로 축소
IsHallucination(L97)제거율 removed/original ≥ 0.8이면 환각으로 판정 → 전체 결과 폐기

dedup 발생 시 WasDeduped=true + OriginalText(원본) 동봉 → 클라이언트가 세션 로그에 stt_dedup 타입으로 원본 보존.

AI 오디오 STT handleProcessAIAudio L362

process_ai_audio 수신 시 CurrentTurn.AIAudio만 Google Speech로 전사(10s) → AIContext.LastAITranscript 갱신(다음 화자분리 컨텍스트) → ai_stt 메시지(transcript, confidence, history_count) 전송.

오디오 S3 저장 uploadAudioFiles L444, buildWavBytes L464

  • 매 요청마다 req{N}_ai.wav, req{N}_user.wav, req{N}_combined.wavsessionID 프리픽스 아래 업로드(fire-and-forget goroutine).
  • buildWavBytes로 PCM16 16kHz mono를 메모리에서 WAV 헤더 붙여 직렬화.
  • 용도: STT 품질 디버깅/재현(음성↔전사 대조).

주요 설정값 config/config.go

항목기본값
BATCH_FINALIZE_DEBOUNCE_MS200
BATCH_AI_AUDIO_SPEED1.5 (AI 가속)
MAX_BATCH_TURNS / MAX_BATCH_DURATION_SEC1 / 60
BATCH_TRIM_*leading silence true, threshold 200, keep 200ms
화자분리 AI_SPEAKER_MIN_JACCARD/MIN_TOKENS/MARGIN0.2 / 3 / 0.05 (변수명 Jaccard, 구현 Jaro-Winkler)
환각 임계값제거율 ≥ 80% (dedup.go)
Google Cloudproject dubu-pingpong, asia-northeast1, recognizer chirp-korean-v1

함정 · 주의

  • snapshot은 API 호출 전: 비동기 STT 도중 들어오는 다음 턴 오디오가 섞이지 않도록 반드시 호출 전에 스냅샷+리셋.
  • 3중 IsLatest: debounce/snapshot/API 후 단계마다 확인. 하나라도 빼면 stale 결과가 최신을 덮어씀.
  • combined는 AI 먼저: First-Speaker Rule이 "첫 화자=AI"에 의존하므로 combined 빌드 시 AI를 앞에 둬야 한다(순서 바뀌면 화자 라벨 반전).
  • config 변수명 ≠ 구현: AI_SPEAKER_MIN_JACCARD지만 실제 유사도는 Jaro-Winkler. 튜닝 시 혼동 주의.
  • 초기 2턴 억제: turnIndex<2 suppressUser는 의도된 동작(데이터 수집). "첫 발화가 안 잡힌다" 오해 금지.
  • 환각 폐기는 전부-아니면-전무: 제거율 80% 넘으면 부분이 아니라 전체 결과를 버린다.

파일 · 라인 레퍼런스

파일역할
apps/stt/main.go서버 진입점(HTTP + /ws/stt)
internal/handler/websocket.goWS 핸들러·finalize·process_ai_audio·S3 업로드
internal/stt/batch.go배치 처리·화자분리 호출·user 발화 추출
internal/stt/client.goGoogle Cloud Speech v2 클라이언트
internal/stt/dedup.go연속/패턴 축소·환각 판정
internal/diarization/speaker.goFirst-Speaker Rule + Jaro-Winkler
internal/session/session.go, history.go턴 버퍼·히스토리·debounce
config/config.go환경변수 설정