P0에 남아 있던 세 가지. 실측 46분 회의 transcript에서 확인된 문제를
하나씩 코드로 막는다.
## 1. Chrome이 흘리는 것을 붙잡는다
**미확정 발화 보존** — Chrome은 `continuous`여도 침묵마다 세션을 닫는데,
그때 확정 전이던 문장을 그냥 버린다. 1시간 회의면 이 사이클이 수십 번 돌고
매번 마지막 한 문장씩 사라졌다. 세션이 끝날 때마다 직접 확정시킨다.
**마지막 발화 보존** — `handleStop`이 렌더 시점의 `chunks`를 그대로 넘기는
바람에 회의 끝머리가 항상 빠졌다. `stopListening()`이 미확정분까지 굳힌
최종 청크를 돌려주도록 바꿨다.
**인식 실패 가시화** — `isFinal: true`에 `transcript: ""`인 결과가 실측에서
19번 나왔다. "소리는 들었는데 못 알아듣겠다"는 신호다. 청크로 넣으면 요약
프롬프트가 오염되고, 조용히 버리면 유실이 보이지 않는다. 빼되 세어서
`⚠️ 인식 실패 N건` 배지로 노출한다.
**미리보기로 강등** — 실시간 텍스트 패널에 (미리보기)를 붙였다. 확정본은
서버 STT 재전사가 만든다.
## 2. 타임스탬프 실측화
`startTime: Math.max(0, now - 2)` 하드코딩을 걷어냈다. interim이 처음 뜬
시각이 곧 발화 시작이므로 그것을 쓴다. 또 `startRecording()`이 캡처 시작
시각을 돌려주고 `startListening(timeOrigin)`이 그것을 받아, 전사 타임스탬프가
오디오 파일의 재생 위치와 같은 시간축을 쓴다.
## 3. 긴 회의가 마지막에 통째로 실패하지 않는다
`/api/summarize`가 10만 자를 넘으면 413으로 거부했다. 3시간 회의를 마치고
회의록을 만들려는 순간 아무것도 못 받는다는 뜻이다. 유실을 막자고 만든
파이프라인 끝에서 결과를 버리는 셈이었다.
구간별로 압축한 뒤(map) 압축본을 모아 평소와 똑같은 템플릿 경로로 회의록을
만든다(reduce). 최종 출력이 짧은 회의와 같은 프롬프트를 타므로 서식이
흔들리지 않는다. 상한은 50만 자(약 14시간)로 올렸다.
한 구간이라도 실패하면 전체를 실패로 돌린다. 일부만 빠진 압축본으로 회의록을
만들면 사용자는 무엇이 빠졌는지 모른 채 멀쩡해 보이는 문서를 받게 된다.
## 검증
브라우저에서 가짜 SpeechRecognition을 주입해 확인:
- 빈 확정 2건 → 청크 0개 + "인식 실패 2건" 배지
- 미확정 마지막 발화가 중지 후 전사문에 남음
- 빈 `[MM:SS]` 줄이 하나도 없음
테스트 229 → 257 통과, 신규 타입 에러 0, 린트 baseline과 동일, 빌드 성공.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzAH7GPWSYTe2AoBV6CZDP
#18이 오디오를 남기게 했다면, 이 PR은 그 오디오를 실제로 쓴다.
Web Speech 포착률 7~10%를 끌어올리는 유일한 방법은 인식 엔진 교체다.
## 두 경로가 하나로 만난다
실시간 녹음 ─┐
├→ recordingId → /api/transcribe → 확정 전사문
파일 업로드 ─┘
`/api/upload`는 파일을 디스크에 떨궈만 두고 "실시간 녹음 탭에서 하세요"라고
안내하던 막다른 길이었다. 이제 녹음 저장소에 넣고 recordingId를 돌려주므로
브라우저 녹음과 완전히 같은 파이프라인을 탄다.
## 실제 타임스탬프
Whisper `verbose_json`이 구간별 실제 오디오 시각을 준다. Web Speech 경로가 쓰던
`결과 이벤트 시각 − 2초` 추정값과 달리 구간 재생·화자 분리에 그대로 쓸 수 있다.
이 값이 있어야 PR #17의 diarization 화자 배정이 비로소 의미를 갖는다.
verbose_json을 지원하지 않는 모델(gpt-4o-transcribe 등)은 400을 주므로 `json`
으로 한 번 더 시도해 텍스트만이라도 받는다.
## 프로바이더별 지원
| 프로바이더 | webm 녹음 | 업로드 파일 | 기본 모델 |
|---|:---:|:---:|---|
| OpenAI · OrcaRouter · 로컬 whisper | O | O | whisper-1 |
| Gemini | X | O | gemini-3.6-flash |
Gemini가 문서로 밝힌 오디오 형식에 webm이 없다. 조용히 실패시키지 않고
"OpenAI 호환 프로바이더를 쓰라"고 명시적으로 안내하며, 설정 화면에도 경고를 띄운다.
## 비트레이트 128k → 32k
이 오디오는 감상용이 아니라 STT 입력이다. 128kbps면 46분 회의가 44MB가 되어
Whisper 상한(25MB)도 Gemini inline 상한도 넘긴다. 32kbps mono Opus는 음성
인식 정확도에 영향이 없으면서 46분을 11MB로 줄인다.
## 참석자·용어 힌트
홈 화면에 입력란을 두고 전사 요청의 어휘 힌트로 보낸다. 실측 transcript에서
"계명대동산병원 → 저희 키스해 주셔서", "양식대로 → 양복점" 같은 고유명사
붕괴가 심했던 부분이다.
## 검증
가짜 Whisper 서버를 세워 종단간 확인:
- 업로드 → recordingId → 전사 → `[00:00]/[00:03]/[00:12]` 전사문
- multipart 필드 검증: model · response_format=verbose_json · language=ko ·
prompt(어휘 힌트) · Bearer 인증 · 파일 바이트 정확히 일치
- webm 녹음도 Whisper로 정상 전사 (5000 bytes 그대로 전달)
- Gemini+webm 거부 / 미설정 400 / 경로조작 400 / 없는 녹음 404
테스트 206 → 229 통과, 신규 타입 에러 0, 린트 baseline과 동일, 빌드 성공.
### 검증하지 못한 것
실제 Whisper·Gemini API를 호출하지 못했다(키 없음). 모든 프로바이더 코드는
목 fetch와 가짜 서버로만 검증했다. 요청 형식은 문서를 따랐으나 실제 응답에
대한 확인이 필요하다.
포착률이 실제로 얼마나 오르는지도 아직 모른다. 다음 회의에서 같은 오디오로
Web Speech와 STT를 나란히 놓고 재야 한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzAH7GPWSYTe2AoBV6CZDP
46분 대면 회의 실측에서 Web Speech 포착률이 7~10%에 그쳤다(489어절 /
분당 10.6어절, 30초 이상 공백 26곳 26분 29초). 빈 텍스트 청크 19개는
Chrome이 `isFinal: true`에 `transcript: ""`를 준 것으로, 소리는 감지했으나
인식에 실패했다는 신호다.
지금까지는 오디오를 어디에도 남기지 않아 놓친 발화를 복구할 수도, 얼마나
놓쳤는지 잴 수도 없었다. 전사와 독립적으로 원음을 파일로 남긴다.
## 유실 방지 설계
메모리에 모았다가 종료 시 한 번에 쓰지 않는다. 그러면 탭이나 서버가 죽는
순간 회의 전체가 사라진다. 15초 조각마다 디스크에 append 하므로 어느
시점에 중단되든 그때까지의 오디오는 남는다.
- 서버 업로드가 실패해도 녹음을 멈추지 않고 브라우저 사본으로 회수한다
- 조각이 3회 재시도 후에도 실패하면 뒤를 이어 붙이지 않고 멈춘다.
중간이 빈 파일은 짧은 파일보다 나쁘다 — 재생도 전사도 안 된다
- 한 조각도 못 받았으면 "보관됨"이라고 하지 않고 오류를 띄운다
- 서버 사본이 로컬보다 짧으면 경고한다
## 캡처 제약 변경
`noiseSuppression: false, echoCancellation: false, autoGainControl: true`.
브라우저 기본값은 전화 통화용 튜닝이라 멀리 앉은 화자를 노이즈로 지운다.
AGC는 조용한 화자를 끌어올려 주므로 남긴다. 실측으로 검증할 가설이며
회귀 방지 테스트를 걸어 뒀다.
## 검증
- 서버 파이프라인: 184조각 755KB 업로드 → 다운로드 SHA256 바이트 일치
- 클라이언트 전 경로: 합성 스트림으로 48초 녹음 → 로컬·서버 크기 일치,
서버 파일이 decodeAudioData로 48.12초 실제 오디오로 디코딩됨
- 경로 조작 400 / 없는 세션 404 / 빈 조각 400 / 미허용 mimeType 400
- 테스트 162 → 206 통과, 신규 타입 에러 0, 린트 baseline과 동일
실제 마이크 경로는 테스트하지 못했다. Web Speech와 getUserMedia가 같은
마이크를 두고 경합하는지 실사용 확인이 필요하다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzAH7GPWSYTe2AoBV6CZDP