diff --git a/.env.example b/.env.example index 3320d7d..8e2f77e 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,10 @@ GEMINI_API_KEY= # ⚠️ 녹음 파일에는 회의 원음이 그대로 들어 있습니다. 디스크 암호화된 위치에 # 두고, 필요 없어진 녹음은 직접 삭제하세요. SECURITY.md 참고. # RECORDINGS_DIR= + +# 음성 전사(STT) 모델 (선택) +# 비우면 프로바이더 기본값: OpenAI 호환 → whisper-1, Gemini → gemini-3.6-flash +# +# ⚠️ 전사는 회의 원음을 프로바이더 서버로 보냅니다. 텍스트보다 훨씬 민감합니다. +# 외부로 내보내고 싶지 않다면 로컬 whisper 서버를 LLM_BASE_URL로 지정하세요. +# LLM_STT_MODEL= diff --git a/README.md b/README.md index 79aa8b6..2c8031e 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,44 @@ docker compose up -d 노이즈로 지워버립니다. 그래서 노이즈 억제·에코 제거를 끄고 AGC만 남깁니다 (`src/lib/recording.ts`의 `RECORDING_AUDIO_CONSTRAINTS`). +**비트레이트** — 32kbps mono Opus. 감상용이 아니라 STT 입력이므로 인식 정확도를 +해치지 않는 선에서 최대한 작게 잡았습니다. 46분 회의가 약 11MB로, 전사 API에 +통째로 넣을 수 있습니다. + +--- + +### 🔤 서버 STT 재전사 (정확도의 본선) + +보관된 오디오를 Whisper / Gemini로 다시 전사합니다. **Web Speech가 놓친 발화를 +되살리는 경로이며, 이 앱의 전사 정확도는 사실상 여기서 결정됩니다.** + +**쓰는 법** +- 실시간 녹음 → 종료 → **🔤 원본 오디오로 다시 전사** +- 또는 파일 업로드 탭 → 파일 선택 → **🔤 전사 시작** + +두 경로 모두 같은 파이프라인(`/api/transcribe`)을 씁니다. + +**실제 타임스탬프** — Whisper의 `verbose_json`은 구간별 실제 오디오 시각을 줍니다. +Web Speech 경로가 쓰던 *"결과 이벤트 시각 − 2초"* 추정값과 달리, 구간 재생이나 +화자 분리에 그대로 쓸 수 있는 진짜 타임라인입니다. + +**참석자 · 용어 힌트** — 홈 화면의 입력란에 이름·제품명·사내 용어를 적어두면 +전사 요청의 어휘 힌트로 전달되어 고유명사 오인식이 크게 줍니다. + +**프로바이더별 지원** + +| 프로바이더 | 브라우저 녹음(webm) | 업로드 파일 | 기본 모델 | +|---|:---:|:---:|---| +| OpenAI / OrcaRouter / 로컬 whisper | ✅ | ✅ | `whisper-1` | +| Google Gemini | ❌ | ✅ (mp3·wav·flac·m4a·ogg) | `gemini-3.6-flash` | + +Gemini는 webm 오디오를 받지 않습니다. **실시간 녹음을 전사하려면 OpenAI 호환 +프로바이더를 선택해야 합니다.** 설정 화면에서 이 경고를 함께 안내합니다. + +> 🔴 **전사는 회의 원음을 프로바이더 서버로 보냅니다.** 텍스트보다 훨씬 민감한 +> 데이터입니다. 외부 전송이 곤란하면 로컬 whisper 서버를 `base URL`로 지정하세요. +> 자세한 내용은 [SECURITY.md](SECURITY.md)를 참고하세요. + **사용** 1. 홈에서 **🎤 녹음 시작** 클릭 2. 마이크 권한 허용 @@ -503,7 +541,9 @@ prisma/ | 제약 | 설명 | 대응 | |---|---|---| | **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | 서버 사이드 STT 전환 예정 | -| **전사 포착률 낮음** | 원거리·다인 대면 회의 실측 10% 안팎 | 오디오 원본 보관 → 서버 STT 재전사 (진행 예정) | +| **Web Speech 포착률 낮음** | 원거리·다인 대면 회의 실측 10% 안팎 | 종료 후 **서버 STT 재전사**로 해결 | +| **Gemini는 webm 전사 불가** | Gemini가 받는 오디오 형식에 webm이 없음 | 녹음 전사는 OpenAI 호환 프로바이더 사용 | +| **긴 회의 전사 상한** | Whisper 25MB(≈1시간 40분) / Gemini inline 14MB(≈1시간) | 초과 시 오류 안내. 분할 전사는 후속 | | **화자 구분 없음** | 미구현 (PR #16 / #17 검토 중) | — | | **Gemini 무료 등급 한도** | 15 RPM / 1000 RPD | 한도 초과 시 자동 쿨다운, 단순 변환 폴백 | | **단일 사용자** | 인증 없음, 데이터 격리 없음 | 1인 1인스턴스로 운용 | diff --git a/SECURITY.md b/SECURITY.md index 9df9f18..2d06902 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -63,6 +63,27 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전 이 경우 **파일 업로드 탭**도 같은 한계가 있으므로(현재 업로드 후 전사는 미구현, Phase 4에서 자체 STT 검토 예정), 이 도구의 사용을 보류하는 것을 권장합니다. +### 🔴 주의: 서버 전사(STT)는 회의 **원음**을 외부로 보냅니다 + +"원본 오디오로 다시 전사" 또는 파일 업로드 전사를 실행하면, 회의 오디오 파일이 +**통째로** 선택한 프로바이더 서버로 전송됩니다. + +이것은 이 앱에서 가장 민감도가 높은 데이터 흐름입니다. 전사 텍스트는 Web Speech가 +대부분 놓치지만, **오디오에는 회의에서 오간 모든 말과 목소리가 그대로 담겨 있습니다.** + +| 프로바이더 | 오디오가 가는 곳 | +|---|---| +| Gemini | Google 서버 | +| OpenAI | OpenAI 서버 | +| OrcaRouter 등 중계 | 중계사 → 실제 모델 제공사 (2단계) | +| **로컬 whisper** | **나가지 않음** (whisper.cpp / faster-whisper 등을 `LLM_BASE_URL`로 지정) | + +**외부 전송이 곤란한 회의라면 로컬 whisper 서버를 쓰세요.** 설정 → 프로바이더에서 +"로컬 모델"을 고르고 base URL을 로컬 whisper 엔드포인트로 지정하면 오디오가 +머신 밖으로 나가지 않습니다. + +전사는 사용자가 버튼을 눌러야만 실행됩니다. 녹음만으로는 오디오가 전송되지 않습니다. + ### ⚠️ 주의: AI 프로바이더 선택에 따른 전송 경로 `/settings`에서 고른 프로바이더에 따라 **회의 전문이 지나가는 회사가 달라집니다.** diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1c14e85..119ca52 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -186,11 +186,17 @@ flush해서 7%를 20~30%로는 올려도 90%로는 못 간다. 구현 결함이 - [x] 0바이트일 때 "보관됨"이라고 하지 않음 - [x] 캡처 제약에서 노이즈 억제·에코 제거 해제 (원거리 화자 보존) - [x] 회의록 저장 시 `audioFileName`/`audioMimeType`/`audioDuration` 연결 -- [ ] **서버 STT 파이프라인** — 녹음 파일 → Whisper / Gemini audio → transcript - - [ ] `/api/upload`를 막다른 길에서 본선 경로로 승격 - - [ ] 프로바이더 레이어에 `/v1/audio/transcriptions` 추가 (#12 구조 확장) - - [ ] Web Speech는 "녹음 중 실시간 미리보기"로 강등, 확정본은 종료 후 재전사 - - [ ] 같은 오디오로 Web Speech vs STT 포착률 실측 비교 +- [x] **서버 STT 파이프라인** — 녹음 파일 → Whisper / Gemini audio → transcript + - [x] `/api/upload`를 막다른 길에서 본선 경로로 승격 (업로드 → recordingId → 전사) + - [x] 프로바이더 레이어에 `/v1/audio/transcriptions` 추가 (#12 구조 확장) + - [x] Gemini 오디오 inline 입력 (webm 미지원은 명시적으로 안내) + - [x] `verbose_json`으로 실제 오디오 타임스탬프 확보 + - [x] 참석자·용어 힌트를 전사 프롬프트로 전달 + - [x] 녹음 비트레이트 32kbps로 하향 — 46분 회의가 11MB로 API 상한 안에 들어옴 + - [ ] 같은 오디오로 Web Speech vs STT 포착률 실측 비교 ← **다음 회의에서** + - [ ] 상한 초과 회의 분할 전사 (Whisper 25MB / Gemini inline 14MB) + - [ ] Gemini Files API 경로 (큰 파일 + webm 우회) + - [ ] Web Speech를 "실시간 미리보기"로 명시적 강등 (현재는 둘 다 노출) - [ ] **빈 청크 필터링** — 빈 발화 19개가 요약 프롬프트를 오염시키고 있음 - [ ] **타임스탬프 실측화** — `useSpeechRecognition.ts`의 `startTime: now - 2` 하드코딩 제거 - [ ] **10만 자 하드 실패 → 분할 요약** — 긴 회의가 마지막에 통째로 실패함 diff --git a/src/__tests__/api-key-storage.test.ts b/src/__tests__/api-key-storage.test.ts index a0f67fd..80d63d4 100644 --- a/src/__tests__/api-key-storage.test.ts +++ b/src/__tests__/api-key-storage.test.ts @@ -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: '', }) }) diff --git a/src/__tests__/transcription.test.ts b/src/__tests__/transcription.test.ts new file mode 100644 index 0000000..2cf2ea0 --- /dev/null +++ b/src/__tests__/transcription.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/app/api/transcribe/route.ts b/src/app/api/transcribe/route.ts new file mode 100644 index 0000000..0513612 --- /dev/null +++ b/src/app/api/transcribe/route.ts @@ -0,0 +1,117 @@ +import { NextRequest } from 'next/server' +import { readFile } from 'fs/promises' +import path from 'path' +import { resolveTranscriptionSettings } from '@/lib/api-keys' +import { resolveSttModel, transcribe } from '@/lib/providers' +import { getRecording, RECORDINGS_DIR } from '@/lib/recording-store' +import { isValidRecordingId } from '@/lib/recording' +import { formatTranscriptionSegments } from '@/lib/transcript-formatter' + +export const dynamic = 'force-dynamic' + +/** 어휘 힌트는 Whisper가 약 224토큰까지만 본다. 넘겨도 버려지므로 잘라 보낸다. */ +const MAX_VOCABULARY_HINT_CHARS = 800 + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})) + const { recordingId, language, vocabularyHint } = body + + if (!isValidRecordingId(recordingId)) { + return Response.json( + { error: '잘못된 녹음 세션 ID입니다.' }, + { status: 400 }, + ) + } + + const meta = await getRecording(recordingId) + if (!meta) { + return Response.json( + { error: '녹음을 찾을 수 없습니다.' }, + { status: 404 }, + ) + } + + const settings = resolveTranscriptionSettings(body) + if (!settings) { + return Response.json( + { + error: + 'AI 프로바이더가 설정되지 않았습니다. /settings에서 먼저 설정해주세요.', + }, + { status: 400 }, + ) + } + + let bytes: Buffer + try { + bytes = await readFile(path.join(RECORDINGS_DIR, meta.fileName)) + } catch { + return Response.json( + { error: '녹음 파일을 읽을 수 없습니다. 오디오가 저장되지 않았을 수 있습니다.' }, + { status: 404 }, + ) + } + + if (bytes.byteLength === 0) { + return Response.json( + { error: '녹음 파일이 비어 있습니다.' }, + { status: 400 }, + ) + } + + const result = await transcribe( + { + bytes, + mimeType: meta.mimeType, + fileName: meta.fileName, + language: typeof language === 'string' && language ? language : 'ko', + vocabularyHint: + typeof vocabularyHint === 'string' && vocabularyHint.trim().length > 0 + ? vocabularyHint.trim().slice(0, MAX_VOCABULARY_HINT_CHARS) + : undefined, + }, + settings, + ) + + if (!result.success) { + return Response.json( + { error: result.error }, + { status: result.rateLimited ? 429 : 502 }, + ) + } + + // 구간 타임스탬프가 있으면 `[MM:SS]` 형식으로 맞춘다. 없으면 원문 그대로. + const transcript = + result.segments.length > 0 + ? formatTranscriptionSegments(result.segments) + : result.text + + return Response.json({ + transcript, + text: result.text, + segments: result.segments, + model: result.model, + hasTimestamps: result.segments.length > 0, + audioBytes: bytes.byteLength, + }) + } catch (err) { + const message = err instanceof Error ? err.message : '알 수 없는 오류' + return Response.json({ error: `전사 실패: ${message}` }, { status: 500 }) + } +} + +/** 이 프로바이더 설정으로 전사가 가능한지 미리 확인. */ +export async function GET(request: NextRequest) { + const url = new URL(request.url) + const settings = resolveTranscriptionSettings({ + provider: url.searchParams.get('provider'), + baseUrl: url.searchParams.get('baseUrl'), + apiKey: url.searchParams.get('apiKey'), + }) + + return Response.json({ + configured: settings !== null, + model: settings ? resolveSttModel(settings) : null, + }) +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 3a06737..041cac2 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,10 +1,20 @@ import { NextRequest } from 'next/server' import { handleUpload } from '@/lib/upload-handler' -import { writeFile, mkdir } from 'fs/promises' -import path from 'path' +import { + appendChunk, + createRecording, + finalizeRecording, +} from '@/lib/recording-store' -const UPLOAD_DIR = path.join(process.cwd(), 'uploads') +export const dynamic = 'force-dynamic' +/** + * 오디오 파일 업로드. + * + * 예전에는 `uploads/`에 파일만 떨궈 두고 아무것도 하지 않는 막다른 길이었다. + * 지금은 녹음 저장소에 그대로 넣어 `recordingId`를 돌려주므로, 브라우저 녹음과 + * 똑같이 `/api/transcribe`로 전사할 수 있다. 두 경로가 한 파이프라인으로 만난다. + */ export async function POST(request: NextRequest) { try { const formData = await request.formData() @@ -15,19 +25,21 @@ export async function POST(request: NextRequest) { } const audioFile = formData.get('audio') as File - const buffer = Buffer.from(await audioFile.arrayBuffer()) + const bytes = Buffer.from(await audioFile.arrayBuffer()) - await mkdir(UPLOAD_DIR, { recursive: true }) + const meta = await createRecording(result.data.mimeType) + const appended = await appendChunk(meta.id, bytes) - const timestamp = Date.now() - const safeFileName = `${timestamp}_${result.data.fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}` - const filePath = path.join(UPLOAD_DIR, safeFileName) + if (!appended.ok) { + return Response.json({ error: appended.error }, { status: appended.status }) + } - await writeFile(filePath, buffer) + await finalizeRecording(meta.id, null) return Response.json({ ...result.data, - savedAs: safeFileName, + recordingId: meta.id, + savedAs: meta.fileName, }) } catch (err) { const message = err instanceof Error ? err.message : '알 수 없는 오류' diff --git a/src/app/page.tsx b/src/app/page.tsx index f4cac57..6530095 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,6 +6,7 @@ import { AudioUploader } from '@/components/upload/AudioUploader' import { LiveRecorder } from '@/components/recorder/LiveRecorder' import type { CompletedRecording } from '@/hooks/useAudioRecorder' import { extensionForMimeType } from '@/lib/recording' +import { useTranscription } from '@/hooks/useTranscription' import { MinutesViewer } from '@/components/minutes/MinutesViewer' import { getProviderRequestPayload } from '@/lib/api-key-storage' import { @@ -46,6 +47,8 @@ export default function HomePage() { const [error, setError] = useState(null) const [uploadedFile, setUploadedFile] = useState(null) const [audio, setAudio] = useState(null) + const [uploadedRecordingId, setUploadedRecordingId] = useState(null) + const [attendees, setAttendees] = useState('') const templateList = useMemo( () => [ @@ -80,6 +83,7 @@ export default function HomePage() { async function handleUpload(file: File) { setUploadedFile(file) + setUploadedRecordingId(null) setError(null) const formData = new FormData() @@ -96,6 +100,7 @@ export default function HomePage() { } if (!title) setTitle(data.title) + setUploadedRecordingId(data.recordingId ?? null) } catch { setError('파일 업로드에 실패했습니다.') } @@ -153,6 +158,24 @@ export default function HomePage() { setAudio(recording) }, []) + const { + isTranscribing: isTranscribingUpload, + error: uploadTranscribeError, + result: uploadTranscription, + transcribeRecording: transcribeUpload, + } = useTranscription() + + async function transcribeUploadedFile() { + if (!uploadedRecordingId) return + const outcome = await transcribeUpload(uploadedRecordingId, { + vocabularyHint: attendees, + }) + if (outcome?.transcript) { + setTranscript(outcome.transcript) + generateMinutes(outcome.transcript, summaryMode) + } + } + // 회의록과 함께 저장할 오디오 정보. 서버 사본이 없으면 붙일 게 없다. const audioMeta = audio?.recordingId ? { @@ -204,6 +227,22 @@ export default function HomePage() { /> +
+ + setAttendees(e.target.value)} + placeholder="예: 김효천, 이지안, 계명대동산병원, PACS" + className="w-full rounded-xl border border-neutral-300 px-4 py-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100 transition-all" + /> +

