mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
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:
1 parent
cf3ce3f40c
commit
b115663ecf
21 files changed
+1340
-30
No files matched your search
@@ -71,6 +71,8 @@ describe('프로바이더 설정 저장', () => {
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||
model: 'google/gemini-2.5-flash-lite',
|
||||
// 예전에 저장된 설정에는 sttModel이 없다. 빈 값으로 채워 마이그레이션 없이 읽힌다.
|
||||
sttModel: '',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,6 +107,7 @@ describe('getProviderRequestPayload', () => {
|
||||
apiKey: 'AIza-legacy',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
sttModel: '',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,6 +117,7 @@ describe('getProviderRequestPayload', () => {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
sttModel: '',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -130,6 +134,7 @@ describe('getProviderRequestPayload', () => {
|
||||
apiKey: '',
|
||||
baseUrl: 'http://localhost:11434/v1',
|
||||
model: 'llama3.1',
|
||||
sttModel: '',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
isGeminiSupportedAudioMimeType,
|
||||
isWhisperSupportedMimeType,
|
||||
parseTimestampedTranscript,
|
||||
resolveSttModel,
|
||||
transcribe,
|
||||
GEMINI_INLINE_AUDIO_LIMIT,
|
||||
DEFAULT_OPENAI_STT_MODEL,
|
||||
DEFAULT_GEMINI_STT_MODEL,
|
||||
} from '@/lib/providers'
|
||||
import { resolveTranscriptionSettings } from '@/lib/api-keys'
|
||||
import {
|
||||
formatTranscriptionSegments,
|
||||
segmentsToChunks,
|
||||
} from '@/lib/transcript-formatter'
|
||||
import type { TranscriptionInput } from '@/lib/providers/types'
|
||||
|
||||
function audio(overrides: Partial<TranscriptionInput> = {}): TranscriptionInput {
|
||||
return {
|
||||
bytes: new Uint8Array([1, 2, 3, 4]),
|
||||
mimeType: 'audio/webm;codecs=opus',
|
||||
fileName: 'meeting.webm',
|
||||
language: 'ko',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function okResponse(body: unknown) {
|
||||
return { ok: true, status: 200, json: async () => body } as unknown as Response
|
||||
}
|
||||
|
||||
/** 목의 호출 인자를 좁혀서 읽는다. vi.fn의 추론 타입은 fetch 오버로드와 안 맞는다. */
|
||||
function callArgs(
|
||||
mock: { mock: { calls: unknown[][] } },
|
||||
index = 0,
|
||||
): { url: string; init: RequestInit } {
|
||||
const [url, init] = mock.mock.calls[index] as [string, RequestInit]
|
||||
return { url, init }
|
||||
}
|
||||
|
||||
function errResponse(status: number) {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
json: async () => ({ error: 'nope' }),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('mime 지원 판정', () => {
|
||||
it('Whisper는 브라우저 녹음(webm)을 받는다', () => {
|
||||
expect(isWhisperSupportedMimeType('audio/webm;codecs=opus')).toBe(true)
|
||||
expect(isWhisperSupportedMimeType('audio/mpeg')).toBe(true)
|
||||
expect(isWhisperSupportedMimeType('audio/x-aiff')).toBe(false)
|
||||
})
|
||||
|
||||
it('Gemini는 webm을 받지 않는다', () => {
|
||||
// 이게 true로 바뀌면 UI의 안내 문구도 같이 고쳐야 한다.
|
||||
expect(isGeminiSupportedAudioMimeType('audio/webm')).toBe(false)
|
||||
expect(isGeminiSupportedAudioMimeType('audio/mpeg')).toBe(true)
|
||||
expect(isGeminiSupportedAudioMimeType('audio/flac')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transcribe — OpenAI 호환', () => {
|
||||
const settings = {
|
||||
provider: 'openai-compatible' as const,
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
}
|
||||
|
||||
it('multipart로 보내고 verbose_json 구간을 파싱한다', async () => {
|
||||
const fetchFn = vi.fn(async () =>
|
||||
okResponse({
|
||||
text: '안녕하세요 회의 시작합니다',
|
||||
segments: [
|
||||
{ start: 0, end: 2.5, text: ' 안녕하세요' },
|
||||
{ start: 2.5, end: 5, text: ' 회의 시작합니다' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await transcribe(audio(), settings, { fetchFn })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
|
||||
expect(result.text).toBe('안녕하세요 회의 시작합니다')
|
||||
expect(result.segments).toEqual([
|
||||
{ start: 0, end: 2.5, text: '안녕하세요' },
|
||||
{ start: 2.5, end: 5, text: '회의 시작합니다' },
|
||||
])
|
||||
expect(result.model).toBe(DEFAULT_OPENAI_STT_MODEL)
|
||||
|
||||
const { url, init } = callArgs(fetchFn)
|
||||
expect(url).toBe('https://api.example.com/v1/audio/transcriptions')
|
||||
expect(init.body).toBeInstanceOf(FormData)
|
||||
|
||||
const form = init.body as FormData
|
||||
expect(form.get('model')).toBe(DEFAULT_OPENAI_STT_MODEL)
|
||||
expect(form.get('response_format')).toBe('verbose_json')
|
||||
expect(form.get('language')).toBe('ko')
|
||||
})
|
||||
|
||||
it('어휘 힌트를 prompt로 보낸다', async () => {
|
||||
const fetchFn = vi.fn(async () =>
|
||||
okResponse({ text: '내용' }),
|
||||
)
|
||||
|
||||
await transcribe(audio({ vocabularyHint: '김효천, PACS' }), settings, {
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
const form = callArgs(fetchFn).init.body as FormData
|
||||
expect(form.get('prompt')).toBe('김효천, PACS')
|
||||
})
|
||||
|
||||
it('verbose_json 미지원(400)이면 json으로 한 번 더 시도한다', async () => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(errResponse(400))
|
||||
.mockResolvedValueOnce(okResponse({ text: '타임스탬프 없는 결과' }))
|
||||
|
||||
const result = await transcribe(audio(), settings, { fetchFn })
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2)
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
expect(result.segments).toEqual([])
|
||||
|
||||
const second = callArgs(fetchFn, 1).init.body as FormData
|
||||
expect(second.get('response_format')).toBe('json')
|
||||
})
|
||||
|
||||
it('429는 rateLimited로 표시한다', async () => {
|
||||
const fetchFn = vi.fn(async () => errResponse(429))
|
||||
const result = await transcribe(audio(), settings, { fetchFn })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (result.success) return
|
||||
expect(result.rateLimited).toBe(true)
|
||||
})
|
||||
|
||||
it('빈 전사 결과를 성공으로 넘기지 않는다', async () => {
|
||||
const fetchFn = vi.fn(async () => okResponse({ text: ' ' }))
|
||||
const result = await transcribe(audio(), settings, { fetchFn })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (result.success) return
|
||||
expect(result.error).toContain('비어 있습니다')
|
||||
})
|
||||
|
||||
it('base URL이 잘못되면 호출조차 하지 않는다', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
const result = await transcribe(
|
||||
audio(),
|
||||
{ ...settings, baseUrl: 'file:///etc/passwd' },
|
||||
{ fetchFn },
|
||||
)
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('망가진 구간은 버리고 정상 구간만 남긴다', async () => {
|
||||
const fetchFn = vi.fn(async () =>
|
||||
okResponse({
|
||||
text: 'ok',
|
||||
segments: [
|
||||
{ start: 0, end: 1, text: '정상' },
|
||||
{ start: 'x', end: 2, text: '시작이 숫자가 아님' },
|
||||
{ start: 3, end: 4, text: ' ' },
|
||||
{ start: 5, end: 6, text: '또 정상' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await transcribe(audio(), settings, { fetchFn })
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
expect(result.segments.map((s) => s.text)).toEqual(['정상', '또 정상'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('transcribe — Gemini', () => {
|
||||
const settings = { provider: 'gemini' as const, apiKey: 'AIza-test' }
|
||||
|
||||
it('webm은 거부하고 대안을 안내한다', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
const result = await transcribe(audio(), settings, { fetchFn })
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(false)
|
||||
if (result.success) return
|
||||
expect(result.error).toContain('OpenAI 호환')
|
||||
})
|
||||
|
||||
it('지원 형식은 inline_data로 보낸다', async () => {
|
||||
const fetchFn = vi.fn(async () =>
|
||||
okResponse({
|
||||
candidates: [{ content: { parts: [{ text: '[00:03] 안녕하세요' }] } }],
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await transcribe(
|
||||
audio({ mimeType: 'audio/mpeg', fileName: 'a.mp3' }),
|
||||
settings,
|
||||
{ fetchFn },
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
expect(result.segments).toEqual([{ start: 3, end: 4, text: '안녕하세요' }])
|
||||
|
||||
const body = JSON.parse(callArgs(fetchFn).init.body as string)
|
||||
expect(body.contents[0].parts[1].inline_data.mime_type).toBe('audio/mpeg')
|
||||
expect(body.contents[0].parts[1].inline_data.data).toBe(
|
||||
Buffer.from([1, 2, 3, 4]).toString('base64'),
|
||||
)
|
||||
})
|
||||
|
||||
it('inline 상한을 넘으면 호출하지 않고 대안을 안내한다', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
const result = await transcribe(
|
||||
audio({
|
||||
mimeType: 'audio/mpeg',
|
||||
bytes: new Uint8Array(GEMINI_INLINE_AUDIO_LIMIT + 1),
|
||||
}),
|
||||
settings,
|
||||
{ fetchFn },
|
||||
)
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(false)
|
||||
if (result.success) return
|
||||
expect(result.error).toContain('너무 큽니다')
|
||||
})
|
||||
|
||||
it('키가 없으면 거부한다', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
const result = await transcribe(
|
||||
audio({ mimeType: 'audio/mpeg' }),
|
||||
{ provider: 'gemini', apiKey: ' ' },
|
||||
{ fetchFn },
|
||||
)
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTimestampedTranscript', () => {
|
||||
it('MM:SS와 HH:MM:SS를 모두 읽는다', () => {
|
||||
const segments = parseTimestampedTranscript(
|
||||
['[00:03] 첫 발화', '[01:10] 두 번째', '[1:02:05] 한참 뒤'].join('\n'),
|
||||
)
|
||||
|
||||
expect(segments.map((s) => s.start)).toEqual([3, 70, 3725])
|
||||
})
|
||||
|
||||
it('다음 구간 시작을 이전 구간의 끝으로 잡는다', () => {
|
||||
const segments = parseTimestampedTranscript('[00:00] 가\n[00:10] 나')
|
||||
expect(segments[0]).toEqual({ start: 0, end: 10, text: '가' })
|
||||
expect(segments[1].end).toBeGreaterThan(segments[1].start)
|
||||
})
|
||||
|
||||
it('타임스탬프 없는 줄은 무시한다', () => {
|
||||
expect(parseTimestampedTranscript('그냥 텍스트\n또 텍스트')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTranscriptionSegments', () => {
|
||||
it('실제 오디오 타임라인으로 전사문을 만든다', () => {
|
||||
const text = formatTranscriptionSegments([
|
||||
{ start: 3, text: '어떤 거죠' },
|
||||
{ start: 3725, text: '한참 뒤' },
|
||||
])
|
||||
|
||||
expect(text).toBe('[00:03] 어떤 거죠\n[01:02:05] 한참 뒤')
|
||||
})
|
||||
|
||||
it('빈 구간은 넣지 않는다', () => {
|
||||
// Web Speech 경로에서 빈 청크 19개가 요약 프롬프트를 오염시켰다.
|
||||
expect(
|
||||
formatTranscriptionSegments([
|
||||
{ start: 0, text: ' ' },
|
||||
{ start: 1, text: '내용' },
|
||||
]),
|
||||
).toBe('[00:01] 내용')
|
||||
})
|
||||
})
|
||||
|
||||
describe('segmentsToChunks', () => {
|
||||
it('청크 구조로 옮기면서 시간을 보존한다', () => {
|
||||
expect(
|
||||
segmentsToChunks([{ start: 1.5, end: 4.25, text: ' 발화 ' }]),
|
||||
).toEqual([{ text: '발화', startTime: 1.5, endTime: 4.25, isFinal: true }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTranscriptionSettings', () => {
|
||||
it('대화 모델이 없어도 전사는 가능하다', () => {
|
||||
// 요약용 resolveProviderSettings는 model이 비면 null을 준다.
|
||||
const settings = resolveTranscriptionSettings({
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
apiKey: 'sk-x',
|
||||
})
|
||||
|
||||
expect(settings).not.toBeNull()
|
||||
expect(resolveSttModel(settings!)).toBe(DEFAULT_OPENAI_STT_MODEL)
|
||||
})
|
||||
|
||||
it('sttModel을 지정하면 그것을 쓴다', () => {
|
||||
const settings = resolveTranscriptionSettings({
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
sttModel: 'gpt-4o-transcribe',
|
||||
})
|
||||
expect(resolveSttModel(settings!)).toBe('gpt-4o-transcribe')
|
||||
})
|
||||
|
||||
it('gemini 기본 STT 모델은 오디오를 받는 모델이다', () => {
|
||||
const settings = resolveTranscriptionSettings({
|
||||
provider: 'gemini',
|
||||
apiKey: 'AIza-x',
|
||||
})
|
||||
expect(resolveSttModel(settings!)).toBe(DEFAULT_GEMINI_STT_MODEL)
|
||||
})
|
||||
|
||||
it('base URL이 없으면 null', () => {
|
||||
expect(
|
||||
resolveTranscriptionSettings({ provider: 'openai-compatible' }),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user