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 | |
|---|---|---|---|
|
|
b115663ecf | ||
|
|
cf3ce3f40c |
No files matched your search
@@ -51,3 +51,20 @@ GEMINI_API_KEY=
|
||||
# LLM_BASE_URL=
|
||||
# LLM_MODEL=
|
||||
# LLM_API_KEY=
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 회의 오디오 녹음 저장 위치 (선택)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 기본값은 <프로젝트>/uploads/recordings 입니다.
|
||||
# Docker Compose에서는 uploads 볼륨에 저장되어 컨테이너를 재시작해도 남습니다.
|
||||
#
|
||||
# ⚠️ 녹음 파일에는 회의 원음이 그대로 들어 있습니다. 디스크 암호화된 위치에
|
||||
# 두고, 필요 없어진 녹음은 직접 삭제하세요. SECURITY.md 참고.
|
||||
# RECORDINGS_DIR=
|
||||
|
||||
# 음성 전사(STT) 모델 (선택)
|
||||
# 비우면 프로바이더 기본값: OpenAI 호환 → whisper-1, Gemini → gemini-3.6-flash
|
||||
#
|
||||
# ⚠️ 전사는 회의 원음을 프로바이더 서버로 보냅니다. 텍스트보다 훨씬 민감합니다.
|
||||
# 외부로 내보내고 싶지 않다면 로컬 whisper 서버를 LLM_BASE_URL로 지정하세요.
|
||||
# LLM_STT_MODEL=
|
||||
@@ -49,6 +49,3 @@ next-env.d.ts
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
|
||||
# 화자 분리 모델 (npm run setup:diarization 으로 내려받음)
|
||||
models/
|
||||
@@ -24,7 +24,6 @@ docker compose up -d
|
||||
|
||||
- [✨ 주요 기능](#-주요-기능)
|
||||
- [🎤 실시간 녹음 & 전사](#-실시간-녹음--전사)
|
||||
- [🗣️ 화자 분리](#️-화자-분리)
|
||||
- [🤖 AI 회의록 (6 템플릿 × 3 강도)](#-ai-회의록-6-템플릿--3-강도)
|
||||
- [⚡ 실시간 롤링 요약](#-실시간-롤링-요약)
|
||||
- [💾 회의록 저장/조회/검색](#-회의록-저장조회검색)
|
||||
@@ -52,10 +51,67 @@ docker compose up -d
|
||||
|
||||
**특징**
|
||||
- 즉시 시작 — 별도 STT 서버나 키 없이 작동
|
||||
- **오디오 원본 동시 녹음** — 전사와 무관하게 회의 원음을 파일로 보관
|
||||
- **자동 재연결** — 네트워크 끊김 시 최대 8회 재시도, 누적 transcript 보존
|
||||
- 확정 전 텍스트는 회색 이탤릭 + 깜빡이는 커서로 시각화
|
||||
- 녹음 중 `🔴 N단어 · M개 구간` 실시간 카운터
|
||||
|
||||
> ⚠️ **Web Speech API는 발화를 상당량 놓칩니다.** 원거리·다인 대면 회의에서
|
||||
> 실측 포착률이 10% 안팎이었습니다(46분 회의 / 489어절). 회의 전사용으로
|
||||
> 설계된 엔진이 아니라 근접 음성 명령용입니다. 그래서 **오디오 원본을 반드시
|
||||
> 함께 남깁니다** — 놓친 발화는 나중에 서버 STT로 다시 살릴 수 있습니다.
|
||||
|
||||
### 🎧 오디오 원본 보관
|
||||
|
||||
녹음을 시작하면 전사와 **별개로** 마이크 입력을 오디오 파일로 저장합니다.
|
||||
|
||||
- 15초마다 조각을 서버에 이어 붙여 **탭이나 서버가 죽어도 그때까지의 오디오는 남습니다**
|
||||
- 서버 저장이 실패해도 녹음은 중단되지 않고, 브라우저 사본을 **⬇️ 내려받기**로 회수할 수 있습니다
|
||||
- 한 조각도 못 받았으면 "보관됨"이라고 하지 않고 오류를 띄웁니다
|
||||
- 저장 위치: `uploads/recordings/` (Docker에서는 `uploads` 볼륨 → 재시작해도 유지)
|
||||
|
||||
**캡처 설정** — 브라우저 기본값은 전화 통화용 튜닝이라 멀리 앉은 화자를
|
||||
노이즈로 지워버립니다. 그래서 노이즈 억제·에코 제거를 끄고 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. 마이크 권한 허용
|
||||
@@ -102,38 +158,6 @@ docker compose up -d
|
||||
|
||||
---
|
||||
|
||||
### 🗣️ 화자 분리
|
||||
|
||||
누가 무슨 말을 했는지 구분해 기록합니다. 회의 환경에 따라 두 가지 방식을 씁니다.
|
||||
|
||||
| 모드 | 방식 | 정확도 | 준비물 |
|
||||
|---|---|---|---|
|
||||
| **원격 회의** | 내 마이크와 시스템(탭) 오디오를 **별도 트랙**으로 받아 트랙별로 전사 | **100%** — 추론 없음 | 없음 |
|
||||
| **대면 회의** | 마이크 한 트랙을 **내 PC에서** 분석해 화자를 나눔 | DER 12~15% | 모델 1회 설치 |
|
||||
|
||||
**원격 회의**는 트랙이 곧 화자라 틀릴 수가 없습니다. 시작할 때 화면 공유 대화상자에서
|
||||
**"탭 오디오 공유"를 반드시 켜야** 합니다. 켜지 않으면 마이크 단독 녹음으로 넘어가고
|
||||
화자 라벨이 붙지 않습니다 (틀린 라벨을 붙이지 않습니다).
|
||||
|
||||
**대면 회의**는 `pyannote-segmentation-3.0` + `3D-Speaker CAM++` ONNX 모델을 로컬
|
||||
Next.js 프로세스에서 실행합니다. **오디오가 외부로 나가지 않습니다.** GPU도 파이썬
|
||||
런타임도 필요 없고, 노트북 CPU에서 실시간의 수십 배 속도로 처리됩니다.
|
||||
|
||||
```bash
|
||||
# 최초 1회 — 모델 약 34MB 내려받기
|
||||
npm run setup:diarization
|
||||
```
|
||||
|
||||
> 💡 **참석자 수를 정확히 입력하세요.** 자동 추정은 화자 수를 잘못 세는 것이 주 실패
|
||||
> 모드입니다. 실측에서 4인 오디오가 자동 모드로는 5명, 참석자 수를 알려주면 4명으로
|
||||
> 정확히 나왔습니다.
|
||||
|
||||
대면 모드는 녹음이 끝난 뒤 **전체 오디오에 한 번** 분석을 돌립니다. 구간을 잘라
|
||||
반복 실행하면 실행마다 화자 번호가 달라져 이어 붙일 수 없기 때문입니다. 따라서
|
||||
녹음 중 실시간 화자 표시는 없고, 종료 시 원문 전체에 화자가 붙습니다.
|
||||
|
||||
---
|
||||
|
||||
### ⚡ 실시간 롤링 요약
|
||||
|
||||
녹음 중 **30초마다** Gemini가 그동안의 발화를 분석해 중간 회의록을 자동 갱신합니다.
|
||||
@@ -368,8 +392,7 @@ npm run test:coverage # 커버리지 리포트
|
||||
| DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) |
|
||||
| Markdown | `marked` + `isomorphic-dompurify` |
|
||||
| 스타일 | Tailwind CSS v4 |
|
||||
| 화자 분리 | pyannote-segmentation-3.0 + CAM++ (ONNX Runtime, CPU) |
|
||||
| 테스트 | Vitest 4 (jsdom, 184 tests) |
|
||||
| 테스트 | Vitest 4 (jsdom, 141 tests) |
|
||||
| 배포 | Docker Compose + standalone Next.js 빌드 |
|
||||
|
||||
---
|
||||
@@ -517,8 +540,11 @@ prisma/
|
||||
|
||||
| 제약 | 설명 | 대응 |
|
||||
|---|---|---|
|
||||
| **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | Phase 4에서 서버 사이드 전사 검토 |
|
||||
| **화자 구분 없음** | 의도적 비지원 (Non-goal) | — |
|
||||
| **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | 서버 사이드 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인스턴스로 운용 |
|
||||
| **마크다운 검색** | PostgreSQL `ILIKE` (수천 건 이상에서 느려질 수 있음) | 필요 시 `tsvector` 인덱스 추가 |
|
||||
|
||||
+41
-16
@@ -52,7 +52,6 @@
|
||||
| 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 |
|
||||
| API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 선택한 프로바이더에만 전송 |
|
||||
| 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ |
|
||||
| 화자 분리용 PCM | 서버 프로세스 메모리 (녹음 중에만) | **외부 송출 없음** — 로컬에서 분석하고 분석 후 즉시 폐기 |
|
||||
|
||||
### ⚠️ 주의: Web Speech API의 음성 외부 전송
|
||||
|
||||
@@ -64,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`에서 고른 프로바이더에 따라 **회의 전문이 지나가는 회사가 달라집니다.**
|
||||
@@ -78,21 +98,6 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전
|
||||
- 중계 라우터는 요청을 대신 전달하는 구조상 **평문 프롬프트를 볼 수 있는 주체가 한 곳 늘어납니다.** 로깅·보관 정책은 각 서비스 약관을 직접 확인하세요.
|
||||
- 사내 컴플라이언스가 외부 전송을 제한한다면 **로컬 모델** 프리셋을 사용하세요.
|
||||
|
||||
### 화자 분리 오디오는 로컬에만 머뭅니다
|
||||
|
||||
대면 회의 화자 분리는 `pyannote-segmentation-3.0` + `CAM++` ONNX 모델을 로컬
|
||||
Next.js 프로세스에서 실행합니다. 외부 API를 호출하지 않으므로 **오디오가 네트워크로
|
||||
나가지 않습니다.**
|
||||
|
||||
- 녹음 중 PCM 조각이 서버 프로세스 **메모리**에 쌓입니다 (디스크에 쓰지 않음)
|
||||
- 분석이 끝나면 즉시 폐기하며, 6시간 지난 세션은 자동 정리됩니다
|
||||
- 세션 최대 길이는 3시간으로 제한됩니다
|
||||
- 단일 사용자 로컬 실행 전제입니다. 여러 사용자가 붙는 환경이라면 세션 ID를
|
||||
추측해 다른 사람의 오디오에 접근할 수 있으므로, 노출 배포 시 인증이 선행되어야 합니다
|
||||
|
||||
전사 텍스트는 여전히 Web Speech API를 거치므로 Google로 갑니다. 화자 분리만
|
||||
로컬이라는 점에 유의하세요.
|
||||
|
||||
### base URL은 서버가 대신 호출합니다 (SSRF 주의)
|
||||
|
||||
OpenAI 호환 프리셋의 base URL은 브라우저가 아니라 **Next.js Route Handler(서버)** 가 fetch 합니다.
|
||||
@@ -148,6 +153,26 @@ OpenAI 호환 프리셋의 base URL은 브라우저가 아니라 **Next.js Route
|
||||
- 추가 보호가 필요하면 reverse proxy(nginx, Caddy)에 Basic Auth 추가
|
||||
- 회사 환경에서는 SSO 통합이 필요하지만 현재 비목표
|
||||
|
||||
### MEDIUM — 회의 원음이 디스크에 평문으로 남습니다
|
||||
|
||||
**현황**: 실시간 녹음을 시작하면 회의 오디오 원본이 `uploads/recordings/`에 저장됩니다
|
||||
(Docker에서는 `uploads` 볼륨). 암호화하지 않은 평문 오디오이며, 회의에서 오간
|
||||
말이 그대로 들어 있습니다. **전사 텍스트보다 민감도가 높습니다** — 텍스트는
|
||||
Web Speech가 대부분 놓치지만 오디오에는 전부 남습니다.
|
||||
|
||||
이 저장은 의도된 설계입니다. Web Speech의 포착률이 낮아(실측 10% 안팎) 원음을
|
||||
남기지 않으면 놓친 발화를 복구할 방법이 없습니다.
|
||||
|
||||
**완화**:
|
||||
- 디스크 암호화(FileVault / BitLocker)가 켜진 머신에서 운용
|
||||
- 불필요해진 녹음은 `uploads/recordings/`에서 직접 삭제
|
||||
- `RECORDINGS_DIR` 환경변수로 저장 위치를 별도 암호화 볼륨으로 지정 가능
|
||||
- 녹음 파일 자동 만료/삭제는 **미구현** — 수동 관리가 필요합니다
|
||||
|
||||
**세션 ID 추측**: 녹음 조회·추가 API는 128비트 난수 ID만으로 접근을 가릅니다.
|
||||
인증이 없으므로, 같은 머신에서 앱에 접근 가능한 주체는 ID를 알면 오디오를
|
||||
내려받을 수 있습니다. 단일 사용자 로컬 실행 전제입니다.
|
||||
|
||||
### MEDIUM — 파일 업로드 매직 바이트 미검증
|
||||
**현황**: `audio-validation.ts`는 `file.type`(클라이언트 제공)과 파일명 확장자만 검사. 매직 바이트 검증 없음.
|
||||
|
||||
|
||||
+64
-1
@@ -155,6 +155,70 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔴 실사용 진단 (2026-09-08 대면 회의)
|
||||
|
||||
46분 대면 회의를 실제로 녹음해 본 결과다. 로드맵의 우선순위를 바꾼 근거.
|
||||
|
||||
| 지표 | 값 |
|
||||
|---|---|
|
||||
| 회의 길이 | 46분 6초 |
|
||||
| 총 청크 | 122개 |
|
||||
| 총 어절 | **489** |
|
||||
| 분당 어절 | **10.6** (한국어 대화 통상 100~150) |
|
||||
| 추정 포착률 | **7~10%** |
|
||||
| 30초 이상 공백 | 26곳 / 합계 26분 29초 (회의의 57%) |
|
||||
| 빈 텍스트 청크 | 19개 (16%) |
|
||||
|
||||
**빈 청크 19개가 결정적 증거다.** Chrome이 `isFinal: true`에 `transcript: ""`를
|
||||
준 경우로, "소리는 감지했으나 인식 실패"를 뜻한다. 마이크 입력 문제가 아니라
|
||||
Web Speech가 원거리·다인 한국어를 못 알아듣고 버린 것이다.
|
||||
|
||||
**결론: Web Speech API는 회의 전사에 쓸 수 없다.** 재시작 갭을 메우고 interim을
|
||||
flush해서 7%를 20~30%로는 올려도 90%로는 못 간다. 구현 결함이 아니라 엔진의 한계다.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 P0 — 유실을 멈춘다
|
||||
|
||||
- [x] **오디오 원본 녹음** — `MediaRecorder`로 회의 원음을 파일로 보관
|
||||
- [x] 15초 조각 단위로 서버에 append → 탭/서버가 죽어도 그때까지는 남음
|
||||
- [x] 서버 실패해도 녹음 계속 + 브라우저 사본 내려받기
|
||||
- [x] 0바이트일 때 "보관됨"이라고 하지 않음
|
||||
- [x] 캡처 제약에서 노이즈 억제·에코 제거 해제 (원거리 화자 보존)
|
||||
- [x] 회의록 저장 시 `audioFileName`/`audioMimeType`/`audioDuration` 연결
|
||||
- [x] **서버 STT 파이프라인** — 녹음 파일 → Whisper / Gemini audio → transcript
|
||||
- [x] `/api/upload`를 막다른 길에서 본선 경로로 승격 (업로드 → recordingId → 전사)
|
||||
- [x] 프로바이더 레이어에 `/v1/audio/transcriptions` 추가 (#12 구조 확장)
|
||||
- [x] Gemini 오디오 inline 입력 (webm 미지원은 명시적으로 안내)
|
||||
- [x] `verbose_json`으로 실제 오디오 타임스탬프 확보
|
||||
- [x] 참석자·용어 힌트를 전사 프롬프트로 전달
|
||||
- [x] 녹음 비트레이트 32kbps로 하향 — 46분 회의가 11MB로 API 상한 안에 들어옴
|
||||
- [ ] 같은 오디오로 Web Speech vs STT 포착률 실측 비교 ← **다음 회의에서**
|
||||
- [ ] 상한 초과 회의 분할 전사 (Whisper 25MB / Gemini inline 14MB)
|
||||
- [ ] Gemini Files API 경로 (큰 파일 + webm 우회)
|
||||
- [ ] Web Speech를 "실시간 미리보기"로 명시적 강등 (현재는 둘 다 노출)
|
||||
- [ ] **빈 청크 필터링** — 빈 발화 19개가 요약 프롬프트를 오염시키고 있음
|
||||
- [ ] **타임스탬프 실측화** — `useSpeechRecognition.ts`의 `startTime: now - 2` 하드코딩 제거
|
||||
- [ ] **10만 자 하드 실패 → 분할 요약** — 긴 회의가 마지막에 통째로 실패함
|
||||
|
||||
## 🎯 P1 — 입력단 개선
|
||||
|
||||
- [ ] 대면 회의용 USB 전방향 마이크 도입 (코드로 못 푸는 물리적 한계)
|
||||
- [ ] 원격 회의용 `echoCancellation` 재활성 경로 분리
|
||||
|
||||
## 🎯 P2 — 화자 분리 재설계
|
||||
|
||||
- [ ] **PR #17은 현재 형태로 머지 보류** — 실사용에서 46분 회의를 화자 1명으로
|
||||
판정했고, 5개 청크는 배정조차 실패했다. `assignSpeakers()`가 쓰는 시간축이
|
||||
가짜(`now - 2`)라 diarization의 실제 타임라인과 맞지 않는다
|
||||
- [ ] 서버 STT 도입 후 재설계 — Whisper의 단어 단위 실제 타임스탬프 위에서
|
||||
diarization을 돌리면 그때 비로소 겹침 기반 배정이 의미를 갖는다
|
||||
- [ ] #17의 sherpa-onnx 래퍼·모델 셋업 스크립트는 그때 재활용
|
||||
- [ ] 참석자 수 힌트는 유지 (#17 실측에서 효과 확인됨)
|
||||
- [ ] PR #16(dual-stream)은 원격 회의 전용으로 분리 검증
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Phase 3 — 참석자 + 태그 (3~5일)
|
||||
|
||||
### Issues
|
||||
@@ -199,7 +263,6 @@
|
||||
|
||||
다음 기능은 개인 사용 목적에 부합하지 않아 **의도적으로 범위 밖**:
|
||||
|
||||
- **화자 구분 (Speaker Diarization)** — 유료 API 비용/복잡도 대비 개인용에 과함
|
||||
- **실시간 공동 편집 (CRDT)** — Yjs/Liveblocks 도입 복잡도 대비 이득 없음
|
||||
- **권한 관리 / 워크스페이스** — 단일 사용자 가정
|
||||
- **모바일 네이티브 앱** — 웹 PWA로 충분
|
||||
|
||||
@@ -12,8 +12,6 @@ const securityHeaders = [
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// sherpa-onnx-node는 네이티브 애드온이라 번들링하면 안 된다.
|
||||
serverExternalPackages: ["sherpa-onnx-node"],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
Generated
+1
-94
@@ -16,8 +16,7 @@
|
||||
"pg": "^8.13.1",
|
||||
"prisma": "^7.7.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"sherpa-onnx-node": "^1.13.7"
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -8423,98 +8422,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sherpa-onnx-darwin-arm64": {
|
||||
"version": "1.13.7",
|
||||
"resolved": "https://registry.npmjs.org/sherpa-onnx-darwin-arm64/-/sherpa-onnx-darwin-arm64-1.13.7.tgz",
|
||||
"integrity": "sha512-5NCE50hAvr3n2pdett0SgfPBJXaFZE0bqHwbHyiq+IKZ8Ids0l4M0VrG+ImGYIafCwie+oC3uAJ+pKj9xg/k+w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/sherpa-onnx-darwin-x64": {
|
||||
"version": "1.13.7",
|
||||
"resolved": "https://registry.npmjs.org/sherpa-onnx-darwin-x64/-/sherpa-onnx-darwin-x64-1.13.7.tgz",
|
||||
"integrity": "sha512-N3o+T+wn9WaQmsKV5DD8bTHdo+WN2+sXwmZcGJZiDjtOMR2zFz7uVCZnYCmEAMgvChC+oHcF5RvEEKcRCAu6Pw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/sherpa-onnx-linux-arm64": {
|
||||
"version": "1.13.7",
|
||||
"resolved": "https://registry.npmjs.org/sherpa-onnx-linux-arm64/-/sherpa-onnx-linux-arm64-1.13.7.tgz",
|
||||
"integrity": "sha512-TFCVpXyTh69buhOtTS8KIfkRXOVKY4Y1qjAktSItrKS4A0chnnrlXO5bKWoNAPeI6fMxTF/uvMYbYgcvjEMfNg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/sherpa-onnx-linux-x64": {
|
||||
"version": "1.13.7",
|
||||
"resolved": "https://registry.npmjs.org/sherpa-onnx-linux-x64/-/sherpa-onnx-linux-x64-1.13.7.tgz",
|
||||
"integrity": "sha512-npmxn5WwmAmlthgBhmbZ33t3i2j4mJwQt46dMEb3j7d41y1/uJrjrVAfa/DkvV+vn49ZWfcQ2UEWDipaZBVhuw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/sherpa-onnx-node": {
|
||||
"version": "1.13.7",
|
||||
"resolved": "https://registry.npmjs.org/sherpa-onnx-node/-/sherpa-onnx-node-1.13.7.tgz",
|
||||
"integrity": "sha512-0XGV7arGngBCnol0m8OLyqlnaUm19Q1KmetVj1DDBdymXa1upmAHZDwNdN47gjsEhqE5hXUEyc1vRQoXrNhNVg==",
|
||||
"license": "Apache-2.0",
|
||||
"optionalDependencies": {
|
||||
"sherpa-onnx-darwin-arm64": "^1.13.7",
|
||||
"sherpa-onnx-darwin-x64": "^1.13.7",
|
||||
"sherpa-onnx-linux-arm64": "^1.13.7",
|
||||
"sherpa-onnx-linux-x64": "^1.13.7",
|
||||
"sherpa-onnx-win-ia32": "^1.13.7",
|
||||
"sherpa-onnx-win-x64": "^1.13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/sherpa-onnx-win-ia32": {
|
||||
"version": "1.13.7",
|
||||
"resolved": "https://registry.npmjs.org/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.13.7.tgz",
|
||||
"integrity": "sha512-sTwtpxPQ76XLn0giAbvknIDEDKD3XXi2mo2AVROEucf1pIK1DjQl+LjLkalTeFoQqbC4J3xGx/g+xgcHQD1dsw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/sherpa-onnx-win-x64": {
|
||||
"version": "1.13.7",
|
||||
"resolved": "https://registry.npmjs.org/sherpa-onnx-win-x64/-/sherpa-onnx-win-x64-1.13.7.tgz",
|
||||
"integrity": "sha512-wBV1o+/zgsMrOjfCFIgGrH6S28xq6CqRCLSavCOjTZ6cqr80yGc07DUHxqsHFPZvfoJU+2JF5L2l3gyWFWoWdQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
|
||||
+2
-4
@@ -9,8 +9,7 @@
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"setup:diarization": "node scripts/setup-diarization.mjs"
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.7.0",
|
||||
@@ -21,8 +20,7 @@
|
||||
"pg": "^8.13.1",
|
||||
"prisma": "^7.7.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"sherpa-onnx-node": "^1.13.7"
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 마이크 오디오를 Int16 PCM 조각으로 잘라 메인 스레드로 넘긴다.
|
||||
*
|
||||
* AudioContext를 16kHz로 열면 브라우저가 리샘플링해주므로 여기서는
|
||||
* float32 → int16 변환과 버퍼링만 한다. 화자 분리 모델이 16kHz 모노를 받는다.
|
||||
*/
|
||||
const CHUNK_SAMPLES = 16000 * 4 // 4초
|
||||
|
||||
class PcmCollector extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super()
|
||||
this.buffer = new Int16Array(CHUNK_SAMPLES)
|
||||
this.offset = 0
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (this.offset === 0) return
|
||||
const out = this.buffer.slice(0, this.offset)
|
||||
this.port.postMessage(out, [out.buffer])
|
||||
this.buffer = new Int16Array(CHUNK_SAMPLES)
|
||||
this.offset = 0
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const channel = inputs[0] && inputs[0][0]
|
||||
if (!channel) return true
|
||||
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
const clamped = Math.max(-1, Math.min(1, channel[i]))
|
||||
this.buffer[this.offset++] = clamped * 32767
|
||||
if (this.offset === CHUNK_SAMPLES) this.flush()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('pcm-collector', PcmCollector)
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 화자 분리 모델 내려받기.
|
||||
*
|
||||
* 두 모델 모두 CPU에서 도는 ONNX 파일이며 GPU나 파이썬 런타임이 필요 없다.
|
||||
* 라이선스 문제로 저장소에 포함하지 않고 최초 1회 내려받는다.
|
||||
*
|
||||
* npm run setup:diarization
|
||||
*/
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { mkdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import path from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const DEST = path.join(process.cwd(), 'models', 'diarization')
|
||||
|
||||
const MODELS = [
|
||||
{
|
||||
name: 'segmentation.onnx',
|
||||
// pyannote/segmentation-3.0 (MIT) — 발화 구간 분할
|
||||
url: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2',
|
||||
archiveEntry: 'sherpa-onnx-pyannote-segmentation-3-0/model.onnx',
|
||||
approxMB: 6,
|
||||
},
|
||||
{
|
||||
name: 'embedding.onnx',
|
||||
// 3D-Speaker CAM++ — 화자 임베딩
|
||||
url: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/3dspeaker_speech_campplus_sv_zh-cn_16k-common.onnx',
|
||||
approxMB: 28,
|
||||
},
|
||||
]
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
await stat(p)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function download(url, dest) {
|
||||
const res = await fetch(url, { redirect: 'follow' })
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} — ${url}`)
|
||||
await pipeline(res.body, createWriteStream(dest))
|
||||
}
|
||||
|
||||
function run(cmd, args, cwd) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { cwd, stdio: 'inherit' })
|
||||
child.on('error', reject)
|
||||
child.on('exit', (code) =>
|
||||
code === 0 ? resolve() : reject(new Error(`${cmd} 실패 (exit ${code})`)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(DEST, { recursive: true })
|
||||
|
||||
for (const model of MODELS) {
|
||||
const target = path.join(DEST, model.name)
|
||||
|
||||
if (await exists(target)) {
|
||||
console.log(`✓ ${model.name} — 이미 있음`)
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`↓ ${model.name} (약 ${model.approxMB}MB) 내려받는 중...`)
|
||||
|
||||
if (!model.archiveEntry) {
|
||||
await download(model.url, target)
|
||||
console.log(`✓ ${model.name}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const archive = path.join(DEST, 'tmp.tar.bz2')
|
||||
await download(model.url, archive)
|
||||
await run('tar', ['xjf', archive], DEST)
|
||||
await rename(path.join(DEST, model.archiveEntry), target)
|
||||
await rm(archive)
|
||||
await rm(path.join(DEST, path.dirname(model.archiveEntry)), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
})
|
||||
console.log(`✓ ${model.name}`)
|
||||
}
|
||||
|
||||
console.log(`\n완료. 모델 위치: ${DEST}`)
|
||||
console.log('이제 녹음 화면에서 "대면 회의 화자 분리"를 쓸 수 있습니다.')
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`\n✗ 실패: ${err.message}`)
|
||||
console.error('네트워크를 확인하고 다시 실행해주세요.')
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -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,164 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { mkdtemp, rm, readFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import path from 'path'
|
||||
|
||||
type Store = typeof import('@/lib/recording-store')
|
||||
|
||||
let dir: string
|
||||
let store: Store
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'recordings-'))
|
||||
process.env.RECORDINGS_DIR = dir
|
||||
vi.resetModules()
|
||||
store = await import('@/lib/recording-store')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.RECORDINGS_DIR
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('createRecording', () => {
|
||||
it('세션 메타를 만들고 확장자를 붙인다', async () => {
|
||||
const meta = await store.createRecording('audio/webm;codecs=opus')
|
||||
|
||||
expect(meta.id).toMatch(/^[0-9a-f]{32}$/)
|
||||
expect(meta.fileName).toBe(`${meta.id}.webm`)
|
||||
expect(meta.finalizedAt).toBeNull()
|
||||
expect(meta.durationMs).toBeNull()
|
||||
})
|
||||
|
||||
it('매번 다른 id를 준다', async () => {
|
||||
const a = await store.createRecording('audio/webm')
|
||||
const b = await store.createRecording('audio/webm')
|
||||
expect(a.id).not.toBe(b.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendChunk', () => {
|
||||
it('조각을 받은 순서대로 이어 붙인다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
|
||||
await store.appendChunk(id, Buffer.from('AAA'))
|
||||
await store.appendChunk(id, Buffer.from('BBB'))
|
||||
const last = await store.appendChunk(id, Buffer.from('CC'))
|
||||
|
||||
expect(last).toEqual({ ok: true, bytes: 8 })
|
||||
|
||||
const meta = await store.getRecording(id)
|
||||
const written = await readFile(path.join(dir, meta!.fileName), 'utf-8')
|
||||
expect(written).toBe('AAABBBCC')
|
||||
})
|
||||
|
||||
it('동시에 들어와도 순서가 섞이지 않는다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
|
||||
// 클라이언트가 직렬로 보내도 서버에서 겹칠 수 있다. 겹쳐도 파일이
|
||||
// 깨지지 않아야 한다 — 순서가 어긋나면 컨테이너를 못 읽는다.
|
||||
await Promise.all([
|
||||
store.appendChunk(id, Buffer.from('1')),
|
||||
store.appendChunk(id, Buffer.from('2')),
|
||||
store.appendChunk(id, Buffer.from('3')),
|
||||
store.appendChunk(id, Buffer.from('4')),
|
||||
])
|
||||
|
||||
const meta = await store.getRecording(id)
|
||||
const written = await readFile(path.join(dir, meta!.fileName), 'utf-8')
|
||||
expect(written).toHaveLength(4)
|
||||
expect(written.split('').sort().join('')).toBe('1234')
|
||||
})
|
||||
|
||||
it('없는 세션은 404로 거절한다', async () => {
|
||||
const result = await store.appendChunk('f'.repeat(32), Buffer.from('x'))
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: '녹음 세션을 찾을 수 없습니다.',
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it('누적 크기를 정확히 보고한다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
await store.appendChunk(id, Buffer.from('keep'))
|
||||
|
||||
const result = await store.appendChunk(id, Buffer.alloc(1024))
|
||||
expect(result).toEqual({ ok: true, bytes: 4 + 1024 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('finalizeRecording', () => {
|
||||
it('길이를 확정하고 최종 크기를 돌려준다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
await store.appendChunk(id, Buffer.from('0123456789'))
|
||||
|
||||
const status = await store.finalizeRecording(id, 46_000)
|
||||
|
||||
expect(status?.bytes).toBe(10)
|
||||
expect(status?.durationMs).toBe(46_000)
|
||||
expect(status?.finalizedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('확정 정보가 디스크에도 남는다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
await store.finalizeRecording(id, 1_234)
|
||||
|
||||
const reloaded = await store.getRecording(id)
|
||||
expect(reloaded?.durationMs).toBe(1_234)
|
||||
})
|
||||
|
||||
it('길이가 숫자가 아니면 null로 둔다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
const status = await store.finalizeRecording(id, null)
|
||||
expect(status?.durationMs).toBeNull()
|
||||
})
|
||||
|
||||
it('없는 세션은 null', async () => {
|
||||
expect(await store.finalizeRecording('e'.repeat(32), 1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRecording', () => {
|
||||
it('잘못된 id는 파일을 찾아보지도 않는다', async () => {
|
||||
expect(await store.getRecording('../../etc/passwd')).toBeNull()
|
||||
expect(await store.getRecording('..')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('openRecording', () => {
|
||||
it('조각이 하나도 없으면 null', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
expect(await store.openRecording(id)).toBeNull()
|
||||
})
|
||||
|
||||
it('저장된 바이트를 그대로 읽어준다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
await store.appendChunk(id, Buffer.from('hello'))
|
||||
|
||||
const opened = await store.openRecording(id)
|
||||
expect(opened?.bytes).toBe(5)
|
||||
expect(opened?.meta.mimeType).toBe('audio/webm')
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of opened!.stream) {
|
||||
chunks.push(Buffer.from(chunk as Buffer))
|
||||
}
|
||||
expect(Buffer.concat(chunks).toString()).toBe('hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('크래시 내성', () => {
|
||||
it('종료 처리 없이 중단돼도 그때까지의 오디오는 남는다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
await store.appendChunk(id, Buffer.from('part1'))
|
||||
await store.appendChunk(id, Buffer.from('part2'))
|
||||
// finalize 없이 프로세스가 죽었다고 치고 모듈을 새로 읽는다.
|
||||
vi.resetModules()
|
||||
const reloaded: Store = await import('@/lib/recording-store')
|
||||
|
||||
const status = await reloaded.getRecordingStatus(id)
|
||||
expect(status?.bytes).toBe(10)
|
||||
expect(status?.finalizedAt).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
PREFERRED_MIME_TYPES,
|
||||
RECORDING_AUDIO_CONSTRAINTS,
|
||||
extensionForMimeType,
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
isValidRecordingId,
|
||||
pickRecorderMimeType,
|
||||
recordingDownloadName,
|
||||
} from '@/lib/recording'
|
||||
|
||||
describe('pickRecorderMimeType', () => {
|
||||
it('가장 앞선 후보를 고른다', () => {
|
||||
const picked = pickRecorderMimeType(() => true)
|
||||
expect(picked).toBe(PREFERRED_MIME_TYPES[0])
|
||||
})
|
||||
|
||||
it('지원하지 않는 후보는 건너뛴다', () => {
|
||||
const picked = pickRecorderMimeType((type) => type === 'audio/mp4')
|
||||
expect(picked).toBe('audio/mp4')
|
||||
})
|
||||
|
||||
it('아무것도 지원하지 않으면 null', () => {
|
||||
expect(pickRecorderMimeType(() => false)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extensionForMimeType', () => {
|
||||
it('코덱 파라미터를 떼고 판단한다', () => {
|
||||
expect(extensionForMimeType('audio/webm;codecs=opus')).toBe('webm')
|
||||
expect(extensionForMimeType('audio/ogg; codecs=opus')).toBe('ogg')
|
||||
})
|
||||
|
||||
it('주요 컨테이너를 매핑한다', () => {
|
||||
expect(extensionForMimeType('audio/webm')).toBe('webm')
|
||||
expect(extensionForMimeType('audio/mp4')).toBe('m4a')
|
||||
expect(extensionForMimeType('audio/mpeg')).toBe('mp3')
|
||||
expect(extensionForMimeType('audio/wav')).toBe('wav')
|
||||
})
|
||||
|
||||
it('대소문자를 가리지 않는다', () => {
|
||||
expect(extensionForMimeType('AUDIO/WEBM')).toBe('webm')
|
||||
})
|
||||
|
||||
it('모르는 형식은 bin으로 떨어진다', () => {
|
||||
expect(extensionForMimeType('application/octet-stream')).toBe('bin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidRecordingId', () => {
|
||||
const valid = 'a'.repeat(32)
|
||||
|
||||
it('32자 hex만 통과시킨다', () => {
|
||||
expect(isValidRecordingId(valid)).toBe(true)
|
||||
expect(isValidRecordingId('0123456789abcdef0123456789abcdef')).toBe(true)
|
||||
})
|
||||
|
||||
it('경로 조작 시도를 막는다', () => {
|
||||
expect(isValidRecordingId('../../etc/passwd')).toBe(false)
|
||||
expect(isValidRecordingId(`${valid}/../x`)).toBe(false)
|
||||
expect(isValidRecordingId('..')).toBe(false)
|
||||
expect(isValidRecordingId(`../${valid}`)).toBe(false)
|
||||
})
|
||||
|
||||
it('길이나 문자셋이 어긋나면 거부한다', () => {
|
||||
expect(isValidRecordingId('a'.repeat(31))).toBe(false)
|
||||
expect(isValidRecordingId('a'.repeat(33))).toBe(false)
|
||||
expect(isValidRecordingId('A'.repeat(32))).toBe(false)
|
||||
expect(isValidRecordingId('g'.repeat(32))).toBe(false)
|
||||
expect(isValidRecordingId('')).toBe(false)
|
||||
})
|
||||
|
||||
it('문자열이 아니면 거부한다', () => {
|
||||
expect(isValidRecordingId(null)).toBe(false)
|
||||
expect(isValidRecordingId(undefined)).toBe(false)
|
||||
expect(isValidRecordingId(123)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordingDownloadName', () => {
|
||||
const at = new Date(2026, 8, 8, 14, 5)
|
||||
|
||||
it('날짜와 제목을 붙인다', () => {
|
||||
expect(recordingDownloadName('주간 회의', at, 'audio/webm')).toBe(
|
||||
'20260908-1405_주간_회의.webm',
|
||||
)
|
||||
})
|
||||
|
||||
it('제목이 없으면 날짜만 쓴다', () => {
|
||||
expect(recordingDownloadName(' ', at, 'audio/webm')).toBe(
|
||||
'20260908-1405.webm',
|
||||
)
|
||||
})
|
||||
|
||||
it('파일명에 못 쓰는 문자를 지운다', () => {
|
||||
const name = recordingDownloadName('a/b\\c:d*e?f"g<h>i|j', at, 'audio/webm')
|
||||
expect(name).toBe('20260908-1405_abcdefghij.webm')
|
||||
expect(name).not.toMatch(/[\\/:*?"<>|]/)
|
||||
})
|
||||
|
||||
it('아주 긴 제목을 자른다', () => {
|
||||
const name = recordingDownloadName('가'.repeat(200), at, 'audio/webm')
|
||||
expect(name.length).toBeLessThan(90)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('단위를 바꿔가며 표기한다', () => {
|
||||
expect(formatBytes(512)).toBe('512B')
|
||||
expect(formatBytes(2048)).toBe('2KB')
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0MB')
|
||||
})
|
||||
|
||||
it('비정상 값은 0으로 떨어진다', () => {
|
||||
expect(formatBytes(-1)).toBe('0B')
|
||||
expect(formatBytes(NaN)).toBe('0B')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('한 시간 미만은 분:초', () => {
|
||||
expect(formatDuration(0)).toBe('00:00')
|
||||
expect(formatDuration(65_000)).toBe('01:05')
|
||||
expect(formatDuration(46 * 60_000 + 6_000)).toBe('46:06')
|
||||
})
|
||||
|
||||
it('한 시간 이상은 시:분:초', () => {
|
||||
expect(formatDuration(3_600_000)).toBe('1:00:00')
|
||||
expect(formatDuration(3_725_000)).toBe('1:02:05')
|
||||
})
|
||||
|
||||
it('음수는 0으로 본다', () => {
|
||||
expect(formatDuration(-5_000)).toBe('00:00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RECORDING_AUDIO_CONSTRAINTS', () => {
|
||||
it('원거리 화자를 지우는 전처리를 끈다', () => {
|
||||
// 46분 대면 회의에서 포착률이 10% 안팎에 그친 원인 중 하나.
|
||||
// 이 값이 다시 true로 돌아가면 회귀다.
|
||||
expect(RECORDING_AUDIO_CONSTRAINTS.noiseSuppression).toBe(false)
|
||||
expect(RECORDING_AUDIO_CONSTRAINTS.echoCancellation).toBe(false)
|
||||
})
|
||||
|
||||
it('조용한 화자를 끌어올리는 AGC는 남긴다', () => {
|
||||
expect(RECORDING_AUDIO_CONSTRAINTS.autoGainControl).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
LOCAL_SPEAKER,
|
||||
REMOTE_SPEAKER,
|
||||
diarizedSpeakerId,
|
||||
listAttendees,
|
||||
resolveSpeakerName,
|
||||
} from '@/lib/speakers'
|
||||
|
||||
describe('resolveSpeakerName', () => {
|
||||
it('기본 화자에 한국어 표기를 붙인다', () => {
|
||||
expect(resolveSpeakerName(LOCAL_SPEAKER)).toBe('나')
|
||||
expect(resolveSpeakerName(REMOTE_SPEAKER)).toBe('상대')
|
||||
})
|
||||
|
||||
it('사용자가 지정한 이름이 우선한다', () => {
|
||||
const names = { [REMOTE_SPEAKER]: '김지훈' }
|
||||
expect(resolveSpeakerName(REMOTE_SPEAKER, names)).toBe('김지훈')
|
||||
expect(resolveSpeakerName(LOCAL_SPEAKER, names)).toBe('나')
|
||||
})
|
||||
|
||||
it('공백뿐인 이름은 무시한다', () => {
|
||||
expect(resolveSpeakerName(LOCAL_SPEAKER, { [LOCAL_SPEAKER]: ' ' })).toBe('나')
|
||||
})
|
||||
|
||||
it('diarization 화자 번호를 한국어 표기로 바꾼다', () => {
|
||||
expect(resolveSpeakerName('spk_1')).toBe('화자 1')
|
||||
expect(resolveSpeakerName('spk_3')).toBe('화자 3')
|
||||
})
|
||||
|
||||
it('diarization 화자에도 지정 이름이 우선한다', () => {
|
||||
expect(resolveSpeakerName('spk_2', { spk_2: '박지연' })).toBe('박지연')
|
||||
})
|
||||
|
||||
it('그 밖의 식별자는 그대로 돌려준다', () => {
|
||||
expect(resolveSpeakerName('unknown')).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listAttendees', () => {
|
||||
it('중복을 제거하고 순서를 유지한다', () => {
|
||||
expect(
|
||||
listAttendees([REMOTE_SPEAKER, LOCAL_SPEAKER, REMOTE_SPEAKER]),
|
||||
).toEqual(['상대', '나'])
|
||||
})
|
||||
|
||||
it('지정된 이름을 반영한다', () => {
|
||||
expect(
|
||||
listAttendees([LOCAL_SPEAKER, REMOTE_SPEAKER], {
|
||||
[LOCAL_SPEAKER]: '배철승',
|
||||
[REMOTE_SPEAKER]: '김지훈',
|
||||
}),
|
||||
).toEqual(['배철승', '김지훈'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('diarizedSpeakerId', () => {
|
||||
it('0-based 번호를 1-based 식별자로 바꾼다', () => {
|
||||
expect(diarizedSpeakerId(0)).toBe('spk_1')
|
||||
expect(diarizedSpeakerId(3)).toBe('spk_4')
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,6 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
formatTranscriptChunks,
|
||||
mergeAdjacentChunks,
|
||||
mergeSpeakerChunks,
|
||||
assignSpeakers,
|
||||
type TranscriptChunk,
|
||||
} from '@/lib/transcript-formatter'
|
||||
|
||||
@@ -57,141 +55,3 @@ describe('mergeAdjacentChunks', () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('화자 표기', () => {
|
||||
it('speaker가 있으면 이름을 붙여 출력한다', () => {
|
||||
const out = formatTranscriptChunks([
|
||||
{ text: '안녕하세요', startTime: 0, endTime: 2, isFinal: true, speaker: 'local' },
|
||||
{ text: '네 반갑습니다', startTime: 3, endTime: 5, isFinal: true, speaker: 'remote' },
|
||||
])
|
||||
expect(out).toBe('[00:00] 나: 안녕하세요\n[00:03] 상대: 네 반갑습니다')
|
||||
})
|
||||
|
||||
it('지정된 화자 이름을 사용한다', () => {
|
||||
const out = formatTranscriptChunks(
|
||||
[{ text: '안녕하세요', startTime: 0, endTime: 2, isFinal: true, speaker: 'remote' }],
|
||||
{ remote: '김지훈' },
|
||||
)
|
||||
expect(out).toBe('[00:00] 김지훈: 안녕하세요')
|
||||
})
|
||||
|
||||
it('speaker가 없으면 기존 형식을 유지한다', () => {
|
||||
const out = formatTranscriptChunks([
|
||||
{ text: '안녕하세요', startTime: 0, endTime: 2, isFinal: true },
|
||||
])
|
||||
expect(out).toBe('[00:00] 안녕하세요')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeSpeakerChunks', () => {
|
||||
const local = [
|
||||
{ text: '먼저 말함', startTime: 1, endTime: 2, isFinal: true, speaker: 'local' },
|
||||
{ text: '나중에 말함', startTime: 9, endTime: 10, isFinal: true, speaker: 'local' },
|
||||
]
|
||||
const remote = [
|
||||
{ text: '중간에 답함', startTime: 4, endTime: 6, isFinal: true, speaker: 'remote' },
|
||||
]
|
||||
|
||||
it('여러 트랙을 시간순으로 합친다', () => {
|
||||
expect(mergeSpeakerChunks(local, remote).map((c) => c.text)).toEqual([
|
||||
'먼저 말함',
|
||||
'중간에 답함',
|
||||
'나중에 말함',
|
||||
])
|
||||
})
|
||||
|
||||
it('같은 시각이면 넘긴 순서를 유지한다', () => {
|
||||
const a = [{ text: 'A', startTime: 5, endTime: 6, isFinal: true, speaker: 'local' }]
|
||||
const b = [{ text: 'B', startTime: 5, endTime: 6, isFinal: true, speaker: 'remote' }]
|
||||
expect(mergeSpeakerChunks(a, b).map((c) => c.text)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('빈 트랙을 섞어도 동작한다', () => {
|
||||
expect(mergeSpeakerChunks([], remote, [])).toEqual(remote)
|
||||
expect(mergeSpeakerChunks()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeAdjacentChunks — 화자 경계', () => {
|
||||
it('화자가 다르면 간격이 좁아도 합치지 않는다', () => {
|
||||
const merged = mergeAdjacentChunks(
|
||||
[
|
||||
{ text: '그건', startTime: 0, endTime: 1, isFinal: true, speaker: 'local' },
|
||||
{ text: '어렵습니다', startTime: 1, endTime: 2, isFinal: true, speaker: 'remote' },
|
||||
],
|
||||
5,
|
||||
)
|
||||
expect(merged).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('같은 화자면 기존대로 합친다', () => {
|
||||
const merged = mergeAdjacentChunks(
|
||||
[
|
||||
{ text: '그건', startTime: 0, endTime: 1, isFinal: true, speaker: 'local' },
|
||||
{ text: '가능합니다', startTime: 1, endTime: 2, isFinal: true, speaker: 'local' },
|
||||
],
|
||||
5,
|
||||
)
|
||||
expect(merged).toHaveLength(1)
|
||||
expect(merged[0].text).toBe('그건 가능합니다')
|
||||
expect(merged[0].speaker).toBe('local')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assignSpeakers', () => {
|
||||
const id = (n: number) => `spk_${n + 1}`
|
||||
|
||||
const chunks: TranscriptChunk[] = [
|
||||
{ text: '안녕하세요', startTime: 0, endTime: 3, isFinal: true },
|
||||
{ text: '네 반갑습니다', startTime: 5, endTime: 8, isFinal: true },
|
||||
{ text: '그럼 시작하죠', startTime: 10, endTime: 12, isFinal: true },
|
||||
]
|
||||
|
||||
it('시간이 가장 많이 겹치는 화자를 붙인다', () => {
|
||||
const out = assignSpeakers(
|
||||
chunks,
|
||||
[
|
||||
{ start: 0, end: 4, speaker: 0 },
|
||||
{ start: 4.5, end: 9, speaker: 1 },
|
||||
{ start: 9.5, end: 13, speaker: 0 },
|
||||
],
|
||||
id,
|
||||
)
|
||||
expect(out.map((c) => c.speaker)).toEqual(['spk_1', 'spk_2', 'spk_1'])
|
||||
})
|
||||
|
||||
it('겹치는 구간이 없으면 화자를 비워 둔다', () => {
|
||||
const out = assignSpeakers(
|
||||
chunks,
|
||||
[{ start: 100, end: 110, speaker: 0 }],
|
||||
id,
|
||||
)
|
||||
expect(out.every((c) => c.speaker === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('부분적으로만 겹치면 더 많이 겹치는 쪽을 고른다', () => {
|
||||
const out = assignSpeakers(
|
||||
[{ text: '경계에 걸침', startTime: 0, endTime: 10, isFinal: true }],
|
||||
[
|
||||
{ start: 0, end: 3, speaker: 0 },
|
||||
{ start: 3, end: 10, speaker: 1 },
|
||||
],
|
||||
id,
|
||||
)
|
||||
expect(out[0].speaker).toBe('spk_2')
|
||||
})
|
||||
|
||||
it('segments가 비면 원본을 그대로 돌려준다', () => {
|
||||
const out = assignSpeakers(chunks, [], id)
|
||||
expect(out).toEqual(chunks)
|
||||
})
|
||||
|
||||
it('기존 화자 라벨은 새 결과로 덮어쓴다', () => {
|
||||
const out = assignSpeakers(
|
||||
[{ text: 'x', startTime: 0, endTime: 2, isFinal: true, speaker: 'spk_9' }],
|
||||
[{ start: 0, end: 2, speaker: 0 }],
|
||||
id,
|
||||
)
|
||||
expect(out[0].speaker).toBe('spk_1')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useAudioRecorder } from '@/hooks/useAudioRecorder'
|
||||
|
||||
/**
|
||||
* jsdom에는 MediaRecorder도 getUserMedia도 없다. 훅이 다루는 것은
|
||||
* "조각이 언제 오고 어디로 가는가"이므로 그 둘만 흉내 내면 충분하다.
|
||||
*/
|
||||
|
||||
class FakeMediaRecorder {
|
||||
static isTypeSupported = () => true
|
||||
|
||||
state: 'inactive' | 'recording' = 'inactive'
|
||||
ondataavailable: ((event: { data: Blob }) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
private listeners: Record<string, Array<() => void>> = {}
|
||||
|
||||
constructor(
|
||||
public stream: MediaStream,
|
||||
public options?: MediaRecorderOptions,
|
||||
) {
|
||||
instances.push(this)
|
||||
}
|
||||
|
||||
start() {
|
||||
this.state = 'recording'
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.state = 'inactive'
|
||||
// 실제 MediaRecorder는 stop() 시 남은 버퍼를 한 번 더 내보낸 뒤 stop을 쏜다.
|
||||
for (const fn of this.listeners.stop ?? []) fn()
|
||||
}
|
||||
|
||||
addEventListener(type: string, fn: () => void) {
|
||||
;(this.listeners[type] ??= []).push(fn)
|
||||
}
|
||||
|
||||
emit(bytes: number) {
|
||||
this.ondataavailable?.({ data: new Blob([new Uint8Array(bytes)]) })
|
||||
}
|
||||
}
|
||||
|
||||
let instances: FakeMediaRecorder[] = []
|
||||
let stopTracks: ReturnType<typeof vi.fn>
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200) {
|
||||
return { ok, status, json: async () => body }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
instances = []
|
||||
stopTracks = vi.fn()
|
||||
|
||||
vi.stubGlobal('MediaRecorder', FakeMediaRecorder)
|
||||
vi.stubGlobal('navigator', {
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(async () => ({
|
||||
getTracks: () => [{ stop: stopTracks }],
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
fetchMock = vi.fn(async (url: string) => {
|
||||
if (url === '/api/recordings') {
|
||||
return jsonResponse({ id: 'a'.repeat(32), mimeType: 'audio/webm' }, true, 201)
|
||||
}
|
||||
if (url.endsWith('/chunk')) return jsonResponse({ bytes: 1024 })
|
||||
return jsonResponse({ bytes: 1024 })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function startedHook() {
|
||||
const view = renderHook(() => useAudioRecorder())
|
||||
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
|
||||
await act(async () => {
|
||||
await view.result.current.startRecording()
|
||||
})
|
||||
return view
|
||||
}
|
||||
|
||||
describe('useAudioRecorder — 캡처 제약', () => {
|
||||
it('원거리 화자를 지우는 전처리를 끄고 마이크를 연다', async () => {
|
||||
await startedHook()
|
||||
|
||||
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({
|
||||
audio: expect.objectContaining({
|
||||
noiseSuppression: false,
|
||||
echoCancellation: false,
|
||||
autoGainControl: true,
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAudioRecorder — 조각 업로드', () => {
|
||||
it('조각이 생길 때마다 서버로 올린다', async () => {
|
||||
const view = await startedHook()
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(2048)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const chunkCalls = fetchMock.mock.calls.filter((c) =>
|
||||
String(c[0]).endsWith('/chunk'),
|
||||
)
|
||||
expect(chunkCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(view.result.current.localBytes).toBe(2048)
|
||||
})
|
||||
|
||||
it('빈 조각은 올리지 않는다', async () => {
|
||||
await startedHook()
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(0)
|
||||
})
|
||||
|
||||
const chunkCalls = fetchMock.mock.calls.filter((c) =>
|
||||
String(c[0]).endsWith('/chunk'),
|
||||
)
|
||||
expect(chunkCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('업로드가 끝내 실패하면 뒤 조각을 이어 붙이지 않고 경고한다', async () => {
|
||||
// 중간이 빈 파일은 짧은 파일보다 나쁘다 — 재생도 전사도 안 된다.
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url === '/api/recordings') {
|
||||
return jsonResponse({ id: 'a'.repeat(32) }, true, 201)
|
||||
}
|
||||
if (url.endsWith('/chunk')) return jsonResponse({ error: 'nope' }, false, 500)
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
const view = await startedHook()
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(1024)
|
||||
})
|
||||
|
||||
// 500은 일시 장애일 수 있으므로 백오프를 두고 3회까지 재시도한다.
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(view.result.current.uploadWarning).toContain('서버 저장이 중단')
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
|
||||
const callsAfterBreak = fetchMock.mock.calls.filter((c) =>
|
||||
String(c[0]).endsWith('/chunk'),
|
||||
).length
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(1024)
|
||||
})
|
||||
|
||||
expect(
|
||||
fetchMock.mock.calls.filter((c) => String(c[0]).endsWith('/chunk')).length,
|
||||
).toBe(callsAfterBreak)
|
||||
})
|
||||
|
||||
it('세션 생성이 실패해도 녹음은 계속된다', async () => {
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url === '/api/recordings') {
|
||||
return jsonResponse({ error: 'down' }, false, 500)
|
||||
}
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
const view = await startedHook()
|
||||
|
||||
expect(view.result.current.isRecording).toBe(true)
|
||||
expect(view.result.current.uploadWarning).toContain('내려받아')
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(4096)
|
||||
})
|
||||
expect(view.result.current.localBytes).toBe(4096)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAudioRecorder — 종료', () => {
|
||||
it('오디오를 한 조각도 못 받았으면 보관됐다고 말하지 않는다', async () => {
|
||||
const view = await startedHook()
|
||||
|
||||
await act(async () => {
|
||||
await view.result.current.stopRecording()
|
||||
})
|
||||
|
||||
expect(view.result.current.recording).toBeNull()
|
||||
expect(view.result.current.error).toContain('한 조각도 캡처되지 않았습니다')
|
||||
expect(view.result.current.isRecording).toBe(false)
|
||||
})
|
||||
|
||||
it('받은 조각이 있으면 결과를 넘기고 마이크를 놓는다', async () => {
|
||||
const view = await startedHook()
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(1024)
|
||||
})
|
||||
await act(async () => {
|
||||
await view.result.current.stopRecording()
|
||||
})
|
||||
|
||||
expect(view.result.current.recording?.blob.size).toBe(1024)
|
||||
expect(view.result.current.recording?.recordingId).toBe('a'.repeat(32))
|
||||
expect(stopTracks).toHaveBeenCalled()
|
||||
expect(view.result.current.isRecording).toBe(false)
|
||||
})
|
||||
|
||||
it('서버 사본이 로컬보다 짧으면 경고한다', async () => {
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url === '/api/recordings') {
|
||||
return jsonResponse({ id: 'a'.repeat(32) }, true, 201)
|
||||
}
|
||||
if (url.endsWith('/chunk')) return jsonResponse({ bytes: 10 })
|
||||
return jsonResponse({ bytes: 10 })
|
||||
})
|
||||
|
||||
const view = await startedHook()
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(4096)
|
||||
})
|
||||
await act(async () => {
|
||||
await view.result.current.stopRecording()
|
||||
})
|
||||
|
||||
expect(view.result.current.uploadWarning).toContain('서버 사본이 로컬보다 짧습니다')
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { appendAudio } from '@/lib/audio-session'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** 녹음 중 몇 초짜리 PCM 조각을 이어 붙인다. 본문은 16kHz 모노 Int16 raw PCM. */
|
||||
export async function POST(request: NextRequest) {
|
||||
const sessionId = request.nextUrl.searchParams.get('session')?.trim()
|
||||
if (!sessionId) {
|
||||
return Response.json({ error: 'session 파라미터가 필요합니다.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = await request.arrayBuffer()
|
||||
if (buffer.byteLength === 0) {
|
||||
return Response.json({ error: '오디오가 비어 있습니다.' }, { status: 400 })
|
||||
}
|
||||
if (buffer.byteLength % 2 !== 0) {
|
||||
return Response.json(
|
||||
{ error: 'Int16 PCM이 아닙니다 (홀수 바이트).' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const result = appendAudio(sessionId, new Int16Array(buffer))
|
||||
if (!result.ok) {
|
||||
return Response.json({ error: result.error }, { status: 413 })
|
||||
}
|
||||
|
||||
return Response.json({ seconds: Math.round(result.seconds) })
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { clearSession, collectSamples, sessionSeconds } from '@/lib/audio-session'
|
||||
import { diarize, modelsInstalled } from '@/lib/diarization'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({ available: modelsInstalled() })
|
||||
}
|
||||
|
||||
/**
|
||||
* 누적된 오디오 전체에 화자 분리를 돌린다.
|
||||
*
|
||||
* 창을 잘라 반복 실행하면 실행마다 화자 번호가 달라져 이어 붙일 수 없다.
|
||||
* 전역 클러스터링을 위해 녹음 종료 시 한 번만 돌린다.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const sessionId = typeof body.session === 'string' ? body.session.trim() : ''
|
||||
|
||||
if (!sessionId) {
|
||||
return Response.json({ error: 'session이 필요합니다.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const samples = collectSamples(sessionId)
|
||||
if (!samples) {
|
||||
return Response.json({ error: '누적된 오디오가 없습니다.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const numSpeakers =
|
||||
typeof body.numSpeakers === 'number' && body.numSpeakers > 1
|
||||
? Math.floor(body.numSpeakers)
|
||||
: undefined
|
||||
|
||||
const seconds = sessionSeconds(sessionId)
|
||||
const result = await diarize(samples, { numSpeakers })
|
||||
|
||||
if (!result.success) {
|
||||
const status = result.reason === 'models-missing' ? 503 : 500
|
||||
return Response.json({ error: result.error, reason: result.reason }, { status })
|
||||
}
|
||||
|
||||
clearSession(sessionId)
|
||||
|
||||
return Response.json({
|
||||
segments: result.segments,
|
||||
seconds: Math.round(seconds),
|
||||
speakers: new Set(result.segments.map((s) => s.speaker)).size,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return Response.json({ error: `화자 분리 실패: ${message}` }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,9 @@ export async function POST(request: NextRequest) {
|
||||
customPrompt,
|
||||
attendees,
|
||||
tags,
|
||||
audioFileName,
|
||||
audioMimeType,
|
||||
audioDuration,
|
||||
} = body
|
||||
|
||||
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||
@@ -112,6 +115,14 @@ export async function POST(request: NextRequest) {
|
||||
tags: Array.isArray(tags)
|
||||
? tags.filter((t) => typeof t === 'string')
|
||||
: [],
|
||||
audioFileName:
|
||||
typeof audioFileName === 'string' ? audioFileName : null,
|
||||
audioMimeType:
|
||||
typeof audioMimeType === 'string' ? audioMimeType : null,
|
||||
audioDuration:
|
||||
typeof audioDuration === 'number' && Number.isFinite(audioDuration)
|
||||
? Math.max(0, Math.round(audioDuration))
|
||||
: null,
|
||||
status: 'COMPLETED',
|
||||
actionItems: {
|
||||
create: parsed.map((item, index) => ({
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { appendChunk } from '@/lib/recording-store'
|
||||
import { MAX_CHUNK_BYTES, isValidRecordingId } from '@/lib/recording'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* 녹음 조각 하나를 이어 붙인다.
|
||||
*
|
||||
* 본문은 MediaRecorder가 준 바이트 그대로다. JSON이나 multipart로 감싸면
|
||||
* base64 팽창이나 파싱 비용만 늘어난다.
|
||||
*/
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { id } = await context.params
|
||||
|
||||
if (!isValidRecordingId(id)) {
|
||||
return Response.json(
|
||||
{ error: '잘못된 녹음 세션 ID입니다.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await request.arrayBuffer())
|
||||
|
||||
if (buffer.byteLength === 0) {
|
||||
return Response.json({ error: '빈 조각입니다.' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (buffer.byteLength > MAX_CHUNK_BYTES) {
|
||||
return Response.json(
|
||||
{ error: '조각이 너무 큽니다.' },
|
||||
{ status: 413 },
|
||||
)
|
||||
}
|
||||
|
||||
const result = await appendChunk(id, buffer)
|
||||
|
||||
if (!result.ok) {
|
||||
return Response.json({ error: result.error }, { status: result.status })
|
||||
}
|
||||
|
||||
return Response.json({ bytes: result.bytes })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return Response.json(
|
||||
{ error: `녹음 조각 저장 실패: ${message}` },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { Readable } from 'stream'
|
||||
import { finalizeRecording, getRecordingStatus, openRecording } from '@/lib/recording-store'
|
||||
import { extensionForMimeType, isValidRecordingId } from '@/lib/recording'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
/** 녹음 파일 내려받기. 헤더에는 사용자 입력을 넣지 않는다. */
|
||||
export async function GET(_request: NextRequest, context: RouteContext) {
|
||||
const { id } = await context.params
|
||||
|
||||
if (!isValidRecordingId(id)) {
|
||||
return Response.json({ error: '잘못된 녹음 세션 ID입니다.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const opened = await openRecording(id)
|
||||
if (!opened) {
|
||||
return Response.json({ error: '녹음을 찾을 수 없습니다.' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { meta, bytes, stream } = opened
|
||||
const stamp = meta.startedAt.slice(0, 16).replace(/[-:]/g, '').replace('T', '-')
|
||||
const fileName = `meeting-${stamp}.${extensionForMimeType(meta.mimeType)}`
|
||||
|
||||
return new Response(Readable.toWeb(stream as Readable) as ReadableStream, {
|
||||
headers: {
|
||||
'Content-Type': meta.mimeType,
|
||||
'Content-Length': String(bytes),
|
||||
'Content-Disposition': `attachment; filename="${fileName}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 녹음 종료. 길이를 확정하고 최종 상태를 돌려준다. */
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { id } = await context.params
|
||||
|
||||
if (!isValidRecordingId(id)) {
|
||||
return Response.json(
|
||||
{ error: '잘못된 녹음 세션 ID입니다.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const durationMs =
|
||||
typeof body.durationMs === 'number' ? body.durationMs : null
|
||||
|
||||
const status = await finalizeRecording(id, durationMs)
|
||||
if (!status) {
|
||||
return Response.json(
|
||||
{ error: '녹음을 찾을 수 없습니다.' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
return Response.json(status)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return Response.json(
|
||||
{ error: `녹음 종료 처리 실패: ${message}` },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 현재까지 저장된 크기 확인용. */
|
||||
export async function HEAD(_request: NextRequest, context: RouteContext) {
|
||||
const { id } = await context.params
|
||||
|
||||
if (!isValidRecordingId(id)) return new Response(null, { status: 400 })
|
||||
|
||||
const status = await getRecordingStatus(id)
|
||||
if (!status) return new Response(null, { status: 404 })
|
||||
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(status.bytes) },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { createRecording } from '@/lib/recording-store'
|
||||
import { PREFERRED_MIME_TYPES } from '@/lib/recording'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** 브라우저가 고른 mimeType만 허용한다. 임의 문자열이 확장자로 새어들면 안 된다. */
|
||||
const ALLOWED_MIME_TYPES: readonly string[] = PREFERRED_MIME_TYPES
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { mimeType } = body
|
||||
|
||||
if (typeof mimeType !== 'string' || !ALLOWED_MIME_TYPES.includes(mimeType)) {
|
||||
return Response.json(
|
||||
{ error: '지원하지 않는 오디오 형식입니다.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const meta = await createRecording(mimeType)
|
||||
return Response.json(meta, { status: 201 })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return Response.json(
|
||||
{ error: `녹음 세션 생성 실패: ${message}` },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 : '알 수 없는 오류'
|
||||
|
||||
+92
-5
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
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 {
|
||||
@@ -43,6 +46,9 @@ export default function HomePage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
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(
|
||||
() => [
|
||||
@@ -77,6 +83,7 @@ export default function HomePage() {
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setUploadedFile(file)
|
||||
setUploadedRecordingId(null)
|
||||
setError(null)
|
||||
|
||||
const formData = new FormData()
|
||||
@@ -93,6 +100,7 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
if (!title) setTitle(data.title)
|
||||
setUploadedRecordingId(data.recordingId ?? null)
|
||||
} catch {
|
||||
setError('파일 업로드에 실패했습니다.')
|
||||
}
|
||||
@@ -146,6 +154,37 @@ export default function HomePage() {
|
||||
generateMinutes(text, summaryMode)
|
||||
}
|
||||
|
||||
const handleRecordingReady = useCallback((recording: CompletedRecording) => {
|
||||
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
|
||||
? {
|
||||
audioFileName: `${audio.recordingId}.${extensionForMimeType(audio.mimeType)}`,
|
||||
audioMimeType: audio.mimeType,
|
||||
audioDuration: Math.round(audio.durationMs / 1000),
|
||||
}
|
||||
: undefined
|
||||
|
||||
const liveSummaryActive =
|
||||
summaryMode === 'gemini' && activeTemplateMeta.live && tab === 'record'
|
||||
|
||||
@@ -188,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">
|
||||
@@ -328,6 +383,10 @@ export default function HomePage() {
|
||||
{tab === 'record' ? (
|
||||
<LiveRecorder
|
||||
onTranscriptReady={handleTranscriptReady}
|
||||
onRecordingReady={handleRecordingReady}
|
||||
onTranscriptReplaced={setTranscript}
|
||||
title={title}
|
||||
vocabularyHint={attendees}
|
||||
liveSummaryEnabled={liveSummaryActive}
|
||||
template={template}
|
||||
depth={depth}
|
||||
@@ -352,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>
|
||||
)}
|
||||
|
||||
@@ -391,6 +477,7 @@ export default function HomePage() {
|
||||
depth={depth}
|
||||
customPrompt={template === 'custom' ? customPrompt : undefined}
|
||||
summaryMode={summaryMode}
|
||||
audio={audioMeta}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -22,6 +22,12 @@ interface MinutesViewerProps {
|
||||
depth?: SummaryDepth
|
||||
customPrompt?: string
|
||||
summaryMode?: 'simple' | 'gemini'
|
||||
/** 이 회의록의 원본 오디오. 서버에 보관된 녹음이 있을 때만 붙는다. */
|
||||
audio?: {
|
||||
audioFileName: string
|
||||
audioMimeType: string
|
||||
audioDuration: number
|
||||
}
|
||||
}
|
||||
|
||||
type SaveState =
|
||||
@@ -38,6 +44,7 @@ export function MinutesViewer({
|
||||
template,
|
||||
depth,
|
||||
customPrompt,
|
||||
audio,
|
||||
summaryMode,
|
||||
}: MinutesViewerProps) {
|
||||
const [renderedView, setRenderedView] = useState<'rendered' | 'raw'>(
|
||||
@@ -110,6 +117,7 @@ export function MinutesViewer({
|
||||
template,
|
||||
depth,
|
||||
customPrompt,
|
||||
...audio,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||
import { useDualStreamCapture } from '@/hooks/useDualStreamCapture'
|
||||
import { useDiarization } from '@/hooks/useDiarization'
|
||||
import { useLiveSummary } from '@/hooks/useLiveSummary'
|
||||
import {
|
||||
assignSpeakers,
|
||||
formatTranscriptChunks,
|
||||
mergeSpeakerChunks,
|
||||
type TimeSpan,
|
||||
} from '@/lib/transcript-formatter'
|
||||
import {
|
||||
LOCAL_SPEAKER,
|
||||
REMOTE_SPEAKER,
|
||||
diarizedSpeakerId,
|
||||
resolveSpeakerName,
|
||||
} from '@/lib/speakers'
|
||||
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'
|
||||
|
||||
type SeparationMode = 'off' | 'remote' | 'in-person'
|
||||
|
||||
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
|
||||
@@ -31,68 +25,48 @@ interface LiveRecorderProps {
|
||||
|
||||
export function LiveRecorder({
|
||||
onTranscriptReady,
|
||||
onRecordingReady,
|
||||
onTranscriptReplaced,
|
||||
title,
|
||||
vocabularyHint,
|
||||
liveSummaryEnabled,
|
||||
template,
|
||||
depth,
|
||||
customPrompt,
|
||||
}: LiveRecorderProps) {
|
||||
const [mode, setMode] = useState<SeparationMode>('off')
|
||||
const [attendeeCount, setAttendeeCount] = useState(2)
|
||||
const [pendingStart, setPendingStart] = useState(false)
|
||||
const [speakerNames, setSpeakerNames] = useState<Record<string, string>>({})
|
||||
const [segments, setSegments] = useState<TimeSpan[] | null>(null)
|
||||
const timeOriginRef = useRef(0)
|
||||
|
||||
const capture = useDualStreamCapture()
|
||||
const diarization = useDiarization()
|
||||
|
||||
const speakerSeparation = mode !== 'off'
|
||||
|
||||
// 화자 분리를 켜면 트랙마다 인식기를 붙인다. 트랙이 곧 화자가 되므로
|
||||
// 추론 없이 화자가 갈린다.
|
||||
// 두 트랙을 실제로 확보했을 때만 화자를 라벨링한다.
|
||||
// 마이크만 잡힌 상태에서 라벨을 붙이면 상대 발언이 내 것으로 기록된다.
|
||||
const isSeparating = capture.mode === 'dual-stream'
|
||||
|
||||
const local = useSpeechRecognition({
|
||||
audioTrack: speakerSeparation ? capture.micTrack : null,
|
||||
speaker: isSeparating ? LOCAL_SPEAKER : undefined,
|
||||
timeOrigin: timeOriginRef.current || undefined,
|
||||
})
|
||||
|
||||
const remote = useSpeechRecognition({
|
||||
audioTrack: capture.systemTrack,
|
||||
speaker: REMOTE_SPEAKER,
|
||||
timeOrigin: timeOriginRef.current || undefined,
|
||||
})
|
||||
|
||||
const {
|
||||
isListening,
|
||||
isSupported,
|
||||
chunks,
|
||||
interimText,
|
||||
error: recognitionError,
|
||||
startListening,
|
||||
} = local
|
||||
stopListening,
|
||||
resetChunks,
|
||||
} = useSpeechRecognition()
|
||||
|
||||
const chunks = useMemo(() => {
|
||||
if (isSeparating) return mergeSpeakerChunks(local.chunks, remote.chunks)
|
||||
if (segments) return assignSpeakers(local.chunks, segments, diarizedSpeakerId)
|
||||
return local.chunks
|
||||
}, [isSeparating, segments, local.chunks, remote.chunks])
|
||||
const {
|
||||
isRecording,
|
||||
isSupported: isRecordingSupported,
|
||||
error: recordingError,
|
||||
uploadWarning,
|
||||
elapsedMs,
|
||||
localBytes,
|
||||
uploadedBytes,
|
||||
recording,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
downloadRecording,
|
||||
reset: resetRecording,
|
||||
} = useAudioRecorder()
|
||||
|
||||
// 캡처가 끝나 트랙이 준비되면 그때 인식기를 시작한다.
|
||||
useEffect(() => {
|
||||
if (!pendingStart) return
|
||||
if (capture.mode === 'idle') return
|
||||
|
||||
setPendingStart(false)
|
||||
local.startListening()
|
||||
if (capture.systemTrack) remote.startListening()
|
||||
if (mode === 'in-person' && capture.micTrack) {
|
||||
diarization.start(capture.micTrack)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pendingStart, capture.mode, capture.systemTrack, capture.micTrack])
|
||||
const {
|
||||
isTranscribing,
|
||||
error: transcriptionError,
|
||||
result: transcription,
|
||||
transcribeRecording,
|
||||
reset: resetTranscription,
|
||||
} = useTranscription()
|
||||
|
||||
const {
|
||||
summary,
|
||||
@@ -121,6 +95,11 @@ export function LiveRecorder({
|
||||
summaryEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}, [summary])
|
||||
|
||||
// 녹음이 끝나 파일이 확정되면 상위로 올려 회의록과 함께 저장되게 한다.
|
||||
useEffect(() => {
|
||||
if (recording) onRecordingReady?.(recording)
|
||||
}, [recording, onRecordingReady])
|
||||
|
||||
if (isSupported === null) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-neutral-200 bg-neutral-50 p-6 text-center text-sm text-neutral-400">
|
||||
@@ -141,45 +120,22 @@ export function LiveRecorder({
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
timeOriginRef.current = Date.now()
|
||||
setSegments(null)
|
||||
|
||||
if (mode === 'off') {
|
||||
startListening()
|
||||
return
|
||||
}
|
||||
|
||||
setPendingStart(true)
|
||||
await capture.start({ withSystemAudio: mode === 'remote' })
|
||||
resetRecording()
|
||||
resetTranscription()
|
||||
// 오디오 녹음을 먼저 건다. 전사가 실패하더라도 원본은 남아야 한다.
|
||||
await startRecording()
|
||||
startListening()
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
local.stopListening()
|
||||
remote.stopListening()
|
||||
stopListening()
|
||||
|
||||
// 대면 모드는 녹음이 끝난 뒤 전체 오디오에 한 번 화자 분리를 돌린다.
|
||||
let finalChunks = chunks
|
||||
if (mode === 'in-person') {
|
||||
const result = await diarization.finish(attendeeCount)
|
||||
if (result.length > 0) {
|
||||
setSegments(result)
|
||||
finalChunks = assignSpeakers(local.chunks, result, diarizedSpeakerId)
|
||||
}
|
||||
}
|
||||
|
||||
capture.stop()
|
||||
|
||||
const transcript = formatTranscriptChunks(finalChunks, speakerNames)
|
||||
const transcript = formatTranscriptChunks(chunks)
|
||||
if (transcript.length > 0) {
|
||||
onTranscriptReady(transcript)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
local.resetChunks()
|
||||
remote.resetChunks()
|
||||
diarization.reset()
|
||||
setSegments(null)
|
||||
await stopRecording()
|
||||
}
|
||||
|
||||
const lastUpdatedLabel = lastUpdatedAt
|
||||
@@ -240,9 +196,37 @@ 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)}
|
||||
className="flex items-center gap-2 rounded-full border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
⬇️ 오디오 내려받기 ({formatBytes(recording.blob.size)})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{chunks.length > 0 && !isListening && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
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"
|
||||
>
|
||||
초기화
|
||||
@@ -256,145 +240,35 @@ export function LiveRecorder({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isListening && capture.mode === 'dual-stream' && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-purple-50 px-4 py-1.5 text-xs text-purple-700">
|
||||
<span className="h-2 w-2 rounded-full bg-purple-500" />
|
||||
화자 분리 중
|
||||
{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>
|
||||
오디오 {formatDuration(elapsedMs)} · 저장 {formatBytes(uploadedBytes)}
|
||||
{localBytes > 0 && uploadedBytes < localBytes && (
|
||||
<span className="text-amber-600">
|
||||
(전송 대기 {formatBytes(localBytes - uploadedBytes)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isListening && chunks.length === 0 && (
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-4">
|
||||
<p className="mb-3 text-sm font-medium text-neutral-800">화자 분리</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
{ id: 'off', label: '사용 안 함' },
|
||||
{ id: 'remote', label: '원격 회의' },
|
||||
{ id: 'in-person', label: '대면 회의' },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMode(option.id)}
|
||||
disabled={option.id === 'in-person' && diarization.available === false}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-all disabled:opacity-40 ${
|
||||
mode === option.id
|
||||
? 'border-purple-400 bg-purple-50 text-purple-700'
|
||||
: 'border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === 'remote' && (
|
||||
<>
|
||||
<p className="mt-3 text-xs text-neutral-500">
|
||||
내 마이크와 상대 목소리를 별도 트랙으로 받아 구분합니다. 추론이 없어
|
||||
정확합니다. 시작 시{' '}
|
||||
<strong>화면 공유 대화상자에서 “탭 오디오 공유”를 반드시 켜주세요.</strong>
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-amber-700">
|
||||
노트북 한 대를 두고 마주 앉은 대면 회의에서는 동작하지 않습니다.
|
||||
그 경우 “대면 회의”를 선택하세요.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
{ id: LOCAL_SPEAKER, label: '내 이름', placeholder: '나' },
|
||||
{ id: REMOTE_SPEAKER, label: '상대 이름', placeholder: '상대' },
|
||||
] as const
|
||||
).map((field) => (
|
||||
<label key={field.id} className="block">
|
||||
<span className="mb-1 block text-xs text-neutral-500">
|
||||
{field.label}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={speakerNames[field.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setSpeakerNames((prev) => ({
|
||||
...prev,
|
||||
[field.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'in-person' && (
|
||||
<>
|
||||
<p className="mt-3 text-xs text-neutral-500">
|
||||
마이크 하나에 섞여 들어온 목소리를 내 PC에서 분석해 화자를 나눕니다.
|
||||
외부로 오디오를 보내지 않습니다.{' '}
|
||||
<strong>녹음이 끝난 뒤 한 번에 분석</strong>하므로 실시간 화자 표시는 없습니다.
|
||||
</p>
|
||||
<label className="mt-4 block max-w-40">
|
||||
<span className="mb-1 block text-xs text-neutral-500">참석자 수</span>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={8}
|
||||
value={attendeeCount}
|
||||
onChange={(e) =>
|
||||
setAttendeeCount(Math.max(2, Math.min(8, Number(e.target.value) || 2)))
|
||||
}
|
||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-neutral-500">
|
||||
정확한 수를 넣을수록 결과가 좋아집니다. 자동 추정은 화자 수를 잘못 세는
|
||||
경우가 많습니다.
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{diarization.available === false && (
|
||||
<p className="mt-3 text-xs text-amber-700">
|
||||
대면 회의 화자 분리를 쓰려면 모델이 필요합니다 —{' '}
|
||||
<code className="rounded bg-amber-50 px-1">npm run setup:diarization</code>{' '}
|
||||
을 실행한 뒤 서버를 다시 시작하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diarization.status.state === 'analyzing' && (
|
||||
<div className="rounded-xl border border-purple-200 bg-purple-50 p-4 text-sm text-purple-700">
|
||||
화자를 분석하는 중입니다... 회의가 길수록 시간이 걸립니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diarization.status.state === 'done' && (
|
||||
<div className="rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-700">
|
||||
화자 {diarization.status.speakers}명을 구분했습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diarization.status.state === 'error' && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<strong>화자 분리 오류:</strong> {diarization.status.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{capture.error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<strong>오디오 캡처 오류:</strong> {capture.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{capture.warning && (
|
||||
{isRecordingSupported === false && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
⚠️ {capture.warning}
|
||||
이 브라우저는 오디오 녹음을 지원하지 않습니다. 전사만 진행되며,
|
||||
<strong> 놓친 발화를 나중에 복구할 수 없습니다.</strong> Chrome을 사용해주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recordingError && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<strong>오디오 녹음 오류:</strong> {recordingError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadWarning && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
<strong>⚠️ 서버 저장 문제:</strong> {uploadWarning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -404,6 +278,39 @@ 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>
|
||||
🎧 오디오 {formatDuration(recording.durationMs)} ·{' '}
|
||||
{formatBytes(recording.blob.size)} 확보
|
||||
</strong>
|
||||
{' — '}
|
||||
{recording.recordingId
|
||||
? `서버에 ${formatBytes(recording.uploadedBytes)} 저장됨.`
|
||||
: '서버 저장 실패 — 브라우저 사본만 있습니다.'}{' '}
|
||||
전사가 놓친 발화는 이 오디오로 다시 살릴 수 있습니다.
|
||||
{!recording.recordingId && (
|
||||
<strong> 지금 내려받아 보관해주세요.</strong>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isListening && !hasTranscript && (
|
||||
<div className="rounded-2xl border border-dashed border-blue-300 bg-blue-50/50 p-6 text-center">
|
||||
<p className="text-sm text-blue-700">
|
||||
@@ -441,20 +348,7 @@ export function LiveRecorder({
|
||||
{hasTranscript ? (
|
||||
<div className="space-y-1 text-sm font-mono leading-relaxed text-neutral-700">
|
||||
{chunks.map((chunk, i) => (
|
||||
<p key={i}>
|
||||
{chunk.speaker && (
|
||||
<span
|
||||
className={`mr-1.5 font-semibold ${
|
||||
chunk.speaker === LOCAL_SPEAKER
|
||||
? 'text-purple-600'
|
||||
: 'text-teal-600'
|
||||
}`}
|
||||
>
|
||||
{resolveSpeakerName(chunk.speaker, speakerNames)}:
|
||||
</span>
|
||||
)}
|
||||
{chunk.text}
|
||||
</p>
|
||||
<p key={i}>{chunk.text}</p>
|
||||
))}
|
||||
{interimText && (
|
||||
<p className="text-neutral-400 italic">
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
AUDIO_BITS_PER_SECOND,
|
||||
CHUNK_INTERVAL_MS,
|
||||
RECORDING_AUDIO_CONSTRAINTS,
|
||||
pickRecorderMimeType,
|
||||
recordingDownloadName,
|
||||
} from '@/lib/recording'
|
||||
|
||||
export interface CompletedRecording {
|
||||
/** 서버 세션 id. 세션 생성에 실패했으면 null이고 로컬 사본만 있다. */
|
||||
recordingId: string | null
|
||||
mimeType: string
|
||||
blob: Blob
|
||||
durationMs: number
|
||||
startedAt: Date
|
||||
/** 서버에 실제로 저장된 바이트. 0이거나 blob보다 작으면 일부가 못 올라갔다. */
|
||||
uploadedBytes: number
|
||||
}
|
||||
|
||||
export interface AudioRecorderHook {
|
||||
isRecording: boolean
|
||||
isSupported: boolean | null
|
||||
/** 녹음을 시작조차 못하게 만든 오류. */
|
||||
error: string | null
|
||||
/** 녹음은 되고 있으나 서버 사본에 문제가 있을 때의 경고. */
|
||||
uploadWarning: string | null
|
||||
elapsedMs: number
|
||||
localBytes: number
|
||||
uploadedBytes: number
|
||||
recording: CompletedRecording | null
|
||||
startRecording: () => Promise<void>
|
||||
stopRecording: () => Promise<void>
|
||||
downloadRecording: (title: string) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const MAX_CHUNK_RETRIES = 3
|
||||
|
||||
function describeCaptureError(err: unknown): string {
|
||||
const name = err instanceof Error ? err.name : ''
|
||||
|
||||
switch (name) {
|
||||
case 'NotAllowedError':
|
||||
case 'SecurityError':
|
||||
return '마이크 권한이 거부되어 녹음할 수 없습니다. 주소창 왼쪽 자물쇠 아이콘에서 마이크를 허용해주세요.'
|
||||
case 'NotFoundError':
|
||||
return '마이크를 찾을 수 없습니다. 장치가 연결되어 있는지 확인해주세요.'
|
||||
case 'NotReadableError':
|
||||
return '다른 프로그램이 마이크를 사용 중입니다. 해당 프로그램을 종료하고 다시 시도해주세요.'
|
||||
default:
|
||||
return err instanceof Error
|
||||
? `마이크를 열 수 없습니다: ${err.message}`
|
||||
: '마이크를 열 수 없습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의 오디오를 파일로 남긴다.
|
||||
*
|
||||
* 전사와 독립적으로 동작한다. Web Speech가 발화를 놓치든 네트워크가 끊기든
|
||||
* 오디오만은 남아야 나중에 서버 STT로 다시 살릴 수 있다. 그래서 서버 업로드가
|
||||
* 실패해도 녹음을 중단하지 않고, 브라우저 안의 사본을 끝까지 들고 간다.
|
||||
*/
|
||||
export function useAudioRecorder(): AudioRecorderHook {
|
||||
const [isSupported, setIsSupported] = useState<boolean | null>(null)
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploadWarning, setUploadWarning] = useState<string | null>(null)
|
||||
const [elapsedMs, setElapsedMs] = useState(0)
|
||||
const [localBytes, setLocalBytes] = useState(0)
|
||||
const [uploadedBytes, setUploadedBytes] = useState(0)
|
||||
const [recording, setRecording] = useState<CompletedRecording | null>(null)
|
||||
|
||||
const recorderRef = useRef<MediaRecorder | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const partsRef = useRef<Blob[]>([])
|
||||
const sessionIdRef = useRef<string | null>(null)
|
||||
const mimeTypeRef = useRef<string | null>(null)
|
||||
const startedAtRef = useRef<Date | null>(null)
|
||||
const uploadChainRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const uploadBrokenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsSupported(
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.MediaRecorder !== 'undefined' &&
|
||||
typeof navigator !== 'undefined' &&
|
||||
!!navigator.mediaDevices?.getUserMedia,
|
||||
)
|
||||
}, [])
|
||||
|
||||
// 녹음 중에는 1초마다 경과 시간을 갱신한다. 이 시각이 나중에 전사 청크를
|
||||
// 오디오 타임라인에 맞추는 기준이 된다.
|
||||
useEffect(() => {
|
||||
if (!isRecording) return
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (startedAtRef.current) {
|
||||
setElapsedMs(Date.now() - startedAtRef.current.getTime())
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [isRecording])
|
||||
|
||||
// 녹음 중 탭을 닫으면 아직 못 올린 조각이 사라진다. 확인을 한 번 받는다.
|
||||
useEffect(() => {
|
||||
if (!isRecording) return
|
||||
|
||||
function warn(event: BeforeUnloadEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', warn)
|
||||
return () => window.removeEventListener('beforeunload', warn)
|
||||
}, [isRecording])
|
||||
|
||||
/**
|
||||
* 조각을 순서대로 올린다.
|
||||
*
|
||||
* 순서가 곧 파일의 순서이므로 병렬로 보내지 않는다. 한 조각이 끝내 실패하면
|
||||
* 그 뒤를 이어 붙여봐야 중간이 비어 재생도 전사도 안 되는 파일이 되므로,
|
||||
* 그 시점에 업로드를 멈추고 로컬 사본을 쓰라고 알린다.
|
||||
*/
|
||||
const queueUpload = useCallback((chunk: Blob) => {
|
||||
const id = sessionIdRef.current
|
||||
if (!id || uploadBrokenRef.current) return
|
||||
|
||||
uploadChainRef.current = uploadChainRef.current.then(async () => {
|
||||
if (uploadBrokenRef.current) return
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_CHUNK_RETRIES; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`/api/recordings/${id}/chunk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: chunk,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUploadedBytes(data.bytes)
|
||||
return
|
||||
}
|
||||
|
||||
// 4xx는 재시도해도 같은 답이 온다.
|
||||
if (res.status >= 400 && res.status < 500) break
|
||||
} catch {
|
||||
// 네트워크 오류 — 아래에서 재시도한다.
|
||||
}
|
||||
|
||||
if (attempt < MAX_CHUNK_RETRIES) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * attempt))
|
||||
}
|
||||
}
|
||||
|
||||
uploadBrokenRef.current = true
|
||||
setUploadWarning(
|
||||
'서버 저장이 중단되었습니다. 녹음은 계속되고 있으니 종료 후 반드시 오디오를 내려받아 주세요.',
|
||||
)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (recorderRef.current) return
|
||||
|
||||
setError(null)
|
||||
setUploadWarning(null)
|
||||
setRecording(null)
|
||||
setElapsedMs(0)
|
||||
setLocalBytes(0)
|
||||
setUploadedBytes(0)
|
||||
partsRef.current = []
|
||||
sessionIdRef.current = null
|
||||
uploadBrokenRef.current = false
|
||||
uploadChainRef.current = Promise.resolve()
|
||||
|
||||
if (isSupported !== true) {
|
||||
setError('이 브라우저는 오디오 녹음을 지원하지 않습니다. Chrome을 사용해주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
const mimeType = pickRecorderMimeType((type) =>
|
||||
MediaRecorder.isTypeSupported(type),
|
||||
)
|
||||
if (!mimeType) {
|
||||
setError('브라우저가 지원하는 녹음 형식을 찾지 못했습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
let stream: MediaStream
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: RECORDING_AUDIO_CONSTRAINTS,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(describeCaptureError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 서버 세션은 있으면 좋고 없어도 녹음은 간다. 여기서 포기하면
|
||||
// 오디오를 남기려던 목적 자체를 잃는다.
|
||||
try {
|
||||
const res = await fetch('/api/recordings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mimeType }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const meta = await res.json()
|
||||
sessionIdRef.current = meta.id
|
||||
} else {
|
||||
uploadBrokenRef.current = true
|
||||
setUploadWarning(
|
||||
'서버에 녹음 세션을 만들지 못했습니다. 브라우저에만 저장되니 종료 후 반드시 내려받아 주세요.',
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
uploadBrokenRef.current = true
|
||||
setUploadWarning(
|
||||
'서버에 연결하지 못했습니다. 브라우저에만 저장되니 종료 후 반드시 내려받아 주세요.',
|
||||
)
|
||||
}
|
||||
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
audioBitsPerSecond: AUDIO_BITS_PER_SECOND,
|
||||
})
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size === 0) return
|
||||
partsRef.current.push(event.data)
|
||||
setLocalBytes((prev) => prev + event.data.size)
|
||||
queueUpload(event.data)
|
||||
}
|
||||
|
||||
recorder.onerror = () => {
|
||||
setError('녹음 중 오류가 발생했습니다. 지금까지의 오디오는 보존되어 있습니다.')
|
||||
}
|
||||
|
||||
streamRef.current = stream
|
||||
recorderRef.current = recorder
|
||||
mimeTypeRef.current = mimeType
|
||||
startedAtRef.current = new Date()
|
||||
|
||||
recorder.start(CHUNK_INTERVAL_MS)
|
||||
setIsRecording(true)
|
||||
}, [isSupported, queueUpload])
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
const recorder = recorderRef.current
|
||||
if (!recorder) return
|
||||
recorderRef.current = null
|
||||
|
||||
// stop()은 남은 버퍼를 dataavailable로 한 번 더 내보낸 뒤 stop을 쏜다.
|
||||
// 그 마지막 조각까지 받아야 회의 끝부분이 잘리지 않는다.
|
||||
if (recorder.state !== 'inactive') {
|
||||
await new Promise<void>((resolve) => {
|
||||
recorder.addEventListener('stop', () => resolve(), { once: true })
|
||||
recorder.stop()
|
||||
})
|
||||
}
|
||||
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop())
|
||||
streamRef.current = null
|
||||
|
||||
await uploadChainRef.current
|
||||
|
||||
const startedAt = startedAtRef.current ?? new Date()
|
||||
const durationMs = Date.now() - startedAt.getTime()
|
||||
const mimeType = mimeTypeRef.current ?? 'audio/webm'
|
||||
const id = sessionIdRef.current
|
||||
|
||||
const blob = new Blob(partsRef.current, { type: mimeType })
|
||||
|
||||
// 한 바이트도 못 받았으면 보관됐다고 말하면 안 된다. 이 기능의 요점은
|
||||
// 오디오가 남는 것인데, 남지 않았는데 남았다고 알리면 사용자는 회의가
|
||||
// 끝난 뒤에야 아무것도 없다는 걸 알게 된다.
|
||||
if (blob.size === 0) {
|
||||
setError(
|
||||
'오디오가 한 조각도 캡처되지 않았습니다. 마이크 입력 장치를 확인하고 다시 녹음해주세요.',
|
||||
)
|
||||
setElapsedMs(durationMs)
|
||||
setIsRecording(false)
|
||||
return
|
||||
}
|
||||
|
||||
let serverBytes = 0
|
||||
if (id && !uploadBrokenRef.current) {
|
||||
try {
|
||||
const res = await fetch(`/api/recordings/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ durationMs }),
|
||||
})
|
||||
if (res.ok) {
|
||||
serverBytes = (await res.json()).bytes ?? 0
|
||||
}
|
||||
} catch {
|
||||
setUploadWarning(
|
||||
'녹음 종료 처리에 실패했습니다. 저장된 조각은 남아 있지만, 로컬 사본도 내려받아 두시길 권합니다.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 서버 사본이 로컬보다 짧으면 조각이 새어나간 것이다. 조용히 넘기지 않는다.
|
||||
if (id && !uploadBrokenRef.current && serverBytes < blob.size) {
|
||||
setUploadWarning(
|
||||
`서버 사본이 로컬보다 짧습니다 (${serverBytes} / ${blob.size} bytes). 오디오를 내려받아 보관해주세요.`,
|
||||
)
|
||||
}
|
||||
|
||||
setElapsedMs(durationMs)
|
||||
setUploadedBytes(serverBytes)
|
||||
setRecording({
|
||||
recordingId: uploadBrokenRef.current ? null : id,
|
||||
mimeType,
|
||||
blob,
|
||||
durationMs,
|
||||
startedAt,
|
||||
uploadedBytes: serverBytes,
|
||||
})
|
||||
setIsRecording(false)
|
||||
}, [])
|
||||
|
||||
const downloadRecording = useCallback((title: string) => {
|
||||
const parts = partsRef.current
|
||||
if (parts.length === 0) return
|
||||
|
||||
const mimeType = mimeTypeRef.current ?? 'audio/webm'
|
||||
const blob = new Blob(parts, { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = recordingDownloadName(
|
||||
title,
|
||||
startedAtRef.current ?? new Date(),
|
||||
mimeType,
|
||||
)
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1_000)
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
partsRef.current = []
|
||||
sessionIdRef.current = null
|
||||
startedAtRef.current = null
|
||||
uploadBrokenRef.current = false
|
||||
setRecording(null)
|
||||
setElapsedMs(0)
|
||||
setLocalBytes(0)
|
||||
setUploadedBytes(0)
|
||||
setError(null)
|
||||
setUploadWarning(null)
|
||||
}, [])
|
||||
|
||||
// 언마운트 시 마이크를 놓아준다. 탭 표시등이 켜진 채 남지 않도록.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const recorder = recorderRef.current
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.stop()
|
||||
}
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isSupported,
|
||||
error,
|
||||
uploadWarning,
|
||||
elapsedMs,
|
||||
localBytes,
|
||||
uploadedBytes,
|
||||
recording,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
downloadRecording,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { TimeSpan } from '@/lib/transcript-formatter'
|
||||
|
||||
export type DiarizationStatus =
|
||||
| { state: 'idle' }
|
||||
| { state: 'unavailable'; message: string }
|
||||
| { state: 'recording'; seconds: number }
|
||||
| { state: 'analyzing' }
|
||||
| { state: 'done'; speakers: number; segments: TimeSpan[] }
|
||||
| { state: 'error'; message: string }
|
||||
|
||||
interface UseDiarizationResult {
|
||||
/** 모델 설치 여부. null이면 확인 중. */
|
||||
available: boolean | null
|
||||
status: DiarizationStatus
|
||||
/** 오디오 수집 시작. 마이크 트랙을 넘긴다. */
|
||||
start: (track: MediaStreamTrack) => Promise<void>
|
||||
/** 수집을 멈추고 전체 오디오에 화자 분리를 돌린다. */
|
||||
finish: (numSpeakers?: number) => Promise<TimeSpan[]>
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
function newSessionId(): string {
|
||||
return `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 대면 회의용 화자 분리.
|
||||
*
|
||||
* 녹음 중에는 4초짜리 PCM 조각을 서버에 쌓아두기만 하고, 녹음이 끝나면
|
||||
* 전체 오디오에 한 번 분리를 돌린다. 창을 잘라 반복 실행하면 실행마다
|
||||
* 화자 번호가 달라져 이어 붙일 수 없기 때문이다.
|
||||
*/
|
||||
export function useDiarization(): UseDiarizationResult {
|
||||
const [available, setAvailable] = useState<boolean | null>(null)
|
||||
const [status, setStatus] = useState<DiarizationStatus>({ state: 'idle' })
|
||||
|
||||
const sessionRef = useRef<string>('')
|
||||
const contextRef = useRef<AudioContext | null>(null)
|
||||
const nodeRef = useRef<AudioWorkletNode | null>(null)
|
||||
const secondsRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/diarize')
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!cancelled) setAvailable(!!d.available)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAvailable(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const teardown = useCallback(() => {
|
||||
nodeRef.current?.port.close()
|
||||
nodeRef.current?.disconnect()
|
||||
nodeRef.current = null
|
||||
contextRef.current?.close().catch(() => {})
|
||||
contextRef.current = null
|
||||
}, [])
|
||||
|
||||
const start = useCallback(
|
||||
async (track: MediaStreamTrack) => {
|
||||
sessionRef.current = newSessionId()
|
||||
secondsRef.current = 0
|
||||
|
||||
try {
|
||||
// 16kHz로 열면 브라우저가 리샘플링해준다 — 모델이 받는 샘플레이트.
|
||||
const context = new AudioContext({ sampleRate: 16_000 })
|
||||
contextRef.current = context
|
||||
|
||||
await context.audioWorklet.addModule('/pcm-worklet.js')
|
||||
|
||||
const source = context.createMediaStreamSource(new MediaStream([track]))
|
||||
const node = new AudioWorkletNode(context, 'pcm-collector')
|
||||
nodeRef.current = node
|
||||
|
||||
node.port.onmessage = (event) => {
|
||||
const pcm = event.data as Int16Array
|
||||
secondsRef.current += pcm.length / 16_000
|
||||
setStatus({
|
||||
state: 'recording',
|
||||
seconds: Math.round(secondsRef.current),
|
||||
})
|
||||
|
||||
// 실패해도 녹음을 막지 않는다. 그 구간만 화자 분리에서 빠진다.
|
||||
fetch(`/api/diarize/chunk?session=${sessionRef.current}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
// 워크릿이 매번 새 버퍼를 transfer 하므로 buffer 전체가 곧 이 조각이다.
|
||||
body: pcm.buffer as ArrayBuffer,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
source.connect(node)
|
||||
setStatus({ state: 'recording', seconds: 0 })
|
||||
} catch (err) {
|
||||
teardown()
|
||||
setStatus({
|
||||
state: 'error',
|
||||
message:
|
||||
err instanceof Error
|
||||
? `오디오 수집 실패: ${err.message}`
|
||||
: '오디오 수집에 실패했습니다.',
|
||||
})
|
||||
}
|
||||
},
|
||||
[teardown],
|
||||
)
|
||||
|
||||
const finish = useCallback(
|
||||
async (numSpeakers?: number): Promise<TimeSpan[]> => {
|
||||
teardown()
|
||||
|
||||
if (!sessionRef.current || secondsRef.current < 1) {
|
||||
setStatus({ state: 'idle' })
|
||||
return []
|
||||
}
|
||||
|
||||
setStatus({ state: 'analyzing' })
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/diarize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session: sessionRef.current, numSpeakers }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus({
|
||||
state: 'error',
|
||||
message: data.error ?? '화자 분리에 실패했습니다.',
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
const segments: TimeSpan[] = data.segments ?? []
|
||||
setStatus({ state: 'done', speakers: data.speakers ?? 0, segments })
|
||||
return segments
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
state: 'error',
|
||||
message:
|
||||
err instanceof Error ? err.message : '화자 분리 요청에 실패했습니다.',
|
||||
})
|
||||
return []
|
||||
}
|
||||
},
|
||||
[teardown],
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
teardown()
|
||||
sessionRef.current = ''
|
||||
secondsRef.current = 0
|
||||
setStatus({ state: 'idle' })
|
||||
}, [teardown])
|
||||
|
||||
useEffect(() => teardown, [teardown])
|
||||
|
||||
return { available, status, start, finish, reset }
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type CaptureMode = 'idle' | 'mic-only' | 'dual-stream'
|
||||
|
||||
interface DualStreamCapture {
|
||||
mode: CaptureMode
|
||||
/** 내 목소리 트랙 (마이크) */
|
||||
micTrack: MediaStreamTrack | null
|
||||
/** 상대 목소리 트랙 (시스템·탭 오디오). 화면 공유를 거부하거나 오디오를 안 켜면 null */
|
||||
systemTrack: MediaStreamTrack | null
|
||||
error: string | null
|
||||
/** 시스템 오디오 없이 마이크만 잡힌 경우의 안내 */
|
||||
warning: string | null
|
||||
start: (options?: { withSystemAudio?: boolean }) => Promise<void>
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
function describeCaptureError(err: unknown): string {
|
||||
if (!(err instanceof Error)) return '오디오 캡처에 실패했습니다.'
|
||||
|
||||
switch (err.name) {
|
||||
case 'NotAllowedError':
|
||||
return '오디오 캡처 권한이 거부되었습니다. 마이크와 화면 공유를 모두 허용해주세요.'
|
||||
case 'NotFoundError':
|
||||
return '사용할 수 있는 마이크를 찾을 수 없습니다.'
|
||||
case 'NotReadableError':
|
||||
return '다른 앱이 마이크를 사용 중입니다. 해당 앱을 종료하고 다시 시도해주세요.'
|
||||
default:
|
||||
return `오디오 캡처 실패: ${err.message}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화자 분리를 위한 2트랙 캡처.
|
||||
*
|
||||
* 내 마이크와 시스템(탭) 오디오를 **별도 트랙으로** 잡는다. 트랙이 곧 화자이므로
|
||||
* 추론 없이 정확도 100%로 화자가 갈린다. 원격 1:1 회의를 겨냥한 경로다.
|
||||
*
|
||||
* 시스템 오디오는 Chrome/Windows와 Chrome 141+/macOS 14.2+ 에서만 캡처된다.
|
||||
* 실패하면 마이크 단독 모드로 떨어지며, 그 경우 화자 분리는 되지 않는다.
|
||||
*/
|
||||
export function useDualStreamCapture(): DualStreamCapture {
|
||||
const [mode, setMode] = useState<CaptureMode>('idle')
|
||||
const [micTrack, setMicTrack] = useState<MediaStreamTrack | null>(null)
|
||||
const [systemTrack, setSystemTrack] = useState<MediaStreamTrack | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [warning, setWarning] = useState<string | null>(null)
|
||||
|
||||
const streamsRef = useRef<MediaStream[]>([])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
for (const stream of streamsRef.current) {
|
||||
for (const track of stream.getTracks()) track.stop()
|
||||
}
|
||||
streamsRef.current = []
|
||||
setMicTrack(null)
|
||||
setSystemTrack(null)
|
||||
setMode('idle')
|
||||
}, [])
|
||||
|
||||
const start = useCallback(
|
||||
async (options?: { withSystemAudio?: boolean }) => {
|
||||
const withSystemAudio = options?.withSystemAudio ?? true
|
||||
|
||||
setError(null)
|
||||
setWarning(null)
|
||||
|
||||
let micStream: MediaStream
|
||||
try {
|
||||
micStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
} catch (err) {
|
||||
setError(describeCaptureError(err))
|
||||
return
|
||||
}
|
||||
streamsRef.current.push(micStream)
|
||||
const mic = micStream.getAudioTracks()[0] ?? null
|
||||
setMicTrack(mic)
|
||||
|
||||
if (!withSystemAudio) {
|
||||
setMode('mic-only')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 오디오만 필요해도 video:true를 함께 요청해야 한다 (스펙 요구사항).
|
||||
const displayStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: true,
|
||||
})
|
||||
streamsRef.current.push(displayStream)
|
||||
|
||||
// 영상은 쓰지 않으므로 즉시 끊어 자원과 프라이버시 노출을 줄인다.
|
||||
for (const track of displayStream.getVideoTracks()) track.stop()
|
||||
|
||||
const system = displayStream.getAudioTracks()[0] ?? null
|
||||
if (!system) {
|
||||
setWarning(
|
||||
'시스템 오디오가 공유되지 않아 화자 분리가 비활성화됩니다. 공유 대화상자에서 "탭 오디오 공유"를 켜주세요.',
|
||||
)
|
||||
setMode('mic-only')
|
||||
return
|
||||
}
|
||||
|
||||
setSystemTrack(system)
|
||||
setMode('dual-stream')
|
||||
} catch (err) {
|
||||
setWarning(
|
||||
`${describeCaptureError(err)} 마이크만으로 계속 녹음합니다 (화자 분리 없음).`,
|
||||
)
|
||||
setMode('mic-only')
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => stop, [stop])
|
||||
|
||||
return { mode, micTrack, systemTrack, error, warning, start, stop }
|
||||
}
|
||||
@@ -3,22 +3,6 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import type { TranscriptChunk } from '@/lib/transcript-formatter'
|
||||
|
||||
export interface UseSpeechRecognitionOptions {
|
||||
/**
|
||||
* 전사할 오디오 트랙. 지정하면 기본 마이크 대신 이 트랙을 인식한다.
|
||||
* 트랙마다 인식기를 붙이면 화자가 트랙으로 확정된다.
|
||||
*/
|
||||
audioTrack?: MediaStreamTrack | null
|
||||
/** 이 인식기가 만든 청크에 붙일 화자 식별자. */
|
||||
speaker?: string
|
||||
/**
|
||||
* 타임스탬프 기준 시각(ms). 여러 인식기를 병합하려면 같은 값을 넘겨야
|
||||
* 한 시간축 위에 놓인다. 생략하면 startListening 호출 시각을 쓴다.
|
||||
*/
|
||||
timeOrigin?: number
|
||||
lang?: string
|
||||
}
|
||||
|
||||
interface SpeechRecognitionHook {
|
||||
isListening: boolean
|
||||
isSupported: boolean | null
|
||||
@@ -46,10 +30,7 @@ function describeError(code: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function useSpeechRecognition(
|
||||
options: UseSpeechRecognitionOptions = {},
|
||||
): SpeechRecognitionHook {
|
||||
const { audioTrack, speaker, timeOrigin, lang = 'ko-KR' } = options
|
||||
export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
||||
const [interimText, setInterimText] = useState('')
|
||||
@@ -59,12 +40,6 @@ export function useSpeechRecognition(
|
||||
const startTimeRef = useRef<number>(0)
|
||||
const networkRetryRef = useRef<number>(0)
|
||||
|
||||
// start()/onend 콜백이 항상 최신 값을 보도록 ref로 들고 있는다.
|
||||
const configRef = useRef({ audioTrack, speaker, timeOrigin, lang })
|
||||
useEffect(() => {
|
||||
configRef.current = { audioTrack, speaker, timeOrigin, lang }
|
||||
}, [audioTrack, speaker, timeOrigin, lang])
|
||||
|
||||
const MAX_NETWORK_RETRIES = 8
|
||||
|
||||
useEffect(() => {
|
||||
@@ -84,15 +59,12 @@ export function useSpeechRecognition(
|
||||
const SpeechRecognitionAPI =
|
||||
window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
|
||||
const { audioTrack: track, speaker: speakerId, timeOrigin: origin, lang: language } =
|
||||
configRef.current
|
||||
|
||||
const recognition = new SpeechRecognitionAPI()
|
||||
recognition.lang = language
|
||||
recognition.lang = 'ko-KR'
|
||||
recognition.continuous = true
|
||||
recognition.interimResults = true
|
||||
|
||||
startTimeRef.current = origin ?? Date.now()
|
||||
startTimeRef.current = Date.now()
|
||||
networkRetryRef.current = 0
|
||||
setError(null)
|
||||
|
||||
@@ -116,7 +88,6 @@ export function useSpeechRecognition(
|
||||
startTime: Math.max(0, now - 2),
|
||||
endTime: now,
|
||||
isFinal: true,
|
||||
...(speakerId ? { speaker: speakerId } : {}),
|
||||
},
|
||||
])
|
||||
setInterimText('')
|
||||
@@ -132,14 +103,8 @@ export function useSpeechRecognition(
|
||||
const delay = networkRetryRef.current > 0 ? 1500 : 0
|
||||
setTimeout(() => {
|
||||
if (recognitionRef.current !== recognition) return
|
||||
// 트랙이 끝났으면(화면 공유 중단 등) 재시작하지 않는다.
|
||||
if (track && track.readyState !== 'live') {
|
||||
setIsListening(false)
|
||||
recognitionRef.current = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
recognition.start(track ?? undefined)
|
||||
recognition.start()
|
||||
} catch {
|
||||
setIsListening(false)
|
||||
recognitionRef.current = null
|
||||
@@ -176,7 +141,7 @@ export function useSpeechRecognition(
|
||||
|
||||
recognitionRef.current = recognition
|
||||
try {
|
||||
recognition.start(track ?? undefined)
|
||||
recognition.start()
|
||||
setIsListening(true)
|
||||
} catch (err) {
|
||||
setError(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* 녹음 중 들어오는 PCM 조각을 세션별로 모아 둔다.
|
||||
*
|
||||
* 화자 분리는 녹음이 끝난 뒤 전체 오디오에 한 번 돌린다. 누적분을 매번
|
||||
* 다시 보내면 1시간 회의에서 100MB가 넘는 요청이 되므로, 녹음 중에는
|
||||
* 몇 초짜리 조각만 올리고 서버가 이어 붙인다.
|
||||
*
|
||||
* 단일 사용자 로컬 실행을 전제로 한 메모리 저장소다. 여러 사용자가 붙는
|
||||
* 환경이라면 디스크나 외부 저장소로 옮겨야 한다.
|
||||
*/
|
||||
|
||||
export const SAMPLE_RATE = 16_000
|
||||
|
||||
/** 세션당 최대 녹음 길이. 초과분은 거부한다. */
|
||||
export const MAX_SESSION_SECONDS = 3 * 60 * 60
|
||||
|
||||
/** 마지막 갱신 이후 이 시간이 지난 세션은 정리한다. */
|
||||
const SESSION_TTL_MS = 6 * 60 * 60 * 1000
|
||||
|
||||
interface Session {
|
||||
chunks: Int16Array[]
|
||||
totalSamples: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const sessions = new Map<string, Session>()
|
||||
|
||||
function sweep(): void {
|
||||
const cutoff = Date.now() - SESSION_TTL_MS
|
||||
for (const [id, session] of sessions) {
|
||||
if (session.updatedAt < cutoff) sessions.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
export type AppendResult =
|
||||
| { ok: true; totalSamples: number; seconds: number }
|
||||
| { ok: false; error: string }
|
||||
|
||||
export function appendAudio(sessionId: string, pcm: Int16Array): AppendResult {
|
||||
sweep()
|
||||
|
||||
const session = sessions.get(sessionId) ?? {
|
||||
chunks: [],
|
||||
totalSamples: 0,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
const nextTotal = session.totalSamples + pcm.length
|
||||
if (nextTotal > MAX_SESSION_SECONDS * SAMPLE_RATE) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `녹음이 최대 길이(${MAX_SESSION_SECONDS / 3600}시간)를 초과했습니다.`,
|
||||
}
|
||||
}
|
||||
|
||||
session.chunks.push(pcm)
|
||||
session.totalSamples = nextTotal
|
||||
session.updatedAt = Date.now()
|
||||
sessions.set(sessionId, session)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
totalSamples: nextTotal,
|
||||
seconds: nextTotal / SAMPLE_RATE,
|
||||
}
|
||||
}
|
||||
|
||||
/** 누적된 PCM을 모델이 받는 [-1, 1] float32로 펼친다. */
|
||||
export function collectSamples(sessionId: string): Float32Array | null {
|
||||
const session = sessions.get(sessionId)
|
||||
if (!session || session.totalSamples === 0) return null
|
||||
|
||||
const out = new Float32Array(session.totalSamples)
|
||||
let offset = 0
|
||||
|
||||
for (const chunk of session.chunks) {
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
out[offset + i] = chunk[i] / 32768
|
||||
}
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function clearSession(sessionId: string): void {
|
||||
sessions.delete(sessionId)
|
||||
}
|
||||
|
||||
export function sessionSeconds(sessionId: string): number {
|
||||
const session = sessions.get(sessionId)
|
||||
return session ? session.totalSamples / SAMPLE_RATE : 0
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
/**
|
||||
* 로컬 화자 분리 (speaker diarization).
|
||||
*
|
||||
* pyannote-segmentation-3.0 + 3D-Speaker 임베딩을 ONNX Runtime으로 돌린다.
|
||||
* GPU도 파이썬 런타임도 필요 없고, 노트북 CPU에서 실시간의 수십 배 속도로 처리된다.
|
||||
*
|
||||
* 마이크 하나에 여러 사람이 섞여 들어오는 대면 회의용 경로다. 원격 회의처럼
|
||||
* 트랙이 이미 나뉘어 있으면 이 모듈을 쓰지 않는 편이 정확하다.
|
||||
*/
|
||||
|
||||
export interface SpeakerSegment {
|
||||
/** 발화 시작 (초) */
|
||||
start: number
|
||||
/** 발화 종료 (초) */
|
||||
end: number
|
||||
/** 0부터 시작하는 화자 번호 */
|
||||
speaker: number
|
||||
}
|
||||
|
||||
export type DiarizationResult =
|
||||
| { success: true; segments: SpeakerSegment[] }
|
||||
| { success: false; error: string; reason: 'models-missing' | 'failed' }
|
||||
|
||||
export interface DiarizeOptions {
|
||||
/**
|
||||
* 참석자 수. 알면 반드시 넘길 것.
|
||||
*
|
||||
* 자동 추정은 화자 수를 과다·과소 계수하는 것이 주 실패 모드다.
|
||||
* 실측에서 4인 오디오가 자동 모드로는 5명, 힌트를 주면 4명으로 나왔다.
|
||||
*/
|
||||
numSpeakers?: number
|
||||
/** 자동 추정 시 클러스터 분리 임계값. 낮을수록 화자를 많이 잡는다. */
|
||||
threshold?: number
|
||||
}
|
||||
|
||||
const MODEL_DIR = process.env.DIARIZATION_MODEL_DIR ?? 'models/diarization'
|
||||
const SEGMENTATION_MODEL = 'segmentation.onnx'
|
||||
const EMBEDDING_MODEL = 'embedding.onnx'
|
||||
|
||||
export function resolveModelPaths(): {
|
||||
segmentation: string
|
||||
embedding: string
|
||||
} {
|
||||
const root = path.isAbsolute(MODEL_DIR)
|
||||
? MODEL_DIR
|
||||
: path.join(process.cwd(), MODEL_DIR)
|
||||
|
||||
return {
|
||||
segmentation: path.join(root, SEGMENTATION_MODEL),
|
||||
embedding: path.join(root, EMBEDDING_MODEL),
|
||||
}
|
||||
}
|
||||
|
||||
export function modelsInstalled(): boolean {
|
||||
const { segmentation, embedding } = resolveModelPaths()
|
||||
return existsSync(segmentation) && existsSync(embedding)
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델 로딩이 수백 ms 걸리므로 프로세스당 한 번만 만들고 재사용한다.
|
||||
* 클러스터링 설정은 요청마다 바뀔 수 있어 setConfig로 갈아끼운다.
|
||||
*/
|
||||
type Diarizer = {
|
||||
process: (samples: Float32Array) => SpeakerSegment[]
|
||||
setConfig: (config: {
|
||||
clustering: { numClusters?: number; threshold?: number }
|
||||
}) => void
|
||||
}
|
||||
|
||||
let cached: Diarizer | null = null
|
||||
|
||||
async function getDiarizer(): Promise<Diarizer> {
|
||||
if (cached) return cached
|
||||
|
||||
const { segmentation, embedding } = resolveModelPaths()
|
||||
const { OfflineSpeakerDiarization } = await import('sherpa-onnx-node')
|
||||
|
||||
cached = new OfflineSpeakerDiarization({
|
||||
segmentation: {
|
||||
pyannote: { model: segmentation },
|
||||
numThreads: 2,
|
||||
},
|
||||
embedding: { model: embedding, numThreads: 2 },
|
||||
clustering: { threshold: 0.5 },
|
||||
}) as unknown as Diarizer
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
export async function diarize(
|
||||
samples: Float32Array,
|
||||
options: DiarizeOptions = {},
|
||||
): Promise<DiarizationResult> {
|
||||
if (samples.length === 0) {
|
||||
return { success: true, segments: [] }
|
||||
}
|
||||
|
||||
if (!modelsInstalled()) {
|
||||
return {
|
||||
success: false,
|
||||
reason: 'models-missing',
|
||||
error:
|
||||
'화자 분리 모델이 설치되지 않았습니다. `npm run setup:diarization`을 실행해주세요.',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const diarizer = await getDiarizer()
|
||||
|
||||
// 참석자 수를 알면 클러스터 개수를 고정한다. 정확도가 크게 오른다.
|
||||
diarizer.setConfig({
|
||||
clustering:
|
||||
options.numSpeakers && options.numSpeakers > 0
|
||||
? { numClusters: options.numSpeakers }
|
||||
: { threshold: options.threshold ?? 0.5 },
|
||||
})
|
||||
|
||||
const segments = diarizer.process(samples)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
segments: segments.map((s) => ({
|
||||
start: s.start,
|
||||
end: s.end,
|
||||
speaker: s.speaker,
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return { success: false, reason: 'failed', error: `화자 분리 실패: ${message}` }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 녹음 조각을 디스크에 이어 붙이는 서버 저장소.
|
||||
*
|
||||
* 메모리에 모아 뒀다가 종료 시 한 번에 쓰지 않는다. 그러면 탭이나 서버가
|
||||
* 죽는 순간 회의 전체가 사라진다. 조각이 도착할 때마다 곧바로 append 하므로
|
||||
* 어느 시점에 중단되든 그때까지의 오디오는 파일로 남아 있다.
|
||||
*
|
||||
* MediaRecorder가 timeslice 모드에서 내보내는 조각은 첫 조각에 컨테이너
|
||||
* 헤더가 들어 있고 이후에는 클러스터만 이어진다. 받은 순서대로 이어 붙이면
|
||||
* 그대로 재생·전사 가능한 파일이 된다. 단 헤더의 duration 필드는 비어 있어
|
||||
* 플레이어에 따라 탐색(seek)이 부정확할 수 있다 — 재전사에는 영향이 없다.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto'
|
||||
import { createReadStream } from 'fs'
|
||||
import type { Readable } from 'stream'
|
||||
import { appendFile, mkdir, readFile, stat, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import {
|
||||
MAX_RECORDING_BYTES,
|
||||
extensionForMimeType,
|
||||
isValidRecordingId,
|
||||
} from './recording'
|
||||
|
||||
/** 별도 볼륨에 두고 싶으면 `RECORDINGS_DIR`로 덮어쓴다. */
|
||||
export const RECORDINGS_DIR =
|
||||
process.env.RECORDINGS_DIR ?? path.join(process.cwd(), 'uploads', 'recordings')
|
||||
|
||||
export interface RecordingMeta {
|
||||
id: string
|
||||
mimeType: string
|
||||
/** `uploads/recordings/` 기준 상대 파일명. */
|
||||
fileName: string
|
||||
startedAt: string
|
||||
finalizedAt: string | null
|
||||
durationMs: number | null
|
||||
}
|
||||
|
||||
export interface RecordingStatus extends RecordingMeta {
|
||||
bytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* id별 직렬화 큐.
|
||||
*
|
||||
* 조각의 순서가 곧 파일의 순서다. 두 요청이 겹쳐 들어오면 컨테이너가
|
||||
* 깨지므로 같은 녹음에 대한 쓰기는 한 줄로 세운다.
|
||||
*/
|
||||
const queues = new Map<string, Promise<unknown>>()
|
||||
|
||||
function enqueue<T>(id: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = queues.get(id) ?? Promise.resolve()
|
||||
const next = previous.then(task, task)
|
||||
|
||||
// 앞 작업이 실패해도 뒤 작업은 돌아야 하므로, 큐에는 절대 거부되지 않는
|
||||
// 프로미스를 넣는다. 실패는 호출자가 받는 `next`로만 전달된다.
|
||||
const settled = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
|
||||
// 큐가 무한정 자라지 않도록, 마지막 작업이 끝나면 항목을 지운다.
|
||||
queues.set(id, settled)
|
||||
settled.then(() => {
|
||||
if (queues.get(id) === settled) queues.delete(id)
|
||||
})
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function metaPath(id: string): string {
|
||||
return path.join(RECORDINGS_DIR, `${id}.json`)
|
||||
}
|
||||
|
||||
function audioPath(meta: RecordingMeta): string {
|
||||
return path.join(RECORDINGS_DIR, meta.fileName)
|
||||
}
|
||||
|
||||
async function fileSize(filePath: string): Promise<number> {
|
||||
try {
|
||||
return (await stat(filePath)).size
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 새 녹음 세션을 만든다. 오디오 파일은 첫 조각이 도착할 때 생긴다. */
|
||||
export async function createRecording(
|
||||
mimeType: string,
|
||||
): Promise<RecordingMeta> {
|
||||
const id = randomBytes(16).toString('hex')
|
||||
const meta: RecordingMeta = {
|
||||
id,
|
||||
mimeType,
|
||||
fileName: `${id}.${extensionForMimeType(mimeType)}`,
|
||||
startedAt: new Date().toISOString(),
|
||||
finalizedAt: null,
|
||||
durationMs: null,
|
||||
}
|
||||
|
||||
await mkdir(RECORDINGS_DIR, { recursive: true })
|
||||
await writeFile(metaPath(id), JSON.stringify(meta, null, 2), 'utf-8')
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
export async function getRecording(id: string): Promise<RecordingMeta | null> {
|
||||
if (!isValidRecordingId(id)) return null
|
||||
|
||||
try {
|
||||
const raw = await readFile(metaPath(id), 'utf-8')
|
||||
return JSON.parse(raw) as RecordingMeta
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecordingStatus(
|
||||
id: string,
|
||||
): Promise<RecordingStatus | null> {
|
||||
const meta = await getRecording(id)
|
||||
if (!meta) return null
|
||||
|
||||
return { ...meta, bytes: await fileSize(audioPath(meta)) }
|
||||
}
|
||||
|
||||
export type AppendResult =
|
||||
| { ok: true; bytes: number }
|
||||
| { ok: false; error: string; status: number }
|
||||
|
||||
/** 조각 하나를 파일 끝에 이어 붙이고 누적 크기를 돌려준다. */
|
||||
export async function appendChunk(
|
||||
id: string,
|
||||
chunk: Buffer,
|
||||
): Promise<AppendResult> {
|
||||
const meta = await getRecording(id)
|
||||
if (!meta) {
|
||||
return { ok: false, error: '녹음 세션을 찾을 수 없습니다.', status: 404 }
|
||||
}
|
||||
|
||||
return enqueue(id, async () => {
|
||||
const filePath = audioPath(meta)
|
||||
const current = await fileSize(filePath)
|
||||
|
||||
if (current + chunk.byteLength > MAX_RECORDING_BYTES) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `녹음이 상한(${Math.round(
|
||||
MAX_RECORDING_BYTES / 1024 / 1024,
|
||||
)}MB)을 넘었습니다.`,
|
||||
status: 413,
|
||||
}
|
||||
}
|
||||
|
||||
await appendFile(filePath, chunk)
|
||||
return { ok: true as const, bytes: current + chunk.byteLength }
|
||||
})
|
||||
}
|
||||
|
||||
/** 녹음을 닫는다. 이후 조각은 더 오지 않는다고 보고 길이를 확정한다. */
|
||||
export async function finalizeRecording(
|
||||
id: string,
|
||||
durationMs: number | null,
|
||||
): Promise<RecordingStatus | null> {
|
||||
const meta = await getRecording(id)
|
||||
if (!meta) return null
|
||||
|
||||
return enqueue(id, async () => {
|
||||
const updated: RecordingMeta = {
|
||||
...meta,
|
||||
finalizedAt: new Date().toISOString(),
|
||||
durationMs:
|
||||
typeof durationMs === 'number' && Number.isFinite(durationMs)
|
||||
? Math.max(0, Math.round(durationMs))
|
||||
: null,
|
||||
}
|
||||
|
||||
await writeFile(metaPath(id), JSON.stringify(updated, null, 2), 'utf-8')
|
||||
return { ...updated, bytes: await fileSize(audioPath(updated)) }
|
||||
})
|
||||
}
|
||||
|
||||
/** 다운로드용 읽기 스트림. 파일이 없으면 null. */
|
||||
export async function openRecording(
|
||||
id: string,
|
||||
): Promise<{ meta: RecordingMeta; bytes: number; stream: Readable } | null> {
|
||||
const meta = await getRecording(id)
|
||||
if (!meta) return null
|
||||
|
||||
const filePath = audioPath(meta)
|
||||
const bytes = await fileSize(filePath)
|
||||
if (bytes === 0) return null
|
||||
|
||||
return { meta, bytes, stream: createReadStream(filePath) }
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 회의 오디오 녹음 — 브라우저·서버가 공유하는 상수와 순수 함수.
|
||||
*
|
||||
* 녹음의 목적은 재생이 아니라 **재전사**다. Web Speech가 놓친 발화를 나중에
|
||||
* 서버 STT로 되살리려면 원본 오디오가 남아 있어야 한다. 그래서 전사와
|
||||
* 무관하게, 전사가 실패하더라도 오디오만은 반드시 파일로 남긴다.
|
||||
*/
|
||||
|
||||
/** MediaRecorder에 우선순위대로 시도할 컨테이너/코덱. */
|
||||
export const PREFERRED_MIME_TYPES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/mp4',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 대면 회의 기준 캡처 제약.
|
||||
*
|
||||
* 브라우저 기본값은 세 가지가 모두 켜져 있고 전화 통화용으로 튜닝돼 있다.
|
||||
* 노이즈 억제는 멀리 앉은 화자의 목소리를 노이즈로 오판해 지워버릴 수 있고,
|
||||
* 에코 제거는 스피커 출력이 없는 대면 회의에서는 얻을 게 없다. 둘 다 끈다.
|
||||
* 반면 AGC는 조용한 화자의 레벨을 끌어올려 주므로 남긴다.
|
||||
*
|
||||
* 원격 회의(스피커 출력이 마이크로 되먹임되는 경우)에는 echoCancellation을
|
||||
* 다시 켜야 한다. 그 경로는 dual-stream 캡처와 함께 다룬다.
|
||||
*/
|
||||
export const RECORDING_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
|
||||
channelCount: 1,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버로 조각을 올리는 간격.
|
||||
*
|
||||
* 짧을수록 탭이 죽었을 때 잃는 양이 적고, 길수록 요청 수가 준다. 15초면
|
||||
* 최악의 경우에도 15초치만 잃는다.
|
||||
*/
|
||||
export const CHUNK_INTERVAL_MS = 15_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
|
||||
|
||||
/** 조각 하나의 상한. 15초 × 128kbps면 240KB 남짓이라 넉넉하다. */
|
||||
export const MAX_CHUNK_BYTES = 8 * 1024 * 1024
|
||||
|
||||
/** 브라우저가 지원하는 첫 번째 후보를 고른다. 없으면 null. */
|
||||
export function pickRecorderMimeType(
|
||||
isTypeSupported: (type: string) => boolean,
|
||||
): string | null {
|
||||
for (const type of PREFERRED_MIME_TYPES) {
|
||||
if (isTypeSupported(type)) return type
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** `audio/webm;codecs=opus` → `webm` */
|
||||
export function extensionForMimeType(mimeType: string): string {
|
||||
const base = mimeType.split(';')[0].trim().toLowerCase()
|
||||
|
||||
switch (base) {
|
||||
case 'audio/webm':
|
||||
return 'webm'
|
||||
case 'audio/ogg':
|
||||
return 'ogg'
|
||||
case 'audio/mp4':
|
||||
case 'audio/x-m4a':
|
||||
return 'm4a'
|
||||
case 'audio/mpeg':
|
||||
return 'mp3'
|
||||
case 'audio/wav':
|
||||
case 'audio/x-wav':
|
||||
return 'wav'
|
||||
case 'audio/flac':
|
||||
return 'flac'
|
||||
default:
|
||||
return 'bin'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 녹음 세션 식별자 검증.
|
||||
*
|
||||
* 이 값이 그대로 파일 경로가 되므로 `..`이나 구분자가 섞이면 안 된다.
|
||||
* 경로 조립 전에 반드시 통과시킨다.
|
||||
*/
|
||||
const RECORDING_ID_PATTERN = /^[0-9a-f]{32}$/
|
||||
|
||||
export function isValidRecordingId(id: unknown): id is string {
|
||||
return typeof id === 'string' && RECORDING_ID_PATTERN.test(id)
|
||||
}
|
||||
|
||||
/** 사용자가 내려받을 때 보게 될 파일 이름. */
|
||||
export function recordingDownloadName(
|
||||
title: string,
|
||||
startedAt: Date,
|
||||
mimeType: string,
|
||||
): string {
|
||||
const stamp = [
|
||||
startedAt.getFullYear(),
|
||||
String(startedAt.getMonth() + 1).padStart(2, '0'),
|
||||
String(startedAt.getDate()).padStart(2, '0'),
|
||||
'-',
|
||||
String(startedAt.getHours()).padStart(2, '0'),
|
||||
String(startedAt.getMinutes()).padStart(2, '0'),
|
||||
].join('')
|
||||
|
||||
const safeTitle = title
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]/g, '')
|
||||
.replace(/\s+/g, '_')
|
||||
.slice(0, 60)
|
||||
|
||||
const stem = safeTitle.length > 0 ? `${stamp}_${safeTitle}` : stamp
|
||||
return `${stem}.${extensionForMimeType(mimeType)}`
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '0B'
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000))
|
||||
const h = Math.floor(total / 3600)
|
||||
const m = Math.floor((total % 3600) / 60)
|
||||
const s = total % 60
|
||||
const mm = String(m).padStart(2, '0')
|
||||
const ss = String(s).padStart(2, '0')
|
||||
|
||||
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* 화자 식별자.
|
||||
*
|
||||
* dual-stream 캡처에서는 트랙이 곧 화자이므로 추론이 개입하지 않는다.
|
||||
* 단일 트랙에서 화자를 추정하는 경로가 생기면 'spk_1' 같은 식별자가 추가된다.
|
||||
*/
|
||||
export const LOCAL_SPEAKER = 'local'
|
||||
export const REMOTE_SPEAKER = 'remote'
|
||||
|
||||
export type SpeakerNames = Readonly<Record<string, string>>
|
||||
|
||||
/** diarization이 매긴 화자 번호를 식별자로 바꾼다. 0-based → 1-based. */
|
||||
export function diarizedSpeakerId(index: number): string {
|
||||
return `spk_${index + 1}`
|
||||
}
|
||||
|
||||
const DEFAULT_NAMES: SpeakerNames = {
|
||||
[LOCAL_SPEAKER]: '나',
|
||||
[REMOTE_SPEAKER]: '상대',
|
||||
}
|
||||
|
||||
/** 화자 식별자를 표시용 이름으로 바꾼다. 사용자가 지정한 이름이 우선한다. */
|
||||
export function resolveSpeakerName(
|
||||
speaker: string,
|
||||
names?: SpeakerNames,
|
||||
): string {
|
||||
const custom = names?.[speaker]?.trim()
|
||||
if (custom) return custom
|
||||
|
||||
const preset = DEFAULT_NAMES[speaker]
|
||||
if (preset) return preset
|
||||
|
||||
// spk_1 → "화자 1"
|
||||
const diarized = /^spk_(\d+)$/.exec(speaker)
|
||||
if (diarized) return `화자 ${diarized[1]}`
|
||||
|
||||
return speaker
|
||||
}
|
||||
|
||||
/** 회의록에 저장할 참석자 목록. 지정된 이름이 없으면 기본 표기를 쓴다. */
|
||||
export function listAttendees(
|
||||
speakers: readonly string[],
|
||||
names?: SpeakerNames,
|
||||
): string[] {
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
|
||||
for (const speaker of speakers) {
|
||||
if (seen.has(speaker)) continue
|
||||
seen.add(speaker)
|
||||
result.push(resolveSpeakerName(speaker, names))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import { resolveSpeakerName, type SpeakerNames } from './speakers'
|
||||
|
||||
export interface TranscriptChunk {
|
||||
text: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
isFinal: boolean
|
||||
/** 화자 식별자. 단일 트랙 녹음에서는 없다. */
|
||||
speaker?: string
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
@@ -20,42 +16,14 @@ function formatTimestamp(seconds: number): string {
|
||||
return `[${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}]`
|
||||
}
|
||||
|
||||
export function formatTranscriptChunks(
|
||||
chunks: readonly TranscriptChunk[],
|
||||
names?: SpeakerNames,
|
||||
): string {
|
||||
export function formatTranscriptChunks(chunks: readonly TranscriptChunk[]): string {
|
||||
if (chunks.length === 0) return ''
|
||||
|
||||
return chunks
|
||||
.map((chunk) => {
|
||||
const stamp = formatTimestamp(chunk.startTime)
|
||||
if (!chunk.speaker) return `${stamp} ${chunk.text}`
|
||||
return `${stamp} ${resolveSpeakerName(chunk.speaker, names)}: ${chunk.text}`
|
||||
})
|
||||
.map((chunk) => `${formatTimestamp(chunk.startTime)} ${chunk.text}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 트랙에서 나온 청크를 하나의 시간축으로 합친다.
|
||||
*
|
||||
* 각 인식기가 독립적으로 동작하므로 도착 순서가 발화 순서와 다를 수 있다.
|
||||
* 모든 트랙이 같은 timeOrigin을 공유한다는 전제 아래 startTime으로 정렬한다.
|
||||
*/
|
||||
export function mergeSpeakerChunks(
|
||||
...streams: readonly (readonly TranscriptChunk[])[]
|
||||
): TranscriptChunk[] {
|
||||
const merged = streams.flat()
|
||||
|
||||
return merged
|
||||
.map((chunk, index) => ({ chunk, index }))
|
||||
.sort((a, b) => {
|
||||
const diff = a.chunk.startTime - b.chunk.startTime
|
||||
// 같은 시각이면 원래 순서를 유지해 결과가 흔들리지 않게 한다.
|
||||
return diff !== 0 ? diff : a.index - b.index
|
||||
})
|
||||
.map(({ chunk }) => chunk)
|
||||
}
|
||||
|
||||
export function mergeAdjacentChunks(
|
||||
chunks: readonly TranscriptChunk[],
|
||||
gapThreshold: number,
|
||||
@@ -68,19 +36,12 @@ export function mergeAdjacentChunks(
|
||||
const current = chunks[i]
|
||||
const last = result[result.length - 1]
|
||||
|
||||
// 화자가 다르면 붙이지 않는다 — 서로 다른 사람의 발화가 한 덩어리가 되면 안 된다.
|
||||
if (current.speaker !== last.speaker) {
|
||||
result.push({ ...current })
|
||||
continue
|
||||
}
|
||||
|
||||
if (current.startTime - last.endTime <= gapThreshold) {
|
||||
result[result.length - 1] = {
|
||||
text: `${last.text} ${current.text}`,
|
||||
startTime: last.startTime,
|
||||
endTime: current.endTime,
|
||||
isFinal: current.isFinal,
|
||||
...(last.speaker ? { speaker: last.speaker } : {}),
|
||||
}
|
||||
} else {
|
||||
result.push({ ...current })
|
||||
@@ -90,63 +51,31 @@ export function mergeAdjacentChunks(
|
||||
return result
|
||||
}
|
||||
|
||||
export interface TimeSpan {
|
||||
start: number
|
||||
end: number
|
||||
speaker: number
|
||||
}
|
||||
|
||||
function overlap(
|
||||
aStart: number,
|
||||
aEnd: number,
|
||||
bStart: number,
|
||||
bEnd: number,
|
||||
): number {
|
||||
return Math.max(0, Math.min(aEnd, bEnd) - Math.max(aStart, bStart))
|
||||
}
|
||||
|
||||
/**
|
||||
* diarization 결과를 전사 청크에 붙인다.
|
||||
* 서버 STT가 준 구간을 전사문으로 옮긴다.
|
||||
*
|
||||
* 청크마다 시간이 가장 많이 겹치는 화자 구간을 고른다. 겹치는 구간이 없으면
|
||||
* 화자를 비워 둔다 — 틀린 화자를 붙이는 것보다 없는 편이 낫다.
|
||||
*
|
||||
* Web Speech API가 주는 청크 타임스탬프는 근사값이므로 이 매칭도 근사다.
|
||||
* 여기 붙는 타임스탬프는 실제 오디오 타임라인이다. Web Speech 경로가 쓰던
|
||||
* `결과 이벤트 시각 − 2초` 추정값과 달리 화자 분리·구간 재생에 그대로 쓸 수 있다.
|
||||
*/
|
||||
export function assignSpeakers(
|
||||
chunks: readonly TranscriptChunk[],
|
||||
segments: readonly TimeSpan[],
|
||||
speakerId: (index: number) => string,
|
||||
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[] {
|
||||
if (segments.length === 0) return chunks.map((chunk) => ({ ...chunk }))
|
||||
|
||||
return chunks.map((chunk) => {
|
||||
let best: TimeSpan | null = null
|
||||
let bestOverlap = 0
|
||||
|
||||
for (const segment of segments) {
|
||||
const value = overlap(
|
||||
chunk.startTime,
|
||||
chunk.endTime,
|
||||
segment.start,
|
||||
segment.end,
|
||||
)
|
||||
if (value > bestOverlap) {
|
||||
bestOverlap = value
|
||||
best = segment
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) {
|
||||
// 겹치는 구간이 없으면 이전 라벨도 남기지 않는다.
|
||||
return {
|
||||
text: chunk.text,
|
||||
startTime: chunk.startTime,
|
||||
endTime: chunk.endTime,
|
||||
isFinal: chunk.isFinal,
|
||||
}
|
||||
}
|
||||
|
||||
return { ...chunk, speaker: speakerId(best.speaker) }
|
||||
})
|
||||
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,
|
||||
}))
|
||||
}
|
||||
Vendored
-36
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* sherpa-onnx-node는 JSDoc만 제공하고 .d.ts를 배포하지 않는다.
|
||||
* 이 프로젝트가 실제로 쓰는 화자 분리 API만 선언한다.
|
||||
*/
|
||||
declare module 'sherpa-onnx-node' {
|
||||
export interface SherpaSpeakerSegment {
|
||||
start: number
|
||||
end: number
|
||||
speaker: number
|
||||
}
|
||||
|
||||
export interface FastClusteringConfig {
|
||||
numClusters?: number
|
||||
threshold?: number
|
||||
}
|
||||
|
||||
export interface OfflineSpeakerDiarizationConfig {
|
||||
segmentation?: {
|
||||
pyannote?: { model?: string; windowShiftRatio?: number }
|
||||
numThreads?: number
|
||||
provider?: string
|
||||
debug?: boolean | number
|
||||
}
|
||||
embedding?: { model?: string; numThreads?: number; provider?: string }
|
||||
clustering?: FastClusteringConfig
|
||||
minDurationOn?: number
|
||||
minDurationOff?: number
|
||||
}
|
||||
|
||||
export class OfflineSpeakerDiarization {
|
||||
constructor(config: OfflineSpeakerDiarizationConfig)
|
||||
readonly sampleRate: number
|
||||
process(samples: Float32Array): SherpaSpeakerSegment[]
|
||||
setConfig(config: { clustering: FastClusteringConfig }): void
|
||||
}
|
||||
}
|
||||
Vendored
+1
-5
@@ -5,11 +5,7 @@ interface SpeechRecognition extends EventTarget {
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null
|
||||
onend: (() => void) | null
|
||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
|
||||
/**
|
||||
* audioTrack을 넘기면 기본 마이크 대신 그 트랙을 전사한다 (Chrome 한정).
|
||||
* 트랙별로 인식기를 붙이면 화자가 트랙 번호로 확정된다.
|
||||
*/
|
||||
start(audioTrack?: MediaStreamTrack): void
|
||||
start(): void
|
||||
stop(): void
|
||||
abort(): void
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user