+ 이름·제품명·사내 용어를 적어두면 전사가 고유명사를 훨씬 정확히 잡습니다. +

+
+
+
+ + setSttModel(e.target.value)} + placeholder={preset.defaultSttModel || 'whisper-1'} + className="w-full rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all" + /> +

+ 녹음 오디오를 텍스트로 옮길 때 쓰는 모델입니다. 요약 모델과 별개입니다. +

+ {!preset.canTranscribeWebm && ( +

+ ⚠️ 이 프로바이더는 브라우저 녹음 형식(webm)을 받지 않습니다. 실시간 + 녹음을 전사하려면 OpenAI 호환 프로바이더(OrcaRouter · OpenAI · + 로컬 whisper)를 선택하세요. 업로드한 mp3·wav·flac 파일은 전사됩니다. +

+ )} +
+
)} + {transcriptionError && ( +
+ 전사 오류: {transcriptionError} +
+ )} + + {transcription && ( +
+ 🔤 재전사 완료 — {transcription.model} 모델,{' '} + {transcription.segments.length > 0 + ? `${transcription.segments.length}개 구간 (실제 오디오 타임스탬프)` + : '타임스탬프 없음'} + . 아래 텍스트가 교체되었습니다. +
+ )} + {recording && !isListening && (
diff --git a/src/hooks/useTranscription.ts b/src/hooks/useTranscription.ts new file mode 100644 index 0000000..8c7f024 --- /dev/null +++ b/src/hooks/useTranscription.ts @@ -0,0 +1,86 @@ +'use client' + +import { useCallback, useState } from 'react' +import { getProviderRequestPayload } from '@/lib/api-key-storage' +import type { TranscriptionSegment } from '@/lib/providers/types' + +export interface TranscriptionOutcome { + transcript: string + segments: TranscriptionSegment[] + model: string + hasTimestamps: boolean +} + +export interface TranscriptionHook { + isTranscribing: boolean + error: string | null + result: TranscriptionOutcome | null + transcribeRecording: ( + recordingId: string, + options?: { vocabularyHint?: string }, + ) => Promise + reset: () => void +} + +/** + * 보관된 녹음을 서버 STT로 전사한다. + * + * Web Speech 결과를 덮어쓰는 것이 목적이다. 실시간 텍스트는 회의 중 흐름을 + * 보기 위한 초안이고, 확정본은 원본 오디오에서 다시 뽑는다. + */ +export function useTranscription(): TranscriptionHook { + const [isTranscribing, setIsTranscribing] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + + const transcribeRecording = useCallback( + async (recordingId: string, options: { vocabularyHint?: string } = {}) => { + setIsTranscribing(true) + setError(null) + + try { + const res = await fetch('/api/transcribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recordingId, + language: 'ko', + vocabularyHint: options.vocabularyHint, + ...getProviderRequestPayload(), + }), + }) + + const data = await res.json().catch(() => ({})) + + if (!res.ok) { + setError(data.error ?? '전사에 실패했습니다.') + return null + } + + const outcome: TranscriptionOutcome = { + transcript: data.transcript ?? '', + segments: Array.isArray(data.segments) ? data.segments : [], + model: data.model ?? '', + hasTimestamps: Boolean(data.hasTimestamps), + } + setResult(outcome) + return outcome + } catch (err) { + setError( + err instanceof Error ? `전사 요청 실패: ${err.message}` : '전사 요청 실패', + ) + return null + } finally { + setIsTranscribing(false) + } + }, + [], + ) + + const reset = useCallback(() => { + setResult(null) + setError(null) + }, []) + + return { isTranscribing, error, result, transcribeRecording, reset } +} diff --git a/src/lib/api-key-storage.ts b/src/lib/api-key-storage.ts index 6b9b18c..65b6f79 100644 --- a/src/lib/api-key-storage.ts +++ b/src/lib/api-key-storage.ts @@ -48,6 +48,8 @@ export interface StoredProviderConfig { apiKey: string baseUrl: string model: string + /** 음성 전사 모델. 비우면 프리셋 기본값을 쓴다. */ + sttModel?: string } export interface ProviderRequestPayload { @@ -55,6 +57,7 @@ export interface ProviderRequestPayload { apiKey: string baseUrl: string model: string + sttModel: string } export function getStoredProviderConfig(): StoredProviderConfig | null { @@ -69,6 +72,7 @@ export function getStoredProviderConfig(): StoredProviderConfig | null { apiKey: typeof parsed.apiKey === 'string' ? parsed.apiKey : '', baseUrl: typeof parsed.baseUrl === 'string' ? parsed.baseUrl : '', model: typeof parsed.model === 'string' ? parsed.model : '', + sttModel: typeof parsed.sttModel === 'string' ? parsed.sttModel : '', } } catch { return null @@ -108,6 +112,7 @@ export function getProviderRequestPayload(): ProviderRequestPayload { apiKey: getStoredApiKey() ?? '', baseUrl: '', model: '', + sttModel: '', } } @@ -117,5 +122,6 @@ export function getProviderRequestPayload(): ProviderRequestPayload { apiKey: stored.apiKey || (preset.provider === 'gemini' ? getStoredApiKey() ?? '' : ''), baseUrl: stored.baseUrl, model: stored.model, + sttModel: stored.sttModel ?? '', } } diff --git a/src/lib/api-keys.ts b/src/lib/api-keys.ts index 7b38bb1..66ec18b 100644 --- a/src/lib/api-keys.ts +++ b/src/lib/api-keys.ts @@ -1,4 +1,9 @@ -import { isProviderId, type ProviderId, type ProviderSettings } from './providers' +import { + isProviderId, + type ProviderId, + type ProviderSettings, + type TranscriptionSettings, +} from './providers' /** * Gemini API 키 해석 우선순위: @@ -88,3 +93,38 @@ function str(value: unknown): string { function env(name: string): string { return (process.env[name] ?? '').trim() } + +export interface TranscriptionRequestBody extends ProviderRequestBody { + sttModel?: unknown +} + +/** + * 전사용 프로바이더 설정. + * + * 요약과 달리 대화 모델 이름이 없어도 된다. 전사는 `sttModel`(비우면 프로바이더 + * 기본값)로 별도 엔드포인트를 호출하므로, 대화 모델을 지정하지 않은 사용자도 + * 전사는 쓸 수 있어야 한다. + */ +export function resolveTranscriptionSettings( + body: TranscriptionRequestBody | null | undefined, +): TranscriptionSettings | null { + const provider = resolveProvider(body?.provider) + const sttModel = str(body?.sttModel) || env('LLM_STT_MODEL') + + if (provider === 'gemini') { + const apiKey = resolveGeminiApiKey(str(body?.apiKey) || env('LLM_API_KEY')) + if (!apiKey) return null + return { provider, apiKey, sttModel: sttModel || undefined } + } + + const baseUrl = str(body?.baseUrl) || env('LLM_BASE_URL') + if (baseUrl.length === 0) return null + + return { + provider, + apiKey: str(body?.apiKey) || env('LLM_API_KEY'), + baseUrl, + model: str(body?.model) || env('LLM_MODEL'), + sttModel: sttModel || undefined, + } +} diff --git a/src/lib/providers/gemini.ts b/src/lib/providers/gemini.ts index 216c05c..2d4cbf9 100644 --- a/src/lib/providers/gemini.ts +++ b/src/lib/providers/gemini.ts @@ -2,6 +2,11 @@ import type { CompletionOptions, CompletionResult, ProviderSettings, + TranscriptionInput, + TranscriptionOptions, + TranscriptionResult, + TranscriptionSegment, + TranscriptionSettings, } from './types' export const DEFAULT_GEMINI_MODEL = 'gemini-3.5-flash-lite' @@ -74,3 +79,187 @@ function describeHttpError(status: number): string { } return `Gemini API 호출 실패: ${status}` } + +/** + * Gemini 오디오 입력 기본 모델. + * flash-lite는 오디오를 받지 않으므로 요약용 기본값과 다르다. + */ +export const DEFAULT_GEMINI_STT_MODEL = 'gemini-3.6-flash' + +/** + * Gemini가 문서로 밝힌 오디오 형식. + * + * **webm은 여기에 없다.** 우리 브라우저 녹음이 webm/opus이므로 Gemini로는 + * 실시간 녹음을 전사할 수 없고, 업로드한 mp3/wav/flac/ogg/m4a만 된다. + * 녹음 전사는 Whisper 호환 엔드포인트(OrcaRouter·OpenAI·로컬)를 써야 한다. + */ +const GEMINI_AUDIO_MIME_TYPES = [ + 'audio/wav', + 'audio/x-wav', + 'audio/mp3', + 'audio/mpeg', + 'audio/aiff', + 'audio/aac', + 'audio/ogg', + 'audio/flac', + 'audio/mp4', + 'audio/x-m4a', +] + +export function isGeminiSupportedAudioMimeType(mimeType: string): boolean { + const base = mimeType.split(';')[0].trim().toLowerCase() + return GEMINI_AUDIO_MIME_TYPES.includes(base) +} + +/** + * inline_data로 보낼 수 있는 최대 오디오 크기. + * + * generateContent 요청 전체가 20MB를 넘으면 안 되는데 base64가 약 4/3배로 + * 부풀리므로, 원본 기준 14MB에서 끊는다. 더 큰 파일은 Files API가 필요하다. + */ +export const GEMINI_INLINE_AUDIO_LIMIT = 14 * 1024 * 1024 + +function buildTranscriptionPrompt(input: TranscriptionInput): string { + const lines = [ + '이 오디오는 회의 녹음입니다. 들리는 발화를 그대로 받아쓰세요.', + '', + '규칙:', + '- 요약하거나 문장을 다듬지 말고 말한 그대로 옮깁니다.', + '- 들리지 않는 구간은 지어내지 말고 건너뜁니다.', + '- 각 발화 앞에 `[MM:SS]` 형식으로 시작 시각을 붙입니다.', + '- 설명이나 머리말 없이 전사문만 출력합니다.', + ] + + if (input.vocabularyHint) { + lines.push( + '', + `이 회의에 나올 수 있는 고유명사·용어: ${input.vocabularyHint}`, + ) + } + + return lines.join('\n') +} + +/** `[MM:SS] 텍스트` 또는 `[HH:MM:SS] 텍스트` 줄을 구간으로 바꾼다. */ +export function parseTimestampedTranscript(text: string): TranscriptionSegment[] { + const segments: TranscriptionSegment[] = [] + const pattern = /^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\]\s*(.+)$/ + + for (const line of text.split('\n')) { + const match = pattern.exec(line.trim()) + if (!match) continue + + const [, h, m, s, body] = match + const start = (h ? Number(h) * 3600 : 0) + Number(m) * 60 + Number(s) + const content = body.trim() + if (content.length === 0) continue + + // 다음 구간이 시작될 때까지가 이 구간이다. 마지막은 뒤에서 채운다. + if (segments.length > 0) { + segments[segments.length - 1].end = start + } + segments.push({ start, end: start, text: content }) + } + + if (segments.length > 0) { + const last = segments[segments.length - 1] + if (last.end <= last.start) last.end = last.start + 1 + } + + return segments +} + +/** Gemini에 오디오를 inline으로 실어 전사한다. */ +export async function transcribeWithGemini( + input: TranscriptionInput, + settings: TranscriptionSettings, + options: TranscriptionOptions = {}, +): Promise { + const { fetchFn = fetch } = options + const apiKey = settings.apiKey.trim() + + if (apiKey.length === 0) { + return { success: false, error: 'Gemini API 키가 필요합니다.' } + } + + const baseMime = input.mimeType.split(';')[0].trim().toLowerCase() + + if (!isGeminiSupportedAudioMimeType(input.mimeType)) { + return { + success: false, + error: + `Gemini는 ${baseMime} 형식을 받지 않습니다. ` + + '브라우저 녹음(webm)을 전사하려면 설정에서 OpenAI 호환 프로바이더' + + '(OrcaRouter · OpenAI · 로컬 whisper)를 선택해주세요.', + } + } + + if (input.bytes.byteLength > GEMINI_INLINE_AUDIO_LIMIT) { + return { + success: false, + error: + `오디오가 너무 큽니다 (${Math.round(input.bytes.byteLength / 1024 / 1024)}MB). ` + + `Gemini 직접 호출은 ${Math.round(GEMINI_INLINE_AUDIO_LIMIT / 1024 / 1024)}MB까지만 가능합니다. ` + + 'OpenAI 호환 프로바이더를 쓰거나 오디오를 나눠주세요.', + } + } + + const model = settings.sttModel?.trim() || DEFAULT_GEMINI_STT_MODEL + const url = `${API_ROOT}/${encodeURIComponent(model)}:generateContent` + + try { + const response = await fetchFn(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': apiKey, + }, + body: JSON.stringify({ + contents: [ + { + parts: [ + { text: buildTranscriptionPrompt(input) }, + { + inline_data: { + mime_type: baseMime, + data: Buffer.from(input.bytes).toString('base64'), + }, + }, + ], + }, + ], + }), + }) + + if (!response.ok) { + if (response.status === 429) { + return { + success: false, + error: 'Gemini API 요청 한도 초과. 잠시 후 다시 시도해주세요.', + rateLimited: true, + } + } + return { success: false, error: describeHttpError(response.status) } + } + + const data = await response.json() + const text: string = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '' + + if (text.trim().length === 0) { + return { + success: false, + error: '전사 결과가 비어 있습니다. 오디오에 음성이 들어 있는지 확인해주세요.', + } + } + + return { + success: true, + text: text.trim(), + segments: parseTimestampedTranscript(text), + model, + } + } catch (err) { + const message = err instanceof Error ? err.message : '알 수 없는 오류' + return { success: false, error: `전사 호출 중 오류: ${message}` } + } +} diff --git a/src/lib/providers/index.ts b/src/lib/providers/index.ts index 926483c..8f71c1d 100644 --- a/src/lib/providers/index.ts +++ b/src/lib/providers/index.ts @@ -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 { + 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 +} diff --git a/src/lib/providers/openai-compatible.ts b/src/lib/providers/openai-compatible.ts index af2f984..57405d7 100644 --- a/src/lib/providers/openai-compatible.ts +++ b/src/lib/providers/openai-compatible.ts @@ -2,6 +2,11 @@ import type { CompletionOptions, CompletionResult, ProviderSettings, + TranscriptionInput, + TranscriptionOptions, + TranscriptionResult, + TranscriptionSegment, + TranscriptionSettings, } from './types' /** @@ -110,3 +115,142 @@ function describeHttpError(status: number): string { } return `API 호출 실패: ${status}` } + +/** Whisper 계열 기본 모델. OrcaRouter·OpenAI·로컬 whisper.cpp 모두 이 이름을 쓴다. */ +export const DEFAULT_OPENAI_STT_MODEL = 'whisper-1' + +/** + * Whisper가 받아주는 컨테이너. + * 우리 녹음(webm/opus)이 여기 포함되므로 별도 변환 없이 그대로 보낸다. + */ +const WHISPER_MIME_TYPES = [ + 'audio/webm', + 'audio/mp4', + 'audio/mpeg', + 'audio/mpga', + 'audio/m4a', + 'audio/x-m4a', + 'audio/wav', + 'audio/x-wav', + 'audio/ogg', + 'audio/flac', +] + +export function isWhisperSupportedMimeType(mimeType: string): boolean { + const base = mimeType.split(';')[0].trim().toLowerCase() + return WHISPER_MIME_TYPES.includes(base) +} + +/** + * OpenAI `/audio/transcriptions` 호환 엔드포인트로 전사. + * + * `verbose_json`을 먼저 요청한다. 구간별 실제 타임스탬프가 같이 오기 때문이다 — + * Web Speech가 주지 못하던 진짜 오디오 타임라인이며, 화자 분리를 얹으려면 + * 반드시 필요하다. 이 형식을 지원하지 않는 최신 모델(gpt-4o-transcribe 등)은 + * 400을 돌려주므로 그때는 `json`으로 한 번 더 시도한다. + */ +export async function transcribeWithOpenAICompatible( + input: TranscriptionInput, + settings: TranscriptionSettings, + options: TranscriptionOptions = {}, +): Promise { + const { fetchFn = fetch } = options + + const baseUrl = normalizeBaseUrl(settings.baseUrl ?? '') + if (!baseUrl) { + return { + success: false, + error: 'API 주소(base URL)가 올바르지 않습니다. http:// 또는 https:// 로 시작해야 합니다.', + } + } + + if (!isWhisperSupportedMimeType(input.mimeType)) { + return { + success: false, + error: `이 엔드포인트가 지원하지 않는 오디오 형식입니다 (${input.mimeType}).`, + } + } + + const model = settings.sttModel?.trim() || DEFAULT_OPENAI_STT_MODEL + + const headers: Record = {} + const apiKey = settings.apiKey.trim() + if (apiKey.length > 0) { + headers.Authorization = `Bearer ${apiKey}` + } + // Content-Type은 지정하지 않는다. FormData가 boundary까지 붙여 설정한다. + + async function send(responseFormat: 'verbose_json' | 'json') { + const form = new FormData() + form.append( + 'file', + new Blob([input.bytes as BlobPart], { type: input.mimeType }), + input.fileName, + ) + form.append('model', model) + form.append('response_format', responseFormat) + if (input.language) form.append('language', input.language) + if (input.vocabularyHint) form.append('prompt', input.vocabularyHint) + + return fetchFn(`${baseUrl}/audio/transcriptions`, { + method: 'POST', + headers, + body: form, + }) + } + + try { + let response = await send('verbose_json') + + // verbose_json 미지원 모델 → 타임스탬프를 포기하고 텍스트만 받는다. + if (response.status === 400) { + response = await send('json') + } + + if (!response.ok) { + if (response.status === 429) { + return { + success: false, + error: '전사 API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.', + rateLimited: true, + } + } + return { success: false, error: describeHttpError(response.status) } + } + + const data = await response.json() + const text: string = typeof data?.text === 'string' ? data.text : '' + + if (text.trim().length === 0) { + return { + success: false, + error: '전사 결과가 비어 있습니다. 오디오에 음성이 들어 있는지 확인해주세요.', + } + } + + return { success: true, text, segments: parseWhisperSegments(data), model } + } catch (err) { + const message = err instanceof Error ? err.message : '알 수 없는 오류' + return { success: false, error: `전사 호출 중 오류: ${message}` } + } +} + +function parseWhisperSegments(data: unknown): TranscriptionSegment[] { + const raw = (data as { segments?: unknown })?.segments + if (!Array.isArray(raw)) return [] + + const segments: TranscriptionSegment[] = [] + + for (const item of raw) { + const start = Number((item as { start?: unknown })?.start) + const end = Number((item as { end?: unknown })?.end) + const text = String((item as { text?: unknown })?.text ?? '').trim() + + if (!Number.isFinite(start) || !Number.isFinite(end)) continue + if (text.length === 0) continue + + segments.push({ start: Math.max(0, start), end: Math.max(start, end), text }) + } + + return segments +} diff --git a/src/lib/providers/presets.ts b/src/lib/providers/presets.ts index 19fa789..db31e17 100644 --- a/src/lib/providers/presets.ts +++ b/src/lib/providers/presets.ts @@ -1,4 +1,5 @@ -import { DEFAULT_GEMINI_MODEL } from './gemini' +import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_STT_MODEL } from './gemini' +import { DEFAULT_OPENAI_STT_MODEL } from './openai-compatible' import type { ProviderId } from './types' export interface ProviderPreset { @@ -8,6 +9,10 @@ export interface ProviderPreset { /** openai-compatible 프리셋의 기본 base URL. 사용자가 수정할 수 있다. */ baseUrl: string defaultModel: string + /** 음성 전사(STT)에 쓸 기본 모델. 요약용 모델과 다르다. */ + defaultSttModel: string + /** 이 프로바이더로 브라우저 녹음(webm)을 전사할 수 있는지. */ + canTranscribeWebm: boolean description: string apiKeyLabel: string apiKeyPlaceholder: string @@ -25,6 +30,9 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ provider: 'gemini', baseUrl: '', defaultModel: DEFAULT_GEMINI_MODEL, + defaultSttModel: DEFAULT_GEMINI_STT_MODEL, + // Gemini는 webm 오디오를 받지 않는다 — 업로드한 mp3/wav/flac만 전사 가능. + canTranscribeWebm: false, description: 'Google에 직접 호출합니다. 무료 티어가 있어 가장 간단합니다.', apiKeyLabel: 'Gemini API 키', apiKeyPlaceholder: 'AIzaSy...', @@ -39,7 +47,9 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ provider: 'openai-compatible', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o-mini', - description: 'OpenAI Chat Completions API를 사용합니다.', + defaultSttModel: DEFAULT_OPENAI_STT_MODEL, + canTranscribeWebm: true, + description: 'OpenAI Chat Completions + Whisper 전사를 사용합니다.', apiKeyLabel: 'OpenAI API 키', apiKeyPlaceholder: 'sk-...', docsUrl: 'https://platform.openai.com/api-keys', @@ -52,6 +62,8 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ provider: 'openai-compatible', baseUrl: 'https://api.orcarouter.ai/v1', defaultModel: 'google/gemini-3.5-flash-lite', + defaultSttModel: 'openai/whisper-1', + canTranscribeWebm: true, description: '하나의 키로 여러 제공사 모델을 사용합니다. 별도 가입과 크레딧 충전(또는 BYOK 등록)이 필요합니다.', apiKeyLabel: 'OrcaRouter API 키', @@ -67,6 +79,8 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ provider: 'openai-compatible', baseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3.1', + defaultSttModel: 'whisper-1', + canTranscribeWebm: true, description: 'Ollama · LM Studio · vLLM 등 내 PC에서 도는 모델. 회의 내용이 외부로 나가지 않습니다.', apiKeyLabel: 'API 키 (보통 불필요)', @@ -80,6 +94,8 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ provider: 'openai-compatible', baseUrl: '', defaultModel: '', + defaultSttModel: DEFAULT_OPENAI_STT_MODEL, + canTranscribeWebm: true, description: 'OpenAI 호환 엔드포인트라면 무엇이든 연결할 수 있습니다.', apiKeyLabel: 'API 키', apiKeyPlaceholder: 'sk-...', diff --git a/src/lib/providers/types.ts b/src/lib/providers/types.ts index a661ab3..2edaa9a 100644 --- a/src/lib/providers/types.ts +++ b/src/lib/providers/types.ts @@ -30,3 +30,53 @@ export type CompletionResult = export interface CompletionOptions { fetchFn?: typeof fetch } + +/* ------------------------------------------------------------------------- * + * 음성 전사 (STT) + * ------------------------------------------------------------------------- */ + +/** + * 전사할 오디오. + * + * 파일 전체를 메모리에 올린다. 회의 하나가 상한(수십 MB) 안에 들어오도록 + * 녹음 비트레이트를 낮춰 두었으므로 스트리밍까지 갈 필요는 없다. + */ +export interface TranscriptionInput { + bytes: Uint8Array + mimeType: string + fileName: string + /** BCP-47 앞부분. 예: 'ko'. 지정하면 인식 정확도가 눈에 띄게 오른다. */ + language?: string + /** + * 어휘 힌트. 참석자 이름·제품명·사내 용어를 넣으면 고유명사 오인식이 준다. + * Whisper는 이 문자열을 직전 문맥처럼 취급한다(약 224토큰까지). + */ + vocabularyHint?: string +} + +/** 전사 구간. Whisper `verbose_json`이 주는 실제 오디오 타임라인 기준. */ +export interface TranscriptionSegment { + /** 초 단위, 오디오 시작 기준. */ + start: number + end: number + text: string +} + +export type TranscriptionResult = + | { + success: true + text: string + /** 프로바이더가 타임스탬프를 주지 않으면 비어 있다. */ + segments: TranscriptionSegment[] + model: string + } + | { success: false; error: string; rateLimited?: boolean } + +export interface TranscriptionOptions { + fetchFn?: typeof fetch +} + +/** 전사에 쓸 모델은 대화용 모델과 다르므로 따로 받는다. */ +export interface TranscriptionSettings extends ProviderSettings { + sttModel?: string +} diff --git a/src/lib/recording.ts b/src/lib/recording.ts index 0d417b9..90cf2d6 100644 --- a/src/lib/recording.ts +++ b/src/lib/recording.ts @@ -40,8 +40,17 @@ export const RECORDING_AUDIO_CONSTRAINTS: MediaTrackConstraints = { */ export const CHUNK_INTERVAL_MS = 15_000 -/** 음성 전용이므로 128kbps면 STT에 충분하고도 남는다. */ -export const AUDIO_BITS_PER_SECOND = 128_000 +/** + * 녹음 비트레이트. + * + * 이 오디오는 감상용이 아니라 STT 입력이다. Opus는 음성 대역에서 32kbps만 + * 되어도 인식 정확도에 영향이 없고, 대신 파일이 작아야 전사 API에 통째로 + * 넣을 수 있다. 128kbps로 두면 46분 회의가 44MB가 되어 Whisper 상한(25MB)도, + * Gemini inline 상한도 넘긴다. + * + * 32kbps 기준 대략: 46분 → 11MB, 1시간 → 14MB, 1시간 40분 → 24MB + */ +export const AUDIO_BITS_PER_SECOND = 32_000 /** 회의 하나의 상한. 128kbps 기준 약 8.6시간. */ export const MAX_RECORDING_BYTES = 500 * 1024 * 1024 diff --git a/src/lib/transcript-formatter.ts b/src/lib/transcript-formatter.ts index 41c2791..18ba646 100644 --- a/src/lib/transcript-formatter.ts +++ b/src/lib/transcript-formatter.ts @@ -50,3 +50,32 @@ export function mergeAdjacentChunks( return result } + +/** + * 서버 STT가 준 구간을 전사문으로 옮긴다. + * + * 여기 붙는 타임스탬프는 실제 오디오 타임라인이다. Web Speech 경로가 쓰던 + * `결과 이벤트 시각 − 2초` 추정값과 달리 화자 분리·구간 재생에 그대로 쓸 수 있다. + */ +export function formatTranscriptionSegments( + segments: readonly { start: number; text: string }[], +): string { + return segments + .filter((segment) => segment.text.trim().length > 0) + .map((segment) => `${formatTimestamp(segment.start)} ${segment.text.trim()}`) + .join('\n') +} + +/** STT 구간을 기존 청크 구조로 변환한다 (화자 배정·병합 로직 재사용용). */ +export function segmentsToChunks( + segments: readonly { start: number; end: number; text: string }[], +): TranscriptChunk[] { + return segments + .filter((segment) => segment.text.trim().length > 0) + .map((segment) => ({ + text: segment.text.trim(), + startTime: Math.max(0, segment.start), + endTime: Math.max(segment.start, segment.end), + isFinal: true, + })) +}