feat: 서버 STT 재전사 — 오디오 원본에서 확정 전사문을 뽑는다

#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
This commit is contained in:
csbaeandClaude Opus 5 committed 2026-09-09 15:19:49 +09:00
1 parent cf3ce3f40c
commit b115663ecf
21 files changed
+1340 -30

No files matched your search

+56 -4
View File
@@ -1,15 +1,39 @@
import { completeWithGemini, resolveGeminiModel } from './gemini'
import { completeWithOpenAICompatible } from './openai-compatible'
import {
DEFAULT_GEMINI_STT_MODEL,
completeWithGemini,
resolveGeminiModel,
transcribeWithGemini,
} from './gemini'
import {
DEFAULT_OPENAI_STT_MODEL,
completeWithOpenAICompatible,
transcribeWithOpenAICompatible,
} from './openai-compatible'
import type {
CompletionOptions,
CompletionResult,
ProviderSettings,
TranscriptionInput,
TranscriptionOptions,
TranscriptionResult,
TranscriptionSettings,
} from './types'
export * from './types'
export * from './presets'
export { DEFAULT_GEMINI_MODEL, resolveGeminiModel } from './gemini'
export { normalizeBaseUrl } from './openai-compatible'
export {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_STT_MODEL,
GEMINI_INLINE_AUDIO_LIMIT,
isGeminiSupportedAudioMimeType,
parseTimestampedTranscript,
resolveGeminiModel,
} from './gemini'
export {
DEFAULT_OPENAI_STT_MODEL,
isWhisperSupportedMimeType,
normalizeBaseUrl,
} from './openai-compatible'
/**
* 설정된 프로바이더로 프롬프트 1회 호출.
@@ -41,3 +65,31 @@ export function toProviderSettings(
): ProviderSettings {
return settings ?? { provider: 'gemini', apiKey: apiKey ?? '' }
}
/**
* 설정된 프로바이더로 오디오 1건 전사.
*
* 요약과 마찬가지로 실패를 예외로 던지지 않는다. 전사는 회의가 끝난 뒤
* 한 번뿐인 기회이므로, 호출부가 오류 문구를 그대로 사용자에게 보여주고
* 원본 오디오를 내려받도록 안내할 수 있어야 한다.
*/
export async function transcribe(
input: TranscriptionInput,
settings: TranscriptionSettings,
options: TranscriptionOptions = {},
): Promise<TranscriptionResult> {
if (settings.provider === 'openai-compatible') {
return transcribeWithOpenAICompatible(input, settings, options)
}
return transcribeWithGemini(input, settings, options)
}
/** 전사에 실제로 쓰일 모델 이름. */
export function resolveSttModel(settings: TranscriptionSettings): string {
const explicit = settings.sttModel?.trim()
if (explicit) return explicit
return settings.provider === 'openai-compatible'
? DEFAULT_OPENAI_STT_MODEL
: DEFAULT_GEMINI_STT_MODEL
}