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

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

STT 배치 서버 (Go) — 화자분리·dedup 코드레벨 동작 흐름 P0…: 입력: 개요 · 범위, 주요 처리 단계: 전체 흐름, 결과: 파일 · 라인 레퍼런스 흐름
동작 흐름 요약
  1. 입력: 개요 · 범위
  2. 주요 처리 단계: 전체 흐름
  3. 결과: 파일 · 라인 레퍼런스
작성일: 2026-06-14 대상: 개발자 — 외부 STT Go 서버 배치 처리 흐름 파악 핵심 파일: apps/stt/
💬 대화로 먼저 이해하기 — "속기사무소 비유" (비개발자·처음 읽는 사람용)
Q이 서버(apps/stt)는 한마디로 뭘 하나요?
A수업 녹음을 글로 옮기는 속기사무소예요. 아동 마이크 소리와 핑퐁이(AI) 목소리를 함께 받아 외부 속기사(Google Speech v2, Chirp 3)에게 맡기고, 그 결과에서 아동 발화만 골라 돌려줍니다.
Q두 목소리가 섞여 있는데 어느 쪽이 아동인지 어떻게 알아요?
A속기사에게 넘기는 테이프(combined audio) 맨 앞에 항상 AI 목소리를 먼저 붙여요. 그래서 "첫 번째로 말한 화자 = AI"라는 규칙(First-Speaker Rule)으로 나머지를 아동으로 판정하고, 보조로 알고 있는 AI 대사와 텍스트 유사도(Jaro-Winkler)도 비교합니다.
Q받아쓴 글에 "안녕안녕안녕안녕…" 같은 반복이 있으면요?
A음성인식이 헛것을 들었을 수 있으니 반복 단어를 2회로 줄이고(dedup), 잘라낸 비율이 80%를 넘으면 전체를 환각으로 판정해 통째로 폐기해요. 원본은 OriginalText로 보존해 나중에 대조할 수 있게 합니다.
Q속기 요청이 연달아 빠르게 들어오면 순서가 꼬이지 않나요?
A접수마다 번호표(requestID)를 발급하고 200ms 기다린 뒤, 최신 번호표인지 3번(debounce 후·스냅샷 후·API 후) 확인해서 오래된 결과는 폐기해요. 상세 코드는 아래 "handleBatchFinalize 흐름" 섹션과 websocket.go·diarization/speaker.go를 보세요.

개요 · 범위

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환경변수 설정