Author SHA1 Message Date
csbaeandClaude Opus 5 a8cfab8a4a fix: 두 트랙을 확보했을 때만 화자를 라벨링
대면 회의처럼 시스템 오디오가 없는 상황에서 화자 분리를 켜면,
마이크에 섞여 들어온 상대 발언까지 "나"로 기록되는 문제가 있었다.
라벨이 틀리는 것은 라벨이 없는 것보다 나쁘다.

capture.mode가 'dual-stream'일 때만 speaker를 붙이고, 마이크 단독으로
폴백하면 라벨 없이 기존 형식으로 기록한다.

설정 UI에도 대면 회의에서는 동작하지 않는다는 점을 명시했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:58 +09:00
csbaeandClaude Opus 5 d2cb5b0009 feat: dual-stream 캡처로 원격 회의 화자 분리
내 마이크와 시스템(탭) 오디오를 별도 트랙으로 잡고, 트랙마다 인식기를
붙인다. 트랙이 곧 화자이므로 추론도 모델 다운로드도 없이 화자가 갈린다.

Chrome이 SpeechRecognition.start(audioTrack)를 지원하는 덕분에 가능하다.
트랙을 명시적으로 넘기므로 기본 마이크를 두고 경합할 일도 없다.

- speakers.ts 신설 — 화자 식별자와 표시 이름 해석
- TranscriptChunk.speaker 추가, formatTranscriptChunks가 "화자: 발화"로 출력
- mergeSpeakerChunks — 트랙별 청크를 하나의 시간축으로 병합.
  여러 인식기가 같은 timeOrigin을 공유해야 순서가 맞는다
- mergeAdjacentChunks가 화자 경계를 넘어 합치지 않도록 수정
- useSpeechRecognition: audioTrack / speaker / timeOrigin / lang 옵션.
  인자 없이 호출하면 기존 동작 그대로다
- useDualStreamCapture 신설 — getUserMedia + getDisplayMedia.
  video는 즉시 끊고 오디오만 쓴다. 시스템 오디오 확보에 실패하면
  마이크 단독으로 폴백하고 화자 분리가 꺼졌음을 안내한다
- LiveRecorder: 화자 분리 토글, 화자 이름 입력, 전사 목록 화자 색상 구분

원격 다자 회의와 대면 회의는 원격 트랙에 diarization이 필요하며 후속 작업이다.

테스트 162 → 176.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:27:00 +09:00
24 changed files with 539 additions and 1692 deletions

No files matched your search

