마지막 업데이트 2026-07-22
PPI는 3계층 STT(External STT / OpenAI Realtime 내장 전사 / Web Speech 폴백)를 쓴다. 이 문서는 그중 Primary인 External STT의 서버측 = Go 배치 서버(apps/stt)를 다룬다. 아동 마이크 + AI 음성을 받아 Google Cloud Speech v2(Chirp 3)로 배치 인식하고, 화자분리로 아동 발화만 뽑아 dedup·환각감지 후 돌려준다.
external-stt-observer.ts)·오디오 캡처·waitForSttResponse 모드는 로드맵 #9(External STT 클라이언트)에서 별도. 이 문서는 wss://.../ws/stt 너머 Go 서버 내부.
GET /ws/stt?userId=&lessonIndex= → Upgrade(L59) → Session 생성 → 메시지 루프(binary=오디오 / text=제어).
[Byte 0: speaker] + [PCM Int16 16kHz]
0x00 = user(마이크) 0x01 = ai(AI 발화)
→ sess.WriteAudioChunk(speaker, audioData) // CurrentTurn 버퍼 누적
| 방향 | 메시지 | 의미 |
|---|---|---|
| C→S | finalize | 배치 STT 트리거(debounce) |
| C→S | process_ai_audio | AI 오디오만 전사 |
| C→S | reset | 진행 중 finalize 무효화 + 턴/히스토리 초기화(활동 전환) |
| S→C | batch_result | {transcript, originalText, wasDeduped, diarization, turnCount, ...} |
| S→C | ai_stt | {transcript, confidence, history_count} |
| 구조 | 역할 |
|---|---|
CurrentTurn {UserAudio, AIAudio} | 현재 턴 PCM 버퍼(bytes.Buffer) |
TurnHistory []*Turn | 최근 MAX_BATCH_TURNS(기본 1)개 턴 — combined audio 컨텍스트 윈도우 |
History | 화자별 텍스트 히스토리(기본 3) — 화자분리 컨텍스트 |
AIContext {LastAITranscript, AISpeakerLabel} | AI 화자 매칭 캐시 |
BatchDebounce | finalize 디바운스 + 최신요청 식별(requestID) |
BatchDebounce.NewRequest) → goroutine 시작.IsLatest 1차 확인(stale면 폐기).TrimLeadingSilencePCM) + user trailing(silence_duration_ms*0.75, ≤1500ms 동적) + AI leading/trailing.req{N}_ai.wav, _user.wav.BuildCombinedAudioWithCurrent): history 윈도우 + 현재 턴, AI 오디오 1.5x 가속. → req{N}_combined.wav도 S3.AddTurnToHistory(다음 요청 컨텍스트로).IsDuplicateResult면 빈 결과. 아니면 SetLastResult + History.AddUser + batch_result 전송.RecognizeBatch(combinedAudio) → Google Cloud Speech v2(Chirp 3 한국어 인식기), 단어별 timestamp + SpeakerLabel.filterValidWords(L108): 오디오 길이를 초과하는 단어 제거(0.1s 허용) — 환각 1차 필터.extractUserUtterance(L120): 화자분리로 아동 발화만 추출(아래). AI 오디오 없으면 전체 텍스트 + dedup.반환 BatchResult { UserText, FullText, Confidence, Latency, SuppressUser, OriginalText, WasDeduped, Diarization }.
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 단어 수집(가장 최근 발화).turnIndex < 2면 suppressUser(화자 매칭 데이터 수집 기간 — 오인식 방지).| 함수 | 동작 |
|---|---|
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 타입으로 원본 보존.
process_ai_audio 수신 시 CurrentTurn.AIAudio만 Google Speech로 전사(10s) → AIContext.LastAITranscript 갱신(다음 화자분리 컨텍스트) → ai_stt 메시지(transcript, confidence, history_count) 전송.
req{N}_ai.wav, req{N}_user.wav, req{N}_combined.wav를 sessionID 프리픽스 아래 업로드(fire-and-forget goroutine).buildWavBytes로 PCM16 16kHz mono를 메모리에서 WAV 헤더 붙여 직렬화.| 항목 | 기본값 |
|---|---|
BATCH_FINALIZE_DEBOUNCE_MS | 200 |
BATCH_AI_AUDIO_SPEED | 1.5 (AI 가속) |
MAX_BATCH_TURNS / MAX_BATCH_DURATION_SEC | 1 / 60 |
BATCH_TRIM_* | leading silence true, threshold 200, keep 200ms |
화자분리 AI_SPEAKER_MIN_JACCARD/MIN_TOKENS/MARGIN | 0.2 / 3 / 0.05 (변수명 Jaccard, 구현 Jaro-Winkler) |
| 환각 임계값 | 제거율 ≥ 80% (dedup.go) |
| Google Cloud | project dubu-pingpong, asia-northeast1, recognizer chirp-korean-v1 |
AI_SPEAKER_MIN_JACCARD지만 실제 유사도는 Jaro-Winkler. 튜닝 시 혼동 주의.| 파일 | 역할 |
|---|---|
| apps/stt/main.go | 서버 진입점(HTTP + /ws/stt) |
| internal/handler/websocket.go | WS 핸들러·finalize·process_ai_audio·S3 업로드 |
| internal/stt/batch.go | 배치 처리·화자분리 호출·user 발화 추출 |
| internal/stt/client.go | Google Cloud Speech v2 클라이언트 |
| internal/stt/dedup.go | 연속/패턴 축소·환각 판정 |
| internal/diarization/speaker.go | First-Speaker Rule + Jaro-Winkler |
| internal/session/session.go, history.go | 턴 버퍼·히스토리·debounce |
| config/config.go | 환경변수 설정 |