mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dacf3e617f | ||
|
|
b115663ecf |
No files matched your search
@@ -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=
|
||||
@@ -58,8 +58,19 @@ docker compose up -d
|
||||
|
||||
> ⚠️ **Web Speech API는 발화를 상당량 놓칩니다.** 원거리·다인 대면 회의에서
|
||||
> 실측 포착률이 10% 안팎이었습니다(46분 회의 / 489어절). 회의 전사용으로
|
||||
> 설계된 엔진이 아니라 근접 음성 명령용입니다. 그래서 **오디오 원본을 반드시
|
||||
> 함께 남깁니다** — 놓친 발화는 나중에 서버 STT로 다시 살릴 수 있습니다.
|
||||
> 설계된 엔진이 아니라 근접 음성 명령용입니다. 그래서 이 패널은 **미리보기**이고,
|
||||
> 확정본은 종료 후 [서버 STT 재전사](#-서버-stt-재전사-정확도의-본선)로 만듭니다.
|
||||
|
||||
**유실 방지** — Chrome이 흘리는 것을 최대한 붙잡습니다.
|
||||
|
||||
- **미확정 발화 보존** — Chrome은 `continuous`여도 침묵마다 세션을 닫으면서 확정 전
|
||||
문장을 버립니다. 세션이 끝날 때마다 직접 확정시켜 살려냅니다
|
||||
- **마지막 발화 보존** — 중지 시점에 확정되지 않은 회의 끝머리도 함께 넘깁니다
|
||||
- **인식 실패 가시화** — Chrome이 "소리는 들었으나 못 알아듣겠다"고 답한 구간
|
||||
(`isFinal: true` + 빈 텍스트)은 요약 프롬프트를 오염시키지 않도록 청크에서 빼되,
|
||||
**`⚠️ 인식 실패 N건` 배지로 세어 보여줍니다.** 실측 46분 회의에서 19건이었습니다
|
||||
- **실제 타임스탬프** — 발화가 처음 들린 시각을 시작으로 잡고, 오디오 녹음과 같은
|
||||
시간축을 씁니다 (예전에는 `결과 이벤트 시각 − 2초` 하드코딩)
|
||||
|
||||
### 🎧 오디오 원본 보관
|
||||
|
||||
@@ -74,6 +85,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. 마이크 권한 허용
|
||||
@@ -82,6 +131,23 @@ docker compose up -d
|
||||
|
||||
---
|
||||
|
||||
### 📏 긴 회의 (map-reduce 요약)
|
||||
|
||||
전사문이 6만 자를 넘으면 한 번에 모델에 넣지 못합니다. 예전에는 10만 자에서
|
||||
**413으로 거부**해, 3시간짜리 회의를 마치고 회의록을 만들려는 순간 아무것도 받지
|
||||
못했습니다. 유실을 막자고 만든 파이프라인 끝에서 결과를 통째로 버리는 셈이었습니다.
|
||||
|
||||
지금은 구간별로 한 번 압축한 뒤(map), 압축본을 모아 **평소와 똑같은 템플릿 경로로**
|
||||
회의록을 만듭니다(reduce). 최종 출력이 짧은 회의와 같은 프롬프트를 타므로 서식이
|
||||
흔들리지 않습니다.
|
||||
|
||||
- 압축 창 4만 자 · 단일 통과 상한 6만 자 · 전체 상한 50만 자(약 14시간 분량)
|
||||
- **한 구간이라도 실패하면 전체를 실패로 돌립니다.** 일부만 빠진 압축본으로 회의록을
|
||||
만들면 사용자는 무엇이 빠졌는지 모른 채 멀쩡해 보이는 문서를 받게 됩니다
|
||||
- 압축을 거치면 회의록 상단에 몇 개 구간으로 나눴는지 안내합니다
|
||||
|
||||
---
|
||||
|
||||
### 🤖 AI 회의록 (6 템플릿 × 3 강도)
|
||||
|
||||
회의 외에도 다양한 용도에 맞춰 출력 구조를 선택할 수 있습니다.
|
||||
@@ -503,7 +569,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인스턴스로 운용 |
|
||||
|
||||
+21
@@ -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`에서 고른 프로바이더에 따라 **회의 전문이 지나가는 회사가 달라집니다.**
|
||||
|
||||
+23
-8
@@ -186,14 +186,29 @@ 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 포착률 실측 비교
|
||||
- [ ] **빈 청크 필터링** — 빈 발화 19개가 요약 프롬프트를 오염시키고 있음
|
||||
- [ ] **타임스탬프 실측화** — `useSpeechRecognition.ts`의 `startTime: now - 2` 하드코딩 제거
|
||||
- [ ] **10만 자 하드 실패 → 분할 요약** — 긴 회의가 마지막에 통째로 실패함
|
||||
- [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를 "실시간 미리보기"로 명시적 강등 (현재는 둘 다 노출)
|
||||
- [x] **Web Speech 유실 지점 정리**
|
||||
- [x] 빈 확정 결과를 청크에서 빼고 "인식 실패 N건"으로 노출 (실측 19건)
|
||||
- [x] 세션 재시작 시 미확정 발화 보존 — Chrome이 버리던 것을 직접 확정
|
||||
- [x] 중지 시 마지막 발화 보존 — `stopListening()`이 최종 청크를 돌려줌
|
||||
- [x] 실시간 텍스트 패널을 "미리보기"로 강등
|
||||
- [x] **타임스탬프 실측화** — `startTime: now - 2` 하드코딩 제거
|
||||
- [x] interim이 처음 뜬 시각을 발화 시작으로 사용
|
||||
- [x] `startRecording()`이 캡처 시작 시각을 돌려주고 인식기가 같은 시간축 사용
|
||||
- [x] **10만 자 하드 실패 → 분할 요약**
|
||||
- [x] 구간 압축(map) → 기존 템플릿 경로로 회의록(reduce)
|
||||
- [x] 상한 10만 → 50만 자 (약 14시간 분량)
|
||||
- [x] 한 구간이라도 실패하면 전체 실패 — 조용히 빠진 회의록을 만들지 않음
|
||||
|
||||
## 🎯 P1 — 입력단 개선
|
||||
|
||||
|
||||
@@ -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,181 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
CONDENSE_WINDOW_CHARS,
|
||||
MAX_TRANSCRIPT_CHARS,
|
||||
SINGLE_PASS_CHAR_LIMIT,
|
||||
condenseTranscript,
|
||||
planTranscriptWindows,
|
||||
} from '@/lib/long-transcript'
|
||||
|
||||
function okResponse(text: string) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
candidates: [{ content: { parts: [{ text }] } }],
|
||||
}),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
const settings = { provider: 'gemini' as const, apiKey: 'AIza-test' }
|
||||
|
||||
describe('planTranscriptWindows', () => {
|
||||
it('상한 안이면 통째로 하나', () => {
|
||||
expect(planTranscriptWindows('짧은 전사문', 100)).toEqual(['짧은 전사문'])
|
||||
})
|
||||
|
||||
it('빈 입력은 구간이 없다', () => {
|
||||
expect(planTranscriptWindows(' ', 100)).toEqual([])
|
||||
})
|
||||
|
||||
it('줄 경계에서 나눈다', () => {
|
||||
const lines = ['[00:00] 가나다', '[00:05] 라마바', '[00:10] 사아자']
|
||||
const windows = planTranscriptWindows(lines.join('\n'), 25)
|
||||
|
||||
expect(windows.length).toBeGreaterThan(1)
|
||||
for (const w of windows) {
|
||||
// 줄 중간에서 끊기지 않았다
|
||||
for (const line of w.split('\n')) {
|
||||
expect(lines).toContain(line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('어떤 내용도 잃지 않는다', () => {
|
||||
const lines = Array.from({ length: 500 }, (_, i) => `[00:${i % 60}] 발화 ${i}`)
|
||||
const source = lines.join('\n')
|
||||
|
||||
const windows = planTranscriptWindows(source, 200)
|
||||
|
||||
expect(windows.join('\n')).toBe(source)
|
||||
})
|
||||
|
||||
it('상한을 넘는 구간은 없다', () => {
|
||||
const source = Array.from({ length: 300 }, (_, i) => `줄 ${i}`).join('\n')
|
||||
for (const w of planTranscriptWindows(source, 50)) {
|
||||
expect(w.length).toBeLessThanOrEqual(50)
|
||||
}
|
||||
})
|
||||
|
||||
it('한 줄이 통째로 상한보다 길면 그 줄만 강제로 자른다', () => {
|
||||
const long = 'ㄱ'.repeat(250)
|
||||
const windows = planTranscriptWindows(long, 100)
|
||||
|
||||
expect(windows).toHaveLength(3)
|
||||
expect(windows.join('')).toBe(long)
|
||||
})
|
||||
|
||||
it('maxChars가 0 이하면 거부한다', () => {
|
||||
expect(() => planTranscriptWindows('가', 0)).toThrow()
|
||||
})
|
||||
|
||||
it('압축 창은 단일 통과 상한보다 작다', () => {
|
||||
// 압축한 결과를 다시 한 번에 넣을 수 있어야 map-reduce가 성립한다.
|
||||
expect(CONDENSE_WINDOW_CHARS).toBeLessThan(SINGLE_PASS_CHAR_LIMIT)
|
||||
expect(SINGLE_PASS_CHAR_LIMIT).toBeLessThan(MAX_TRANSCRIPT_CHARS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('condenseTranscript', () => {
|
||||
it('구간마다 한 번씩 호출하고 결과를 이어 붙인다', async () => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okResponse('앞부분 정리'))
|
||||
.mockResolvedValueOnce(okResponse('뒷부분 정리'))
|
||||
|
||||
const source = Array.from({ length: 40 }, (_, i) => `줄 ${i}`).join('\n')
|
||||
const result = await condenseTranscript(source, {
|
||||
provider: settings,
|
||||
windowChars: 120,
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2)
|
||||
expect(result.windows).toBe(2)
|
||||
expect(result.text).toContain('앞부분 정리')
|
||||
expect(result.text).toContain('뒷부분 정리')
|
||||
expect(result.text).toContain('구간 1/2')
|
||||
})
|
||||
|
||||
it('프롬프트에 구간 위치를 알려 앞뒤를 지어내지 않게 한다', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(okResponse('정리'))
|
||||
const source = Array.from({ length: 40 }, (_, i) => `줄 ${i}`).join('\n')
|
||||
|
||||
await condenseTranscript(source, {
|
||||
provider: settings,
|
||||
windowChars: 120,
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchFn.mock.calls[0][1] as RequestInit).body as string,
|
||||
)
|
||||
const prompt = body.contents[0].parts[0].text
|
||||
expect(prompt).toContain('1/2 구간')
|
||||
expect(prompt).toContain('추측해 채우지 않습니다')
|
||||
})
|
||||
|
||||
it('한 구간이라도 실패하면 전체를 실패로 돌린다', async () => {
|
||||
// 일부만 빠진 압축본으로 회의록을 만들면 사용자는 무엇이 빠졌는지 모른다.
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okResponse('앞부분'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response)
|
||||
|
||||
const source = Array.from({ length: 40 }, (_, i) => `줄 ${i}`).join('\n')
|
||||
const result = await condenseTranscript(source, {
|
||||
provider: settings,
|
||||
windowChars: 120,
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (result.success) return
|
||||
expect(result.error).toContain('2/2 구간')
|
||||
})
|
||||
|
||||
it('빈 응답도 실패로 본다', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(okResponse(' '))
|
||||
const result = await condenseTranscript('짧은 글', {
|
||||
provider: settings,
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('429는 rateLimited로 올려보낸다', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response)
|
||||
|
||||
const result = await condenseTranscript('짧은 글', {
|
||||
provider: settings,
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (result.success) return
|
||||
expect(result.rateLimited).toBe(true)
|
||||
})
|
||||
|
||||
it('빈 전사문은 호출조차 하지 않는다', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
const result = await condenseTranscript(' ', {
|
||||
provider: settings,
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -99,6 +99,45 @@ describe('useAudioRecorder — 캡처 제약', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAudioRecorder — 시간축', () => {
|
||||
it('캡처 시작 시각을 돌려준다', async () => {
|
||||
// 이 값을 인식기에 넘겨야 전사 타임스탬프가 오디오 파일과 같은 축을 쓴다.
|
||||
const before = Date.now()
|
||||
const view = renderHook(() => useAudioRecorder())
|
||||
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
|
||||
|
||||
let startedAt: number | null = null
|
||||
await act(async () => {
|
||||
startedAt = await view.result.current.startRecording()
|
||||
})
|
||||
|
||||
expect(startedAt).not.toBeNull()
|
||||
expect(startedAt!).toBeGreaterThanOrEqual(before)
|
||||
expect(startedAt!).toBeLessThanOrEqual(Date.now())
|
||||
})
|
||||
|
||||
it('마이크를 못 열면 null', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(async () => {
|
||||
throw Object.assign(new Error('denied'), { name: 'NotAllowedError' })
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const view = renderHook(() => useAudioRecorder())
|
||||
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
|
||||
|
||||
let startedAt: number | null = 0
|
||||
await act(async () => {
|
||||
startedAt = await view.result.current.startRecording()
|
||||
})
|
||||
|
||||
expect(startedAt).toBeNull()
|
||||
expect(view.result.current.error).toContain('마이크 권한')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAudioRecorder — 조각 업로드', () => {
|
||||
it('조각이 생길 때마다 서버로 올린다', async () => {
|
||||
const view = await startedHook()
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||
|
||||
/**
|
||||
* jsdom에는 SpeechRecognition이 없다. 여기서 검증하려는 것은 인식 품질이 아니라
|
||||
* **Chrome이 준 것을 우리가 흘리지 않는가**이므로, 이벤트를 직접 쏠 수 있는
|
||||
* 최소 구현이면 충분하다.
|
||||
*/
|
||||
|
||||
interface FakeResult {
|
||||
isFinal: boolean
|
||||
0: { transcript: string }
|
||||
}
|
||||
|
||||
class FakeSpeechRecognition {
|
||||
lang = ''
|
||||
continuous = false
|
||||
interimResults = false
|
||||
|
||||
onresult: ((event: { resultIndex: number; results: FakeResult[] }) => void) | null = null
|
||||
onend: (() => void) | null = null
|
||||
onerror: ((event: { error: string }) => void) | null = null
|
||||
|
||||
started = 0
|
||||
|
||||
constructor() {
|
||||
instances.push(this)
|
||||
}
|
||||
|
||||
start() {
|
||||
this.started += 1
|
||||
}
|
||||
|
||||
stop() {
|
||||
// 실제 Chrome은 stop() 뒤에도 onend를 쏘지만, 훅이 ref를 먼저 끊어
|
||||
// 재시작을 막는다. 그 동작을 그대로 흉내 낸다.
|
||||
this.onend?.()
|
||||
}
|
||||
|
||||
/** Chrome이 결과를 내보내는 것을 흉내 낸다. */
|
||||
emit(...results: Array<{ text: string; final: boolean }>) {
|
||||
this.onresult?.({
|
||||
resultIndex: 0,
|
||||
results: results.map((r) => ({
|
||||
isFinal: r.final,
|
||||
0: { transcript: r.text },
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let instances: FakeSpeechRecognition[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
instances = []
|
||||
vi.stubGlobal('SpeechRecognition', FakeSpeechRecognition)
|
||||
vi.stubGlobal('webkitSpeechRecognition', FakeSpeechRecognition)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function listening(timeOrigin?: number) {
|
||||
const view = renderHook(() => useSpeechRecognition())
|
||||
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
|
||||
act(() => view.result.current.startListening(timeOrigin))
|
||||
return view
|
||||
}
|
||||
|
||||
describe('확정된 발화', () => {
|
||||
it('청크로 쌓인다', async () => {
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '안녕하세요', final: true }))
|
||||
|
||||
expect(view.result.current.chunks).toHaveLength(1)
|
||||
expect(view.result.current.chunks[0].text).toBe('안녕하세요')
|
||||
expect(view.result.current.chunks[0].isFinal).toBe(true)
|
||||
})
|
||||
|
||||
it('빈 확정 결과는 청크로 넣지 않고 유실로 센다', async () => {
|
||||
// 실측 46분 회의에서 19번 나온 케이스. Chrome이 "소리는 들었는데
|
||||
// 못 알아듣겠다"고 답한 것으로, 청크로 넣으면 요약 프롬프트가 오염된다.
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: ' ', final: true }))
|
||||
act(() => instances[0].emit({ text: '', final: true }))
|
||||
|
||||
expect(view.result.current.chunks).toHaveLength(0)
|
||||
expect(view.result.current.missedCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('미확정 발화 보존', () => {
|
||||
it('세션이 끊길 때 확정 전 발화를 살려낸다', async () => {
|
||||
// Chrome은 continuous여도 침묵마다 세션을 닫고, 그때 확정 전 문장을 버린다.
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '아직 확정 안 된 말', final: false }))
|
||||
expect(view.result.current.interimText).toBe('아직 확정 안 된 말')
|
||||
expect(view.result.current.chunks).toHaveLength(0)
|
||||
|
||||
act(() => instances[0].onend?.())
|
||||
|
||||
expect(view.result.current.chunks).toHaveLength(1)
|
||||
expect(view.result.current.chunks[0].text).toBe('아직 확정 안 된 말')
|
||||
// 우리가 살려낸 값이지 Chrome이 확정해 준 값이 아니다.
|
||||
expect(view.result.current.chunks[0].isFinal).toBe(false)
|
||||
expect(view.result.current.interimText).toBe('')
|
||||
})
|
||||
|
||||
it('중지할 때도 마지막 발화를 살려낸다', async () => {
|
||||
// 회의 끝머리가 통째로 사라지던 자리.
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '수고하셨습니다', final: false }))
|
||||
|
||||
let finalChunks: ReturnType<typeof view.result.current.stopListening> = []
|
||||
act(() => {
|
||||
finalChunks = view.result.current.stopListening()
|
||||
})
|
||||
|
||||
expect(finalChunks).toHaveLength(1)
|
||||
expect(finalChunks[0].text).toBe('수고하셨습니다')
|
||||
})
|
||||
|
||||
it('stopListening이 렌더 시점이 아닌 최신 청크를 돌려준다', async () => {
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '첫 발화', final: true }))
|
||||
act(() => instances[0].emit({ text: '끝 발화', final: false }))
|
||||
|
||||
let finalChunks: ReturnType<typeof view.result.current.stopListening> = []
|
||||
act(() => {
|
||||
finalChunks = view.result.current.stopListening()
|
||||
})
|
||||
|
||||
expect(finalChunks.map((c) => c.text)).toEqual(['첫 발화', '끝 발화'])
|
||||
})
|
||||
|
||||
it('살려낼 미확정 발화가 없으면 아무것도 추가하지 않는다', async () => {
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '확정된 말', final: true }))
|
||||
act(() => instances[0].onend?.())
|
||||
|
||||
expect(view.result.current.chunks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('재시작이 반복돼도 확정 전 발화가 매번 보존된다', async () => {
|
||||
const view = await listening()
|
||||
const recognition = instances[0]
|
||||
|
||||
// flushInterim은 재시작 타이머보다 먼저, 동기로 돈다.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
act(() => recognition.emit({ text: `구간 ${i}`, final: false }))
|
||||
act(() => recognition.onend?.())
|
||||
}
|
||||
|
||||
expect(view.result.current.chunks.map((c) => c.text)).toEqual([
|
||||
'구간 0',
|
||||
'구간 1',
|
||||
'구간 2',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('타임스탬프', () => {
|
||||
it('발화가 처음 들린 시각을 시작으로 잡는다', async () => {
|
||||
const origin = Date.now()
|
||||
const view = await listening(origin)
|
||||
|
||||
const nowSpy = vi.spyOn(Date, 'now')
|
||||
|
||||
nowSpy.mockReturnValue(origin + 5_000)
|
||||
act(() => instances[0].emit({ text: '말하는 중', final: false }))
|
||||
|
||||
nowSpy.mockReturnValue(origin + 12_000)
|
||||
act(() => instances[0].emit({ text: '말하는 중입니다', final: true }))
|
||||
|
||||
const chunk = view.result.current.chunks[0]
|
||||
// 하드코딩된 `끝 − 2초`(10초)가 아니라 실제로 들리기 시작한 5초여야 한다.
|
||||
expect(chunk.startTime).toBeCloseTo(5, 1)
|
||||
expect(chunk.endTime).toBeCloseTo(12, 1)
|
||||
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('timeOrigin을 주면 그 기준으로 시각을 잰다', async () => {
|
||||
// 오디오 녹음 시작 시각을 넘기면 전사 타임스탬프가 파일 재생 위치와 맞는다.
|
||||
const origin = Date.now() - 30_000
|
||||
const view = await listening(origin)
|
||||
|
||||
act(() => instances[0].emit({ text: '발화', final: true }))
|
||||
|
||||
expect(view.result.current.chunks[0].endTime).toBeGreaterThan(29)
|
||||
})
|
||||
|
||||
it('interim 없이 확정만 오면 기본 길이로 되돌아간다', async () => {
|
||||
const origin = Date.now()
|
||||
const view = await listening(origin)
|
||||
|
||||
const nowSpy = vi.spyOn(Date, 'now')
|
||||
nowSpy.mockReturnValue(origin + 8_000)
|
||||
act(() => instances[0].emit({ text: '갑자기 확정', final: true }))
|
||||
|
||||
const chunk = view.result.current.chunks[0]
|
||||
expect(chunk.endTime).toBeCloseTo(8, 1)
|
||||
expect(chunk.startTime).toBeCloseTo(6, 1)
|
||||
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetChunks', () => {
|
||||
it('청크와 유실 카운트를 함께 지운다', async () => {
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '발화', final: true }))
|
||||
act(() => instances[0].emit({ text: '', final: true }))
|
||||
act(() => view.result.current.resetChunks())
|
||||
|
||||
expect(view.result.current.chunks).toHaveLength(0)
|
||||
expect(view.result.current.missedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('초기화 후 stopListening은 빈 배열', async () => {
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '발화', final: true }))
|
||||
act(() => view.result.current.resetChunks())
|
||||
|
||||
let finalChunks: ReturnType<typeof view.result.current.stopListening> = []
|
||||
act(() => {
|
||||
finalChunks = view.result.current.stopListening()
|
||||
})
|
||||
expect(finalChunks).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,13 @@ import {
|
||||
generateSimpleMinutes,
|
||||
} from '@/lib/minutes-generator'
|
||||
import { resolveProviderSettings } from '@/lib/api-keys'
|
||||
import {
|
||||
MAX_TRANSCRIPT_CHARS,
|
||||
SINGLE_PASS_CHAR_LIMIT,
|
||||
condenseTranscript,
|
||||
} from '@/lib/long-transcript'
|
||||
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||
|
||||
const MAX_TRANSCRIPT_CHARS = 100_000
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -52,6 +55,30 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// 한 번에 넣기엔 긴 회의는 구간별로 압축한 뒤 평소 경로로 회의록을 만든다.
|
||||
// 예전에는 여기서 413으로 거부해, 3시간 회의가 마지막에 통째로 실패했다.
|
||||
let condensedNotice: string | undefined
|
||||
|
||||
if (transcript.length > SINGLE_PASS_CHAR_LIMIT) {
|
||||
const condensed = await condenseTranscript(transcript, {
|
||||
provider: settings,
|
||||
})
|
||||
|
||||
if (!condensed.success) {
|
||||
const fallback = generateSimpleMinutes(input)
|
||||
return Response.json({
|
||||
markdown: fallback,
|
||||
mode: 'simple',
|
||||
warning: `긴 회의 정리에 실패하여 단순 변환으로 대체되었습니다: ${condensed.error}`,
|
||||
})
|
||||
}
|
||||
|
||||
input.transcript = condensed.text
|
||||
condensedNotice =
|
||||
`긴 회의(${transcript.length.toLocaleString()}자)라 ${condensed.windows}개 구간으로 ` +
|
||||
'나눠 정리한 뒤 회의록을 작성했습니다.'
|
||||
}
|
||||
|
||||
const result = await generateAiMinutes(input, { provider: settings })
|
||||
|
||||
if (!result.success) {
|
||||
@@ -63,7 +90,11 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
return Response.json({ markdown: result.markdown, mode: 'gemini' })
|
||||
return Response.json({
|
||||
markdown: result.markdown,
|
||||
mode: 'gemini',
|
||||
...(condensedNotice ? { warning: condensedNotice } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const markdown = generateSimpleMinutes(input)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
+22
-10
@@ -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 : '알 수 없는 오류'
|
||||
|
||||
+72
-4
@@ -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<string | null>(null)
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
||||
const [audio, setAudio] = useState<CompletedRecording | null>(null)
|
||||
const [uploadedRecordingId, setUploadedRecordingId] = useState<string | null>(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() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<label className="block text-sm font-medium text-neutral-600 mb-2">
|
||||
참석자 · 용어 <span className="font-normal text-neutral-400">(선택)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={attendees}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-neutral-500">
|
||||
이름·제품명·사내 용어를 적어두면 전사가 고유명사를 훨씬 정확히 잡습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8 space-y-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium text-neutral-600 min-w-20">
|
||||
@@ -345,7 +384,9 @@ export default function HomePage() {
|
||||
<LiveRecorder
|
||||
onTranscriptReady={handleTranscriptReady}
|
||||
onRecordingReady={handleRecordingReady}
|
||||
onTranscriptReplaced={setTranscript}
|
||||
title={title}
|
||||
vocabularyHint={attendees}
|
||||
liveSummaryEnabled={liveSummaryActive}
|
||||
template={template}
|
||||
depth={depth}
|
||||
@@ -370,12 +411,39 @@ export default function HomePage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{uploadedFile && !transcript && (
|
||||
<section className="mb-8">
|
||||
{uploadedFile && (
|
||||
<section className="mb-8 space-y-3">
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-700">
|
||||
<strong>{uploadedFile.name}</strong> 업로드 완료.
|
||||
실시간 녹음 탭에서 음성 인식을 시작하거나, 텍스트를 직접 입력해주세요.
|
||||
<strong>{uploadedFile.name}</strong> 업로드 완료
|
||||
{uploadedRecordingId
|
||||
? ' — 아래 버튼으로 전사를 시작하세요.'
|
||||
: ' — 서버 저장에 실패해 전사할 수 없습니다.'}
|
||||
</div>
|
||||
|
||||
{uploadedRecordingId && (
|
||||
<button
|
||||
onClick={transcribeUploadedFile}
|
||||
disabled={isTranscribingUpload}
|
||||
className="w-full rounded-xl bg-neutral-900 px-6 py-3 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isTranscribingUpload
|
||||
? '전사 중… 회의 길이에 따라 몇 분 걸릴 수 있습니다'
|
||||
: '🔤 전사 시작'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{uploadTranscribeError && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<strong>전사 오류:</strong> {uploadTranscribeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadTranscription && (
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||
🔤 전사 완료 — {uploadTranscription.model} 모델,{' '}
|
||||
{uploadTranscription.segments.length}개 구간
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function SettingsPage() {
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
const [sttModel, setSttModel] = useState('')
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [savedKey, setSavedKey] = useState<string | null>(null)
|
||||
const [envConfigured, setEnvConfigured] = useState<boolean | null>(null)
|
||||
@@ -44,12 +45,15 @@ export default function SettingsPage() {
|
||||
setApiKey(stored.apiKey)
|
||||
setBaseUrl(stored.baseUrl)
|
||||
setModel(stored.model)
|
||||
setSttModel(stored.sttModel ?? '')
|
||||
setSavedKey(stored.apiKey || null)
|
||||
} else {
|
||||
// 프로바이더 설정 이전에 저장해둔 Gemini 키를 그대로 이어받는다.
|
||||
const legacyKey = getStoredApiKey()
|
||||
setApiKey(legacyKey ?? '')
|
||||
setModel(findPreset(DEFAULT_PRESET_ID).defaultModel)
|
||||
const initial = findPreset(DEFAULT_PRESET_ID)
|
||||
setModel(initial.defaultModel)
|
||||
setSttModel(initial.defaultSttModel)
|
||||
setSavedKey(legacyKey)
|
||||
}
|
||||
|
||||
@@ -66,6 +70,7 @@ export default function SettingsPage() {
|
||||
setPresetId(next.id)
|
||||
setBaseUrl(next.baseUrl)
|
||||
setModel(next.defaultModel)
|
||||
setSttModel(next.defaultSttModel)
|
||||
// 프로바이더가 바뀌면 이전 키는 무의미할 뿐 아니라, 그대로 두면
|
||||
// 다른 회사 엔드포인트로 전송될 수 있으므로 비운다.
|
||||
setApiKey('')
|
||||
@@ -78,6 +83,7 @@ export default function SettingsPage() {
|
||||
apiKey: apiKey.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
model: model.trim(),
|
||||
sttModel: sttModel.trim(),
|
||||
})
|
||||
setSavedKey(apiKey.trim() || null)
|
||||
setSavedToast(true)
|
||||
@@ -99,6 +105,7 @@ export default function SettingsPage() {
|
||||
setApiKey('')
|
||||
setBaseUrl(fallback.baseUrl)
|
||||
setModel(fallback.defaultModel)
|
||||
setSttModel(fallback.defaultSttModel)
|
||||
setSavedKey(null)
|
||||
setTestState({ status: 'idle' })
|
||||
}
|
||||
@@ -286,6 +293,29 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-2 block text-sm text-neutral-600">
|
||||
음성 전사(STT) 모델
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sttModel}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
녹음 오디오를 텍스트로 옮길 때 쓰는 모델입니다. 요약 모델과 별개입니다.
|
||||
</p>
|
||||
{!preset.canTranscribeWebm && (
|
||||
<p className="mt-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
⚠️ 이 프로바이더는 브라우저 녹음 형식(webm)을 받지 않습니다. 실시간
|
||||
녹음을 전사하려면 OpenAI 호환 프로바이더(OrcaRouter · OpenAI ·
|
||||
로컬 whisper)를 선택하세요. 업로드한 mp3·wav·flac 파일은 전사됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-2 block text-sm text-neutral-600">
|
||||
{preset.apiKeyLabel}
|
||||
|
||||
@@ -5,13 +5,18 @@ import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||
import { useLiveSummary } from '@/hooks/useLiveSummary'
|
||||
import { useAudioRecorder, type CompletedRecording } from '@/hooks/useAudioRecorder'
|
||||
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
||||
import { useTranscription } from '@/hooks/useTranscription'
|
||||
import { formatBytes, formatDuration } from '@/lib/recording'
|
||||
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||
|
||||
interface LiveRecorderProps {
|
||||
onTranscriptReady: (transcript: string) => void
|
||||
onRecordingReady?: (recording: CompletedRecording) => void
|
||||
/** STT 재전사 결과로 전사문을 갈아끼운다. */
|
||||
onTranscriptReplaced?: (transcript: string) => void
|
||||
title: string
|
||||
/** 참석자 등 고유명사 힌트. STT 정확도를 올린다. */
|
||||
vocabularyHint?: string
|
||||
liveSummaryEnabled: boolean
|
||||
template: TemplateId
|
||||
depth: SummaryDepth
|
||||
@@ -21,7 +26,9 @@ interface LiveRecorderProps {
|
||||
export function LiveRecorder({
|
||||
onTranscriptReady,
|
||||
onRecordingReady,
|
||||
onTranscriptReplaced,
|
||||
title,
|
||||
vocabularyHint,
|
||||
liveSummaryEnabled,
|
||||
template,
|
||||
depth,
|
||||
@@ -33,6 +40,7 @@ export function LiveRecorder({
|
||||
chunks,
|
||||
interimText,
|
||||
error: recognitionError,
|
||||
missedCount,
|
||||
startListening,
|
||||
stopListening,
|
||||
resetChunks,
|
||||
@@ -53,6 +61,14 @@ export function LiveRecorder({
|
||||
reset: resetRecording,
|
||||
} = useAudioRecorder()
|
||||
|
||||
const {
|
||||
isTranscribing,
|
||||
error: transcriptionError,
|
||||
result: transcription,
|
||||
transcribeRecording,
|
||||
reset: resetTranscription,
|
||||
} = useTranscription()
|
||||
|
||||
const {
|
||||
summary,
|
||||
isSummarizing,
|
||||
@@ -106,15 +122,20 @@ export function LiveRecorder({
|
||||
|
||||
async function handleStart() {
|
||||
resetRecording()
|
||||
resetTranscription()
|
||||
// 오디오 녹음을 먼저 건다. 전사가 실패하더라도 원본은 남아야 한다.
|
||||
await startRecording()
|
||||
startListening()
|
||||
// 녹음 시작 시각을 인식기에 넘겨, 전사 타임스탬프가 오디오 파일의
|
||||
// 재생 위치와 같은 시간축을 쓰게 한다.
|
||||
const audioStartedAt = await startRecording()
|
||||
startListening(audioStartedAt ?? undefined)
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
stopListening()
|
||||
// 훅이 미확정 발화까지 굳혀서 돌려준다. 렌더 시점의 `chunks`를 쓰면
|
||||
// 회의 마지막 문장이 빠진다.
|
||||
const finalChunks = stopListening()
|
||||
|
||||
const transcript = formatTranscriptChunks(chunks)
|
||||
const transcript = formatTranscriptChunks(finalChunks)
|
||||
if (transcript.length > 0) {
|
||||
onTranscriptReady(transcript)
|
||||
}
|
||||
@@ -180,6 +201,21 @@ export function LiveRecorder({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{recording?.recordingId && !isListening && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const outcome = await transcribeRecording(recording.recordingId!, {
|
||||
vocabularyHint,
|
||||
})
|
||||
if (outcome?.transcript) onTranscriptReplaced?.(outcome.transcript)
|
||||
}}
|
||||
disabled={isTranscribing}
|
||||
className="flex items-center gap-2 rounded-full bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isTranscribing ? '전사 중… (몇 분 걸릴 수 있습니다)' : '🔤 원본 오디오로 다시 전사'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{recording && !isListening && (
|
||||
<button
|
||||
onClick={() => downloadRecording(title)}
|
||||
@@ -194,6 +230,7 @@ export function LiveRecorder({
|
||||
onClick={() => {
|
||||
resetChunks()
|
||||
resetRecording()
|
||||
resetTranscription()
|
||||
}}
|
||||
className="rounded-full border border-neutral-300 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
@@ -208,6 +245,15 @@ export function LiveRecorder({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missedCount > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded-full bg-amber-50 px-4 py-1.5 text-xs text-amber-700"
|
||||
title="Chrome이 소리는 감지했지만 무슨 말인지 인식하지 못한 구간입니다."
|
||||
>
|
||||
⚠️ 인식 실패 {missedCount}건
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-1.5 text-xs text-neutral-600">
|
||||
<span className="text-sm">🎧</span>
|
||||
@@ -246,6 +292,22 @@ export function LiveRecorder({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcriptionError && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<strong>전사 오류:</strong> {transcriptionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcription && (
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||
<strong>🔤 재전사 완료</strong> — {transcription.model} 모델,{' '}
|
||||
{transcription.segments.length > 0
|
||||
? `${transcription.segments.length}개 구간 (실제 오디오 타임스탬프)`
|
||||
: '타임스탬프 없음'}
|
||||
. 아래 텍스트가 교체되었습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recording && !isListening && (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
|
||||
<strong>
|
||||
@@ -289,7 +351,7 @@ export function LiveRecorder({
|
||||
}`}
|
||||
/>
|
||||
<h3 className="text-xs font-semibold text-neutral-600 uppercase tracking-wider">
|
||||
실시간 텍스트
|
||||
실시간 텍스트 <span className="normal-case font-normal text-neutral-400">(미리보기)</span>
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500">
|
||||
|
||||
@@ -31,7 +31,8 @@ export interface AudioRecorderHook {
|
||||
localBytes: number
|
||||
uploadedBytes: number
|
||||
recording: CompletedRecording | null
|
||||
startRecording: () => Promise<void>
|
||||
/** 캡처가 시작된 시각(epoch ms). 실패하면 null. */
|
||||
startRecording: () => Promise<number | null>
|
||||
stopRecording: () => Promise<void>
|
||||
downloadRecording: (title: string) => void
|
||||
reset: () => void
|
||||
@@ -164,8 +165,8 @@ export function useAudioRecorder(): AudioRecorderHook {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (recorderRef.current) return
|
||||
const startRecording = useCallback(async (): Promise<number | null> => {
|
||||
if (recorderRef.current) return null
|
||||
|
||||
setError(null)
|
||||
setUploadWarning(null)
|
||||
@@ -180,7 +181,7 @@ export function useAudioRecorder(): AudioRecorderHook {
|
||||
|
||||
if (isSupported !== true) {
|
||||
setError('이 브라우저는 오디오 녹음을 지원하지 않습니다. Chrome을 사용해주세요.')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
const mimeType = pickRecorderMimeType((type) =>
|
||||
@@ -188,7 +189,7 @@ export function useAudioRecorder(): AudioRecorderHook {
|
||||
)
|
||||
if (!mimeType) {
|
||||
setError('브라우저가 지원하는 녹음 형식을 찾지 못했습니다.')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
let stream: MediaStream
|
||||
@@ -198,7 +199,7 @@ export function useAudioRecorder(): AudioRecorderHook {
|
||||
})
|
||||
} catch (err) {
|
||||
setError(describeCaptureError(err))
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
// 서버 세션은 있으면 좋고 없어도 녹음은 간다. 여기서 포기하면
|
||||
@@ -242,13 +243,16 @@ export function useAudioRecorder(): AudioRecorderHook {
|
||||
setError('녹음 중 오류가 발생했습니다. 지금까지의 오디오는 보존되어 있습니다.')
|
||||
}
|
||||
|
||||
const startedAt = new Date()
|
||||
streamRef.current = stream
|
||||
recorderRef.current = recorder
|
||||
mimeTypeRef.current = mimeType
|
||||
startedAtRef.current = new Date()
|
||||
startedAtRef.current = startedAt
|
||||
|
||||
recorder.start(CHUNK_INTERVAL_MS)
|
||||
setIsRecording(true)
|
||||
|
||||
return startedAt.getTime()
|
||||
}, [isSupported, queueUpload])
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
|
||||
@@ -9,8 +9,20 @@ interface SpeechRecognitionHook {
|
||||
chunks: TranscriptChunk[]
|
||||
interimText: string
|
||||
error: string | null
|
||||
startListening: () => void
|
||||
stopListening: () => void
|
||||
/**
|
||||
* Chrome이 "소리는 들었는데 못 알아듣겠다"고 답한 횟수.
|
||||
*
|
||||
* `isFinal: true`에 `transcript: ""`인 결과다. 실측 46분 회의에서 19번 나왔다.
|
||||
* 조용히 버리면 유실이 보이지 않으므로 세어서 노출한다.
|
||||
*/
|
||||
missedCount: number
|
||||
/**
|
||||
* @param timeOrigin 시간축의 기준점(ms). 오디오 녹음 시작 시각을 넘기면
|
||||
* 전사 타임스탬프가 녹음 파일의 재생 위치와 맞는다. 생략하면 지금 시각.
|
||||
*/
|
||||
startListening: (timeOrigin?: number) => void
|
||||
/** 미확정 발화까지 확정한 최종 청크를 돌려준다. */
|
||||
stopListening: () => TranscriptChunk[]
|
||||
resetChunks: () => void
|
||||
}
|
||||
|
||||
@@ -30,16 +42,31 @@ function describeError(code: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 발화 시작을 못 잡았을 때 되돌아갈 기본 길이(초). */
|
||||
const FALLBACK_UTTERANCE_SECONDS = 2
|
||||
|
||||
export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
||||
const [interimText, setInterimText] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSupported, setIsSupported] = useState<boolean | null>(null)
|
||||
const [missedCount, setMissedCount] = useState(0)
|
||||
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||
const startTimeRef = useRef<number>(0)
|
||||
const networkRetryRef = useRef<number>(0)
|
||||
|
||||
// 청크의 정본은 ref다. 인식 종료 직후 곧바로 최종 결과를 넘겨야 하는데
|
||||
// setState는 비동기라 그 시점의 값을 읽을 수 없다.
|
||||
const chunksRef = useRef<TranscriptChunk[]>([])
|
||||
|
||||
// 아직 확정되지 않은 발화. 세션이 끊기면 Chrome은 이걸 그냥 버리므로
|
||||
// 우리가 들고 있다가 직접 확정시킨다.
|
||||
const interimRef = useRef('')
|
||||
// 현재 발화가 처음 들리기 시작한 시각(초). interim이 처음 뜰 때 기록한다.
|
||||
const utteranceStartRef = useRef<number | null>(null)
|
||||
|
||||
const MAX_NETWORK_RETRIES = 8
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,7 +77,44 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
)
|
||||
}, [])
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
const elapsed = useCallback(
|
||||
() => (Date.now() - startTimeRef.current) / 1000,
|
||||
[],
|
||||
)
|
||||
|
||||
const appendChunk = useCallback((chunk: TranscriptChunk) => {
|
||||
chunksRef.current = [...chunksRef.current, chunk]
|
||||
setChunks(chunksRef.current)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 들고 있던 미확정 발화를 청크로 굳힌다.
|
||||
*
|
||||
* 세션이 끝날 때마다 호출한다. Chrome은 `continuous`여도 침묵마다 세션을
|
||||
* 닫는데, 그때 확정 전이던 문장이 통째로 사라지는 것이 유실의 큰 축이었다.
|
||||
*/
|
||||
const flushInterim = useCallback(() => {
|
||||
const pending = interimRef.current.trim()
|
||||
interimRef.current = ''
|
||||
setInterimText('')
|
||||
|
||||
if (pending.length === 0) {
|
||||
utteranceStartRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const end = elapsed()
|
||||
appendChunk({
|
||||
text: pending,
|
||||
startTime: utteranceStartRef.current ?? Math.max(0, end - FALLBACK_UTTERANCE_SECONDS),
|
||||
endTime: end,
|
||||
// Chrome이 확정해 준 결과가 아니라 우리가 살려낸 값이다.
|
||||
isFinal: false,
|
||||
})
|
||||
utteranceStartRef.current = null
|
||||
}, [appendChunk, elapsed])
|
||||
|
||||
const startListening = useCallback((timeOrigin?: number) => {
|
||||
if (isSupported !== true) {
|
||||
setError('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome을 사용해주세요.')
|
||||
return
|
||||
@@ -64,8 +128,10 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
recognition.continuous = true
|
||||
recognition.interimResults = true
|
||||
|
||||
startTimeRef.current = Date.now()
|
||||
startTimeRef.current = timeOrigin ?? Date.now()
|
||||
networkRetryRef.current = 0
|
||||
interimRef.current = ''
|
||||
utteranceStartRef.current = null
|
||||
setError(null)
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
@@ -74,32 +140,47 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const now = (Date.now() - startTimeRef.current) / 1000
|
||||
const now = elapsed()
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i]
|
||||
const text = result[0].transcript.trim()
|
||||
|
||||
if (result.isFinal) {
|
||||
setChunks((prev) => [
|
||||
...prev,
|
||||
{
|
||||
text,
|
||||
startTime: Math.max(0, now - 2),
|
||||
endTime: now,
|
||||
isFinal: true,
|
||||
},
|
||||
])
|
||||
setInterimText('')
|
||||
} else {
|
||||
setInterimText(text)
|
||||
if (!result.isFinal) {
|
||||
// 발화의 첫 조각이 들린 순간이 곧 시작 시각이다.
|
||||
if (utteranceStartRef.current === null) {
|
||||
utteranceStartRef.current = now
|
||||
}
|
||||
interimRef.current = text
|
||||
setInterimText(text)
|
||||
continue
|
||||
}
|
||||
|
||||
const start =
|
||||
utteranceStartRef.current ??
|
||||
Math.max(0, now - FALLBACK_UTTERANCE_SECONDS)
|
||||
|
||||
interimRef.current = ''
|
||||
utteranceStartRef.current = null
|
||||
setInterimText('')
|
||||
|
||||
if (text.length === 0) {
|
||||
// 소리는 감지했지만 인식에 실패한 구간. 프롬프트를 오염시키지 않도록
|
||||
// 청크로 넣지 않되, 유실이 있었다는 사실은 남긴다.
|
||||
setMissedCount((prev) => prev + 1)
|
||||
continue
|
||||
}
|
||||
|
||||
appendChunk({ text, startTime: start, endTime: now, isFinal: true })
|
||||
}
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
if (recognitionRef.current !== recognition) return
|
||||
|
||||
// 재시작 전에 반드시 비운다. 새 세션은 이전 오디오를 다시 주지 않는다.
|
||||
flushInterim()
|
||||
|
||||
const delay = networkRetryRef.current > 0 ? 1500 : 0
|
||||
setTimeout(() => {
|
||||
if (recognitionRef.current !== recognition) return
|
||||
@@ -151,22 +232,31 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
)
|
||||
recognitionRef.current = null
|
||||
}
|
||||
}, [isSupported])
|
||||
}, [appendChunk, elapsed, flushInterim, isSupported])
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
if (recognitionRef.current) {
|
||||
const stopListening = useCallback((): TranscriptChunk[] => {
|
||||
const recognition = recognitionRef.current
|
||||
if (recognition) {
|
||||
recognitionRef.current = null
|
||||
recognition.stop()
|
||||
setIsListening(false)
|
||||
setInterimText('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 마지막 발화는 확정 전에 세션이 닫히는 경우가 대부분이다.
|
||||
// 여기서 굳히지 않으면 회의 끝머리가 통째로 사라진다.
|
||||
flushInterim()
|
||||
|
||||
return chunksRef.current
|
||||
}, [flushInterim])
|
||||
|
||||
const resetChunks = useCallback(() => {
|
||||
chunksRef.current = []
|
||||
interimRef.current = ''
|
||||
utteranceStartRef.current = null
|
||||
setChunks([])
|
||||
setInterimText('')
|
||||
setError(null)
|
||||
setMissedCount(0)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
@@ -175,6 +265,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
chunks,
|
||||
interimText,
|
||||
error,
|
||||
missedCount,
|
||||
startListening,
|
||||
stopListening,
|
||||
resetChunks,
|
||||
|
||||
@@ -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<TranscriptionOutcome | null>
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 보관된 녹음을 서버 STT로 전사한다.
|
||||
*
|
||||
* Web Speech 결과를 덮어쓰는 것이 목적이다. 실시간 텍스트는 회의 중 흐름을
|
||||
* 보기 위한 초안이고, 확정본은 원본 오디오에서 다시 뽑는다.
|
||||
*/
|
||||
export function useTranscription(): TranscriptionHook {
|
||||
const [isTranscribing, setIsTranscribing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<TranscriptionOutcome | null>(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 }
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
}
|
||||
}
|
||||
+41
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { complete, toProviderSettings, type ProviderSettings } from './providers'
|
||||
|
||||
/**
|
||||
* 긴 회의 처리.
|
||||
*
|
||||
* 예전에는 10만 자를 넘으면 `/api/summarize`가 413으로 **거부**했다. 3시간짜리
|
||||
* 회의를 마치고 회의록을 만들려는 순간 아무것도 못 받는다는 뜻이다. 유실을
|
||||
* 막자고 만든 파이프라인의 끝에서 결과를 통째로 버리는 셈이었다.
|
||||
*
|
||||
* 이제는 구간별로 한 번 압축한 뒤(map), 압축본을 모아 평소와 똑같은 템플릿
|
||||
* 경로로 회의록을 만든다(reduce). 최종 출력이 짧은 회의와 같은 프롬프트를
|
||||
* 타므로 서식이 흔들리지 않는다.
|
||||
*/
|
||||
|
||||
/** 이 길이를 넘으면 한 번에 못 넣는다고 보고 압축 단계를 거친다. */
|
||||
export const SINGLE_PASS_CHAR_LIMIT = 60_000
|
||||
|
||||
/** 압축 단계에서 한 번에 넣을 구간 크기. */
|
||||
export const CONDENSE_WINDOW_CHARS = 40_000
|
||||
|
||||
/** 이걸 넘으면 압축을 해도 감당이 안 된다고 보고 거부한다(약 14시간 분량). */
|
||||
export const MAX_TRANSCRIPT_CHARS = 500_000
|
||||
|
||||
/**
|
||||
* 전사문을 줄 경계에서 잘라 구간으로 나눈다.
|
||||
*
|
||||
* 한 줄이 통째로 상한을 넘으면(타임스탬프 없는 긴 문단) 그 줄만 강제로 자른다.
|
||||
* 어떤 경우에도 원문의 어느 부분도 버리지 않는다.
|
||||
*/
|
||||
export function planTranscriptWindows(
|
||||
transcript: string,
|
||||
maxChars: number = CONDENSE_WINDOW_CHARS,
|
||||
): string[] {
|
||||
if (maxChars <= 0) throw new Error('maxChars는 1 이상이어야 한다')
|
||||
|
||||
const text = transcript.trim()
|
||||
if (text.length === 0) return []
|
||||
if (text.length <= maxChars) return [text]
|
||||
|
||||
const windows: string[] = []
|
||||
let current: string[] = []
|
||||
let currentLength = 0
|
||||
|
||||
function flush() {
|
||||
if (current.length === 0) return
|
||||
windows.push(current.join('\n'))
|
||||
current = []
|
||||
currentLength = 0
|
||||
}
|
||||
|
||||
for (const line of text.split('\n')) {
|
||||
// 줄 하나가 상한보다 길면 쪼개는 수밖에 없다.
|
||||
if (line.length > maxChars) {
|
||||
flush()
|
||||
for (let i = 0; i < line.length; i += maxChars) {
|
||||
windows.push(line.slice(i, i + maxChars))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const projected = currentLength + line.length + (current.length > 0 ? 1 : 0)
|
||||
if (projected > maxChars) flush()
|
||||
|
||||
current.push(line)
|
||||
currentLength += line.length + (current.length > 1 ? 1 : 0)
|
||||
}
|
||||
|
||||
flush()
|
||||
return windows
|
||||
}
|
||||
|
||||
function buildCondensePrompt(
|
||||
window: string,
|
||||
index: number,
|
||||
total: number,
|
||||
): string {
|
||||
return [
|
||||
`다음은 회의 전사문의 ${index + 1}/${total} 구간입니다.`,
|
||||
'',
|
||||
'이 구간에서 오간 내용을 빠짐없이 정리하세요. 이 정리본만 보고 회의록을',
|
||||
'작성하게 되므로, 뒤에서 쓰일 정보를 잃으면 안 됩니다.',
|
||||
'',
|
||||
'규칙:',
|
||||
'- 논의된 주제, 결정된 사항, 할 일, 숫자·날짜·고유명사를 모두 남깁니다.',
|
||||
'- 누가 말했는지 드러나면 함께 적습니다.',
|
||||
'- 인사말·잡담·중복은 덜어냅니다.',
|
||||
'- 회의록 서식으로 만들지 말고, 사실을 담은 개조식 메모로만 정리합니다.',
|
||||
'- 앞뒤 구간을 추측해 채우지 않습니다. 이 구간에 있는 내용만 씁니다.',
|
||||
'',
|
||||
'---',
|
||||
window,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export interface CondenseOptions {
|
||||
provider?: ProviderSettings
|
||||
apiKey?: string
|
||||
windowChars?: number
|
||||
fetchFn?: typeof fetch
|
||||
}
|
||||
|
||||
export type CondenseResult =
|
||||
| { success: true; text: string; windows: number }
|
||||
| { success: false; error: string; rateLimited?: boolean }
|
||||
|
||||
/**
|
||||
* 긴 전사문을 구간별로 압축한다.
|
||||
*
|
||||
* 한 구간이라도 실패하면 전체를 실패로 돌린다. 일부만 빠진 압축본으로 회의록을
|
||||
* 만들면 사용자는 무엇이 빠졌는지 모른 채 멀쩡해 보이는 문서를 받게 된다.
|
||||
*/
|
||||
export async function condenseTranscript(
|
||||
transcript: string,
|
||||
options: CondenseOptions = {},
|
||||
): Promise<CondenseResult> {
|
||||
const settings = toProviderSettings(options.provider, options.apiKey)
|
||||
const windows = planTranscriptWindows(
|
||||
transcript,
|
||||
options.windowChars ?? CONDENSE_WINDOW_CHARS,
|
||||
)
|
||||
|
||||
if (windows.length === 0) {
|
||||
return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
|
||||
}
|
||||
|
||||
const condensed: string[] = []
|
||||
|
||||
for (const [index, window] of windows.entries()) {
|
||||
const result = await complete(
|
||||
buildCondensePrompt(window, index, windows.length),
|
||||
settings,
|
||||
{ fetchFn: options.fetchFn },
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${index + 1}/${windows.length} 구간 정리 실패: ${result.error}`,
|
||||
...(result.rateLimited ? { rateLimited: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const text = result.text.trim()
|
||||
if (text.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${index + 1}/${windows.length} 구간 정리 결과가 비어 있습니다.`,
|
||||
}
|
||||
}
|
||||
|
||||
condensed.push(`## 구간 ${index + 1}/${windows.length}\n\n${text}`)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
text: condensed.join('\n\n'),
|
||||
windows: windows.length,
|
||||
}
|
||||
}
|
||||
@@ -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<TranscriptionResult> {
|
||||
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}` }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<TranscriptionResult> {
|
||||
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<string, string> = {}
|
||||
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
|
||||
}
|
||||
@@ -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-...',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+11
-2
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
Reference in new issue
Block a user