-10
View File
@@ -51,13 +51,3 @@ GEMINI_API_KEY=
# LLM_BASE_URL= # LLM_BASE_URL=
# LLM_MODEL= # LLM_MODEL=
# LLM_API_KEY= # LLM_API_KEY=
# ─────────────────────────────────────────────────────────────
# 회의 오디오 녹음 저장 위치 (선택)
# ─────────────────────────────────────────────────────────────
# 기본값은 <프로젝트>/uploads/recordings 입니다.
# Docker Compose에서는 uploads 볼륨에 저장되어 컨테이너를 재시작해도 남습니다.
#
# ⚠️ 녹음 파일에는 회의 원음이 그대로 들어 있습니다. 디스크 암호화된 위치에
# 두고, 필요 없어진 녹음은 직접 삭제하세요. SECURITY.md 참고.
# RECORDINGS_DIR=
+2 -22
View File
@@ -51,29 +51,10 @@ docker compose up -d
**특징** **특징**
- 즉시 시작 — 별도 STT 서버나 키 없이 작동 - 즉시 시작 — 별도 STT 서버나 키 없이 작동
- **오디오 원본 동시 녹음** — 전사와 무관하게 회의 원음을 파일로 보관
- **자동 재연결** — 네트워크 끊김 시 최대 8회 재시도, 누적 transcript 보존 - **자동 재연결** — 네트워크 끊김 시 최대 8회 재시도, 누적 transcript 보존
- 확정 전 텍스트는 회색 이탤릭 + 깜빡이는 커서로 시각화 - 확정 전 텍스트는 회색 이탤릭 + 깜빡이는 커서로 시각화
- 녹음 중 `🔴 N단어 · M개 구간` 실시간 카운터 - 녹음 중 `🔴 N단어 · M개 구간` 실시간 카운터
> ⚠️ **Web Speech API는 발화를 상당량 놓칩니다.** 원거리·다인 대면 회의에서
> 실측 포착률이 10% 안팎이었습니다(46분 회의 / 489어절). 회의 전사용으로
> 설계된 엔진이 아니라 근접 음성 명령용입니다. 그래서 **오디오 원본을 반드시
> 함께 남깁니다** — 놓친 발화는 나중에 서버 STT로 다시 살릴 수 있습니다.
### 🎧 오디오 원본 보관
녹음을 시작하면 전사와 **별개로** 마이크 입력을 오디오 파일로 저장합니다.
- 15초마다 조각을 서버에 이어 붙여 **탭이나 서버가 죽어도 그때까지의 오디오는 남습니다**
- 서버 저장이 실패해도 녹음은 중단되지 않고, 브라우저 사본을 **⬇️ 내려받기**로 회수할 수 있습니다
- 한 조각도 못 받았으면 "보관됨"이라고 하지 않고 오류를 띄웁니다
- 저장 위치: `uploads/recordings/` (Docker에서는 `uploads` 볼륨 → 재시작해도 유지)
**캡처 설정** — 브라우저 기본값은 전화 통화용 튜닝이라 멀리 앉은 화자를
노이즈로 지워버립니다. 그래서 노이즈 억제·에코 제거를 끄고 AGC만 남깁니다
(`src/lib/recording.ts`의 `RECORDING_AUDIO_CONSTRAINTS`).
**사용** **사용**
1. 홈에서 **🎤 녹음 시작** 클릭 1. 홈에서 **🎤 녹음 시작** 클릭
2. 마이크 권한 허용 2. 마이크 권한 허용
@@ -502,9 +483,8 @@ prisma/
| 제약 | 설명 | 대응 | | 제약 | 설명 | 대응 |
|---|---|---| |---|---|---|
| **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | 서버 사이드 STT 전환 예정 | | **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | Phase 4에서 서버 사이드 전사 검토 |
| **전사 포착률 낮음** | 원거리·다인 대면 회의 실측 10% 안팎 | 오디오 원본 보관 → 서버 STT 재전사 (진행 예정) | | **화자 구분 없음** | 의도적 비지원 (Non-goal) | — |
| **화자 구분 없음** | 미구현 (PR #16 / #17 검토 중) | — |
| **Gemini 무료 등급 한도** | 15 RPM / 1000 RPD | 한도 초과 시 자동 쿨다운, 단순 변환 폴백 | | **Gemini 무료 등급 한도** | 15 RPM / 1000 RPD | 한도 초과 시 자동 쿨다운, 단순 변환 폴백 |
| **단일 사용자** | 인증 없음, 데이터 격리 없음 | 1인 1인스턴스로 운용 | | **단일 사용자** | 인증 없음, 데이터 격리 없음 | 1인 1인스턴스로 운용 |
| **마크다운 검색** | PostgreSQL `ILIKE` (수천 건 이상에서 느려질 수 있음) | 필요 시 `tsvector` 인덱스 추가 | | **마크다운 검색** | PostgreSQL `ILIKE` (수천 건 이상에서 느려질 수 있음) | 필요 시 `tsvector` 인덱스 추가 |
-20
View File
@@ -132,26 +132,6 @@ OpenAI 호환 프리셋의 base URL은 브라우저가 아니라 **Next.js Route
- 추가 보호가 필요하면 reverse proxy(nginx, Caddy)에 Basic Auth 추가 - 추가 보호가 필요하면 reverse proxy(nginx, Caddy)에 Basic Auth 추가
- 회사 환경에서는 SSO 통합이 필요하지만 현재 비목표 - 회사 환경에서는 SSO 통합이 필요하지만 현재 비목표
### MEDIUM — 회의 원음이 디스크에 평문으로 남습니다
**현황**: 실시간 녹음을 시작하면 회의 오디오 원본이 `uploads/recordings/`에 저장됩니다
(Docker에서는 `uploads` 볼륨). 암호화하지 않은 평문 오디오이며, 회의에서 오간
말이 그대로 들어 있습니다. **전사 텍스트보다 민감도가 높습니다** — 텍스트는
Web Speech가 대부분 놓치지만 오디오에는 전부 남습니다.
이 저장은 의도된 설계입니다. Web Speech의 포착률이 낮아(실측 10% 안팎) 원음을
남기지 않으면 놓친 발화를 복구할 방법이 없습니다.
**완화**:
- 디스크 암호화(FileVault / BitLocker)가 켜진 머신에서 운용
- 불필요해진 녹음은 `uploads/recordings/`에서 직접 삭제
- `RECORDINGS_DIR` 환경변수로 저장 위치를 별도 암호화 볼륨으로 지정 가능
- 녹음 파일 자동 만료/삭제는 **미구현** — 수동 관리가 필요합니다
**세션 ID 추측**: 녹음 조회·추가 API는 128비트 난수 ID만으로 접근을 가릅니다.
인증이 없으므로, 같은 머신에서 앱에 접근 가능한 주체는 ID를 알면 오디오를
내려받을 수 있습니다. 단일 사용자 로컬 실행 전제입니다.
### MEDIUM — 파일 업로드 매직 바이트 미검증 ### MEDIUM — 파일 업로드 매직 바이트 미검증
**현황**: `audio-validation.ts`는 `file.type`(클라이언트 제공)과 파일명 확장자만 검사. 매직 바이트 검증 없음. **현황**: `audio-validation.ts`는 `file.type`(클라이언트 제공)과 파일명 확장자만 검사. 매직 바이트 검증 없음.
+1 -58
View File
@@ -155,64 +155,6 @@
--- ---
## 🔴 실사용 진단 (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` 연결
- [ ] **서버 STT 파이프라인** — 녹음 파일 → Whisper / Gemini audio → transcript
- [ ] `/api/upload`를 막다른 길에서 본선 경로로 승격
- [ ] 프로바이더 레이어에 `/v1/audio/transcriptions` 추가 (#12 구조 확장)
- [ ] Web Speech는 "녹음 중 실시간 미리보기"로 강등, 확정본은 종료 후 재전사
- [ ] 같은 오디오로 Web Speech vs STT 포착률 실측 비교
- [ ] **빈 청크 필터링** — 빈 발화 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일) ## 🎯 Phase 3 — 참석자 + 태그 (3~5일)
### Issues ### Issues
@@ -257,6 +199,7 @@ flush해서 7%를 20~30%로는 올려도 90%로는 못 간다. 구현 결함이
다음 기능은 개인 사용 목적에 부합하지 않아 **의도적으로 범위 밖**: 다음 기능은 개인 사용 목적에 부합하지 않아 **의도적으로 범위 밖**:
- **화자 구분 (Speaker Diarization)** — 유료 API 비용/복잡도 대비 개인용에 과함
- **실시간 공동 편집 (CRDT)** — Yjs/Liveblocks 도입 복잡도 대비 이득 없음 - **실시간 공동 편집 (CRDT)** — Yjs/Liveblocks 도입 복잡도 대비 이득 없음
- **권한 관리 / 워크스페이스** — 단일 사용자 가정 - **권한 관리 / 워크스페이스** — 단일 사용자 가정
- **모바일 네이티브 앱** — 웹 PWA로 충분 - **모바일 네이티브 앱** — 웹 PWA로 충분
-164
View File
@@ -1,164 +0,0 @@
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()
})
})
-149
View File
@@ -1,149 +0,0 @@
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)
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest'
import {
LOCAL_SPEAKER,
REMOTE_SPEAKER,
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('모르는 식별자는 그대로 돌려준다', () => {
expect(resolveSpeakerName('spk_3')).toBe('spk_3')
})
})
describe('listAttendees', () => {
it('중복을 제거하고 순서를 유지한다', () => {
expect(
listAttendees([REMOTE_SPEAKER, LOCAL_SPEAKER, REMOTE_SPEAKER]),
).toEqual(['상대', '나'])
})
it('지정된 이름을 반영한다', () => {
expect(
listAttendees([LOCAL_SPEAKER, REMOTE_SPEAKER], {
[LOCAL_SPEAKER]: '배철승',
[REMOTE_SPEAKER]: '김지훈',
}),
).toEqual(['배철승', '김지훈'])
})
})
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import { import {
formatTranscriptChunks, formatTranscriptChunks,
mergeAdjacentChunks, mergeAdjacentChunks,
mergeSpeakerChunks,
type TranscriptChunk, type TranscriptChunk,
} from '@/lib/transcript-formatter' } from '@/lib/transcript-formatter'
@@ -55,3 +56,83 @@ describe('mergeAdjacentChunks', () => {
expect(result).toEqual([]) 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')
})
})
-239
View File
@@ -1,239 +0,0 @@
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('서버 사본이 로컬보다 짧습니다')
})
})
-11
View File
@@ -79,9 +79,6 @@ export async function POST(request: NextRequest) {
customPrompt, customPrompt,
attendees, attendees,
tags, tags,
audioFileName,
audioMimeType,
audioDuration,
} = body } = body
if (!title || typeof title !== 'string' || title.trim().length === 0) { if (!title || typeof title !== 'string' || title.trim().length === 0) {
@@ -115,14 +112,6 @@ export async function POST(request: NextRequest) {
tags: Array.isArray(tags) tags: Array.isArray(tags)
? tags.filter((t) => typeof t === 'string') ? 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', status: 'COMPLETED',
actionItems: { actionItems: {
create: parsed.map((item, index) => ({ create: parsed.map((item, index) => ({
@@ -1,55 +0,0 @@
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 },
)
}
}
-86
View File
@@ -1,86 +0,0 @@
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) },
})
}
-31
View File
@@ -1,31 +0,0 @@
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 },
)
}
}
+1 -20
View File
@@ -1,11 +1,9 @@
'use client' 'use client'
import { useCallback, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { AudioUploader } from '@/components/upload/AudioUploader' import { AudioUploader } from '@/components/upload/AudioUploader'
import { LiveRecorder } from '@/components/recorder/LiveRecorder' import { LiveRecorder } from '@/components/recorder/LiveRecorder'
import type { CompletedRecording } from '@/hooks/useAudioRecorder'
import { extensionForMimeType } from '@/lib/recording'
import { MinutesViewer } from '@/components/minutes/MinutesViewer' import { MinutesViewer } from '@/components/minutes/MinutesViewer'
import { getProviderRequestPayload } from '@/lib/api-key-storage' import { getProviderRequestPayload } from '@/lib/api-key-storage'
import { import {
@@ -45,7 +43,6 @@ export default function HomePage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [uploadedFile, setUploadedFile] = useState<File | null>(null) const [uploadedFile, setUploadedFile] = useState<File | null>(null)
const [audio, setAudio] = useState<CompletedRecording | null>(null)
const templateList = useMemo( const templateList = useMemo(
() => [ () => [
@@ -149,19 +146,6 @@ export default function HomePage() {
generateMinutes(text, summaryMode) generateMinutes(text, summaryMode)
} }
const handleRecordingReady = useCallback((recording: CompletedRecording) => {
setAudio(recording)
}, [])
// 회의록과 함께 저장할 오디오 정보. 서버 사본이 없으면 붙일 게 없다.
const audioMeta = audio?.recordingId
? {
audioFileName: `${audio.recordingId}.${extensionForMimeType(audio.mimeType)}`,
audioMimeType: audio.mimeType,
audioDuration: Math.round(audio.durationMs / 1000),
}
: undefined
const liveSummaryActive = const liveSummaryActive =
summaryMode === 'gemini' && activeTemplateMeta.live && tab === 'record' summaryMode === 'gemini' && activeTemplateMeta.live && tab === 'record'
@@ -344,8 +328,6 @@ export default function HomePage() {
{tab === 'record' ? ( {tab === 'record' ? (
<LiveRecorder <LiveRecorder
onTranscriptReady={handleTranscriptReady} onTranscriptReady={handleTranscriptReady}
onRecordingReady={handleRecordingReady}
title={title}
liveSummaryEnabled={liveSummaryActive} liveSummaryEnabled={liveSummaryActive}
template={template} template={template}
depth={depth} depth={depth}
@@ -409,7 +391,6 @@ export default function HomePage() {
depth={depth} depth={depth}
customPrompt={template === 'custom' ? customPrompt : undefined} customPrompt={template === 'custom' ? customPrompt : undefined}
summaryMode={summaryMode} summaryMode={summaryMode}
audio={audioMeta}
/> />
)} )}
</div> </div>
-8
View File
@@ -22,12 +22,6 @@ interface MinutesViewerProps {
depth?: SummaryDepth depth?: SummaryDepth
customPrompt?: string customPrompt?: string
summaryMode?: 'simple' | 'gemini' summaryMode?: 'simple' | 'gemini'
/** 이 회의록의 원본 오디오. 서버에 보관된 녹음이 있을 때만 붙는다. */
audio?: {
audioFileName: string
audioMimeType: string
audioDuration: number
}
} }
type SaveState = type SaveState =
@@ -44,7 +38,6 @@ export function MinutesViewer({
template, template,
depth, depth,
customPrompt, customPrompt,
audio,
summaryMode, summaryMode,
}: MinutesViewerProps) { }: MinutesViewerProps) {
const [renderedView, setRenderedView] = useState<'rendered' | 'raw'>( const [renderedView, setRenderedView] = useState<'rendered' | 'raw'>(
@@ -117,7 +110,6 @@ export function MinutesViewer({
template, template,
depth, depth,
customPrompt, customPrompt,
...audio,
}), }),
}) })
const data = await res.json() const data = await res.json()
+156 -87
View File
@@ -1,17 +1,22 @@
'use client' 'use client'
import { useEffect, useRef } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition' import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
import { useDualStreamCapture } from '@/hooks/useDualStreamCapture'
import { useLiveSummary } from '@/hooks/useLiveSummary' import { useLiveSummary } from '@/hooks/useLiveSummary'
import { useAudioRecorder, type CompletedRecording } from '@/hooks/useAudioRecorder' import {
import { formatTranscriptChunks } from '@/lib/transcript-formatter' formatTranscriptChunks,
import { formatBytes, formatDuration } from '@/lib/recording' mergeSpeakerChunks,
} from '@/lib/transcript-formatter'
import {
LOCAL_SPEAKER,
REMOTE_SPEAKER,
resolveSpeakerName,
} from '@/lib/speakers'
import type { SummaryDepth, TemplateId } from '@/lib/templates' import type { SummaryDepth, TemplateId } from '@/lib/templates'
interface LiveRecorderProps { interface LiveRecorderProps {
onTranscriptReady: (transcript: string) => void onTranscriptReady: (transcript: string) => void
onRecordingReady?: (recording: CompletedRecording) => void
title: string
liveSummaryEnabled: boolean liveSummaryEnabled: boolean
template: TemplateId template: TemplateId
depth: SummaryDepth depth: SummaryDepth
@@ -20,38 +25,62 @@ interface LiveRecorderProps {
export function LiveRecorder({ export function LiveRecorder({
onTranscriptReady, onTranscriptReady,
onRecordingReady,
title,
liveSummaryEnabled, liveSummaryEnabled,
template, template,
depth, depth,
customPrompt, customPrompt,
}: LiveRecorderProps) { }: LiveRecorderProps) {
const [speakerSeparation, setSpeakerSeparation] = useState(false)
const [pendingStart, setPendingStart] = useState(false)
const [speakerNames, setSpeakerNames] = useState<Record<string, string>>({})
const timeOriginRef = useRef(0)
const capture = useDualStreamCapture()
// 화자 분리를 켜면 트랙마다 인식기를 붙인다. 트랙이 곧 화자가 되므로
// 추론 없이 화자가 갈린다.
// 두 트랙을 실제로 확보했을 때만 화자를 라벨링한다.
// 마이크만 잡힌 상태에서 라벨을 붙이면 상대 발언이 내 것으로 기록된다.
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 { const {
isListening, isListening,
isSupported, isSupported,
chunks,
interimText, interimText,
error: recognitionError, error: recognitionError,
startListening, startListening,
stopListening, } = local
resetChunks,
} = useSpeechRecognition()
const { const chunks = useMemo(
isRecording, () =>
isSupported: isRecordingSupported, isSeparating
error: recordingError, ? mergeSpeakerChunks(local.chunks, remote.chunks)
uploadWarning, : local.chunks,
elapsedMs, [isSeparating, local.chunks, remote.chunks],
localBytes, )
uploadedBytes,
recording, // 캡처가 끝나 트랙이 준비되면 그때 인식기를 시작한다.
startRecording, useEffect(() => {
stopRecording, if (!pendingStart) return
downloadRecording, if (capture.mode === 'idle') return
reset: resetRecording,
} = useAudioRecorder() setPendingStart(false)
local.startListening()
if (capture.systemTrack) remote.startListening()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingStart, capture.mode, capture.systemTrack])
const { const {
summary, summary,
@@ -80,11 +109,6 @@ export function LiveRecorder({
summaryEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) summaryEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [summary]) }, [summary])
// 녹음이 끝나 파일이 확정되면 상위로 올려 회의록과 함께 저장되게 한다.
useEffect(() => {
if (recording) onRecordingReady?.(recording)
}, [recording, onRecordingReady])
if (isSupported === null) { if (isSupported === null) {
return ( return (
<div className="rounded-2xl border border-neutral-200 bg-neutral-50 p-6 text-center text-sm text-neutral-400"> <div className="rounded-2xl border border-neutral-200 bg-neutral-50 p-6 text-center text-sm text-neutral-400">
@@ -105,21 +129,31 @@ export function LiveRecorder({
} }
async function handleStart() { async function handleStart() {
resetRecording() timeOriginRef.current = Date.now()
// 오디오 녹음을 먼저 건다. 전사가 실패하더라도 원본은 남아야 한다.
await startRecording() if (!speakerSeparation) {
startListening() startListening()
return
}
setPendingStart(true)
await capture.start({ withSystemAudio: true })
} }
async function handleStop() { function handleStop() {
stopListening() local.stopListening()
remote.stopListening()
capture.stop()
const transcript = formatTranscriptChunks(chunks) const transcript = formatTranscriptChunks(chunks, speakerNames)
if (transcript.length > 0) { if (transcript.length > 0) {
onTranscriptReady(transcript) onTranscriptReady(transcript)
} }
}
await stopRecording() function handleReset() {
local.resetChunks()
remote.resetChunks()
} }
const lastUpdatedLabel = lastUpdatedAt const lastUpdatedLabel = lastUpdatedAt
@@ -180,21 +214,9 @@ export function LiveRecorder({
</button> </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 && ( {chunks.length > 0 && !isListening && (
<button <button
onClick={() => { onClick={handleReset}
resetChunks()
resetRecording()
}}
className="rounded-full border border-neutral-300 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 transition-colors" className="rounded-full border border-neutral-300 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 transition-colors"
> >
초기화 초기화
@@ -208,35 +230,86 @@ export function LiveRecorder({
</div> </div>
)} )}
{isRecording && ( {isListening && capture.mode === 'dual-stream' && (
<div className="flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-1.5 text-xs text-neutral-600"> <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="text-sm">🎧</span> <span className="h-2 w-2 rounded-full bg-purple-500" />
오디오 {formatDuration(elapsedMs)} · 저장 {formatBytes(uploadedBytes)} 화자 분리 중
{localBytes > 0 && uploadedBytes < localBytes && (
<span className="text-amber-600">
(전송 대기 {formatBytes(localBytes - uploadedBytes)})
</span>
)}
</div> </div>
)} )}
</div> </div>
{isRecordingSupported === false && ( {!isListening && chunks.length === 0 && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800"> <div className="rounded-xl border border-neutral-200 bg-white p-4">
이 브라우저는 오디오 녹음을 지원하지 않습니다. 전사만 진행되며, <label className="flex items-start gap-3 cursor-pointer">
<strong> 놓친 발화를 나중에 복구할 수 없습니다.</strong> Chrome을 사용해주세요. <input
type="checkbox"
checked={speakerSeparation}
onChange={(e) => setSpeakerSeparation(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-purple-600"
/>
<span>
<span className="text-sm font-medium text-neutral-800">
화자 분리 (원격 회의)
</span>
<span className="mt-0.5 block text-xs text-neutral-500">
내 마이크와 상대 목소리를 별도 트랙으로 받아 구분합니다. 추론이 없어
정확합니다. 시작 시 <strong>화면 공유 대화상자에서 &ldquo;탭 오디오 공유&rdquo;를
반드시 켜주세요.</strong>
</span>
<span className="mt-1.5 block text-xs text-amber-700">
한 대의 노트북을 앞에 두고 마주 앉은 <strong>대면 회의에서는 동작하지
않습니다.</strong> 마이크 하나에 두 사람 목소리가 섞여 들어오기 때문입니다.
이 경우 화자 라벨 없이 녹음되며, 대면 화자 분리는 준비 중입니다.
</span>
</span>
</label>
{speakerSeparation && (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs text-neutral-500">내 이름</span>
<input
type="text"
value={speakerNames[LOCAL_SPEAKER] ?? ''}
onChange={(e) =>
setSpeakerNames((prev) => ({
...prev,
[LOCAL_SPEAKER]: e.target.value,
}))
}
placeholder="나"
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-purple-400 focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-neutral-500">상대 이름</span>
<input
type="text"
value={speakerNames[REMOTE_SPEAKER] ?? ''}
onChange={(e) =>
setSpeakerNames((prev) => ({
...prev,
[REMOTE_SPEAKER]: e.target.value,
}))
}
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>
)}
</div> </div>
)} )}
{recordingError && ( {capture.error && (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700"> <div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<strong>오디오 녹음 오류:</strong> {recordingError} <strong>오디오 캡처 오류:</strong> {capture.error}
</div> </div>
)} )}
{uploadWarning && ( {capture.warning && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800"> <div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
<strong>⚠️ 서버 저장 문제:</strong> {uploadWarning} ⚠️ {capture.warning}
</div> </div>
)} )}
@@ -246,23 +319,6 @@ export function LiveRecorder({
</div> </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 && ( {isListening && !hasTranscript && (
<div className="rounded-2xl border border-dashed border-blue-300 bg-blue-50/50 p-6 text-center"> <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"> <p className="text-sm text-blue-700">
@@ -300,7 +356,20 @@ export function LiveRecorder({
{hasTranscript ? ( {hasTranscript ? (
<div className="space-y-1 text-sm font-mono leading-relaxed text-neutral-700"> <div className="space-y-1 text-sm font-mono leading-relaxed text-neutral-700">
{chunks.map((chunk, i) => ( {chunks.map((chunk, i) => (
<p key={i}>{chunk.text}</p> <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>
))} ))}
{interimText && ( {interimText && (
<p className="text-neutral-400 italic"> <p className="text-neutral-400 italic">
-390
View File
@@ -1,390 +0,0 @@
'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,
}
}
+121
View File
@@ -0,0 +1,121 @@
'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 }
}
+40 -5
View File
@@ -3,6 +3,22 @@
import { useState, useRef, useCallback, useEffect } from 'react' import { useState, useRef, useCallback, useEffect } from 'react'
import type { TranscriptChunk } from '@/lib/transcript-formatter' import type { TranscriptChunk } from '@/lib/transcript-formatter'
export interface UseSpeechRecognitionOptions {
/**
* 전사할 오디오 트랙. 지정하면 기본 마이크 대신 이 트랙을 인식한다.
* 트랙마다 인식기를 붙이면 화자가 트랙으로 확정된다.
*/
audioTrack?: MediaStreamTrack | null
/** 이 인식기가 만든 청크에 붙일 화자 식별자. */
speaker?: string
/**
* 타임스탬프 기준 시각(ms). 여러 인식기를 병합하려면 같은 값을 넘겨야
* 한 시간축 위에 놓인다. 생략하면 startListening 호출 시각을 쓴다.
*/
timeOrigin?: number
lang?: string
}
interface SpeechRecognitionHook { interface SpeechRecognitionHook {
isListening: boolean isListening: boolean
isSupported: boolean | null isSupported: boolean | null
@@ -30,7 +46,10 @@ function describeError(code: string): string {
} }
} }
export function useSpeechRecognition(): SpeechRecognitionHook { export function useSpeechRecognition(
options: UseSpeechRecognitionOptions = {},
): SpeechRecognitionHook {
const { audioTrack, speaker, timeOrigin, lang = 'ko-KR' } = options
const [isListening, setIsListening] = useState(false) const [isListening, setIsListening] = useState(false)
const [chunks, setChunks] = useState<TranscriptChunk[]>([]) const [chunks, setChunks] = useState<TranscriptChunk[]>([])
const [interimText, setInterimText] = useState('') const [interimText, setInterimText] = useState('')
@@ -40,6 +59,12 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
const startTimeRef = useRef<number>(0) const startTimeRef = useRef<number>(0)
const networkRetryRef = 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 const MAX_NETWORK_RETRIES = 8
useEffect(() => { useEffect(() => {
@@ -59,12 +84,15 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
const SpeechRecognitionAPI = const SpeechRecognitionAPI =
window.SpeechRecognition || window.webkitSpeechRecognition window.SpeechRecognition || window.webkitSpeechRecognition
const { audioTrack: track, speaker: speakerId, timeOrigin: origin, lang: language } =
configRef.current
const recognition = new SpeechRecognitionAPI() const recognition = new SpeechRecognitionAPI()
recognition.lang = 'ko-KR' recognition.lang = language
recognition.continuous = true recognition.continuous = true
recognition.interimResults = true recognition.interimResults = true
startTimeRef.current = Date.now() startTimeRef.current = origin ?? Date.now()
networkRetryRef.current = 0 networkRetryRef.current = 0
setError(null) setError(null)
@@ -88,6 +116,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
startTime: Math.max(0, now - 2), startTime: Math.max(0, now - 2),
endTime: now, endTime: now,
isFinal: true, isFinal: true,
...(speakerId ? { speaker: speakerId } : {}),
}, },
]) ])
setInterimText('') setInterimText('')
@@ -103,8 +132,14 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
const delay = networkRetryRef.current > 0 ? 1500 : 0 const delay = networkRetryRef.current > 0 ? 1500 : 0
setTimeout(() => { setTimeout(() => {
if (recognitionRef.current !== recognition) return if (recognitionRef.current !== recognition) return
// 트랙이 끝났으면(화면 공유 중단 등) 재시작하지 않는다.
if (track && track.readyState !== 'live') {
setIsListening(false)
recognitionRef.current = null
return
}
try { try {
recognition.start() recognition.start(track ?? undefined)
} catch { } catch {
setIsListening(false) setIsListening(false)
recognitionRef.current = null recognitionRef.current = null
@@ -141,7 +176,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
recognitionRef.current = recognition recognitionRef.current = recognition
try { try {
recognition.start() recognition.start(track ?? undefined)
setIsListening(true) setIsListening(true)
} catch (err) { } catch (err) {
setError( setError(
-195
View File
@@ -1,195 +0,0 @@
/**
* 녹음 조각을 디스크에 이어 붙이는 서버 저장소.
*
* 메모리에 모아 뒀다가 종료 시 한 번에 쓰지 않는다. 그러면 탭이나 서버가
* 죽는 순간 회의 전체가 사라진다. 조각이 도착할 때마다 곧바로 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) }
}
-139
View File
@@ -1,139 +0,0 @@
/**
* 회의 오디오 녹음 — 브라우저·서버가 공유하는 상수와 순수 함수.
*
* 녹음의 목적은 재생이 아니라 **재전사**다. 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
/** 음성 전용이므로 128kbps면 STT에 충분하고도 남는다. */
export const AUDIO_BITS_PER_SECOND = 128_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}`
}
+46
View File
@@ -0,0 +1,46 @@
/**
* 화자 식별자.
*
* dual-stream 캡처에서는 트랙이 곧 화자이므로 추론이 개입하지 않는다.
* 단일 트랙에서 화자를 추정하는 경로가 생기면 'spk_1' 같은 식별자가 추가된다.
*/
export const LOCAL_SPEAKER = 'local'
export const REMOTE_SPEAKER = 'remote'
export type SpeakerNames = Readonly<Record<string, string>>
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
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
}
+41 -2
View File
@@ -1,8 +1,12 @@
import { resolveSpeakerName, type SpeakerNames } from './speakers'
export interface TranscriptChunk { export interface TranscriptChunk {
text: string text: string
startTime: number startTime: number
endTime: number endTime: number
isFinal: boolean isFinal: boolean
/** 화자 식별자. 단일 트랙 녹음에서는 없다. */
speaker?: string
} }
function formatTimestamp(seconds: number): string { function formatTimestamp(seconds: number): string {
@@ -16,14 +20,42 @@ function formatTimestamp(seconds: number): string {
return `[${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}]` return `[${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}]`
} }
export function formatTranscriptChunks(chunks: readonly TranscriptChunk[]): string { export function formatTranscriptChunks(
chunks: readonly TranscriptChunk[],
names?: SpeakerNames,
): string {
if (chunks.length === 0) return '' if (chunks.length === 0) return ''
return chunks return chunks
.map((chunk) => `${formatTimestamp(chunk.startTime)} ${chunk.text}`) .map((chunk) => {
const stamp = formatTimestamp(chunk.startTime)
if (!chunk.speaker) return `${stamp} ${chunk.text}`
return `${stamp} ${resolveSpeakerName(chunk.speaker, names)}: ${chunk.text}`
})
.join('\n') .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( export function mergeAdjacentChunks(
chunks: readonly TranscriptChunk[], chunks: readonly TranscriptChunk[],
gapThreshold: number, gapThreshold: number,
@@ -36,12 +68,19 @@ export function mergeAdjacentChunks(
const current = chunks[i] const current = chunks[i]
const last = result[result.length - 1] const last = result[result.length - 1]
// 화자가 다르면 붙이지 않는다 — 서로 다른 사람의 발화가 한 덩어리가 되면 안 된다.
if (current.speaker !== last.speaker) {
result.push({ ...current })
continue
}
if (current.startTime - last.endTime <= gapThreshold) { if (current.startTime - last.endTime <= gapThreshold) {
result[result.length - 1] = { result[result.length - 1] = {
text: `${last.text} ${current.text}`, text: `${last.text} ${current.text}`,
startTime: last.startTime, startTime: last.startTime,
endTime: current.endTime, endTime: current.endTime,
isFinal: current.isFinal, isFinal: current.isFinal,
...(last.speaker ? { speaker: last.speaker } : {}),
} }
} else { } else {
result.push({ ...current }) result.push({ ...current })
+5 -1
View File
@@ -5,7 +5,11 @@ interface SpeechRecognition extends EventTarget {
onresult: ((event: SpeechRecognitionEvent) => void) | null onresult: ((event: SpeechRecognitionEvent) => void) | null
onend: (() => void) | null onend: (() => void) | null
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
start(): void /**
* audioTrack을 넘기면 기본 마이크 대신 그 트랙을 전사한다 (Chrome 한정).
* 트랙별로 인식기를 붙이면 화자가 트랙 번호로 확정된다.
*/
start(audioTrack?: MediaStreamTrack): void
stop(): void stop(): void
abort(): void abort(): void
} }