diff --git a/.env.example b/.env.example index 3b3e940..3320d7d 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,13 @@ GEMINI_API_KEY= # LLM_BASE_URL= # LLM_MODEL= # LLM_API_KEY= + +# ───────────────────────────────────────────────────────────── +# 회의 오디오 녹음 저장 위치 (선택) +# ───────────────────────────────────────────────────────────── +# 기본값은 <프로젝트>/uploads/recordings 입니다. +# Docker Compose에서는 uploads 볼륨에 저장되어 컨테이너를 재시작해도 남습니다. +# +# ⚠️ 녹음 파일에는 회의 원음이 그대로 들어 있습니다. 디스크 암호화된 위치에 +# 두고, 필요 없어진 녹음은 직접 삭제하세요. SECURITY.md 참고. +# RECORDINGS_DIR= diff --git a/README.md b/README.md index a0e41e5..79aa8b6 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,29 @@ 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`). + **사용** 1. 홈에서 **🎤 녹음 시작** 클릭 2. 마이크 권한 허용 @@ -483,8 +502,9 @@ prisma/ | 제약 | 설명 | 대응 | |---|---|---| -| **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | Phase 4에서 서버 사이드 전사 검토 | -| **화자 구분 없음** | 의도적 비지원 (Non-goal) | — | +| **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | 서버 사이드 STT 전환 예정 | +| **전사 포착률 낮음** | 원거리·다인 대면 회의 실측 10% 안팎 | 오디오 원본 보관 → 서버 STT 재전사 (진행 예정) | +| **화자 구분 없음** | 미구현 (PR #16 / #17 검토 중) | — | | **Gemini 무료 등급 한도** | 15 RPM / 1000 RPD | 한도 초과 시 자동 쿨다운, 단순 변환 폴백 | | **단일 사용자** | 인증 없음, 데이터 격리 없음 | 1인 1인스턴스로 운용 | | **마크다운 검색** | PostgreSQL `ILIKE` (수천 건 이상에서 느려질 수 있음) | 필요 시 `tsvector` 인덱스 추가 | diff --git a/SECURITY.md b/SECURITY.md index 7ae724e..9df9f18 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -132,6 +132,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`(클라이언트 제공)과 파일명 확장자만 검사. 매직 바이트 검증 없음. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2e07501..1c14e85 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -155,6 +155,64 @@ --- +## 🔴 실사용 진단 (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일) ### Issues @@ -199,7 +257,6 @@ 다음 기능은 개인 사용 목적에 부합하지 않아 **의도적으로 범위 밖**: -- **화자 구분 (Speaker Diarization)** — 유료 API 비용/복잡도 대비 개인용에 과함 - **실시간 공동 편집 (CRDT)** — Yjs/Liveblocks 도입 복잡도 대비 이득 없음 - **권한 관리 / 워크스페이스** — 단일 사용자 가정 - **모바일 네이티브 앱** — 웹 PWA로 충분 diff --git a/src/__tests__/recording-store.test.ts b/src/__tests__/recording-store.test.ts new file mode 100644 index 0000000..8d7f12a --- /dev/null +++ b/src/__tests__/recording-store.test.ts @@ -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() + }) +}) diff --git a/src/__tests__/recording.test.ts b/src/__tests__/recording.test.ts new file mode 100644 index 0000000..c6704f8 --- /dev/null +++ b/src/__tests__/recording.test.ts @@ -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"gi|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) + }) +}) diff --git a/src/__tests__/use-audio-recorder.test.ts b/src/__tests__/use-audio-recorder.test.ts new file mode 100644 index 0000000..141fae9 --- /dev/null +++ b/src/__tests__/use-audio-recorder.test.ts @@ -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 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 +let fetchMock: ReturnType + +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('서버 사본이 로컬보다 짧습니다') + }) +}) diff --git a/src/app/api/meetings/route.ts b/src/app/api/meetings/route.ts index cc1aa7c..31a53f7 100644 --- a/src/app/api/meetings/route.ts +++ b/src/app/api/meetings/route.ts @@ -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) => ({ diff --git a/src/app/api/recordings/[id]/chunk/route.ts b/src/app/api/recordings/[id]/chunk/route.ts new file mode 100644 index 0000000..57583dd --- /dev/null +++ b/src/app/api/recordings/[id]/chunk/route.ts @@ -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 }, + ) + } +} diff --git a/src/app/api/recordings/[id]/route.ts b/src/app/api/recordings/[id]/route.ts new file mode 100644 index 0000000..8b7f420 --- /dev/null +++ b/src/app/api/recordings/[id]/route.ts @@ -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) }, + }) +} diff --git a/src/app/api/recordings/route.ts b/src/app/api/recordings/route.ts new file mode 100644 index 0000000..22ffa46 --- /dev/null +++ b/src/app/api/recordings/route.ts @@ -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 }, + ) + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 4d9a937..f4cac57 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,11 @@ '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 { MinutesViewer } from '@/components/minutes/MinutesViewer' import { getProviderRequestPayload } from '@/lib/api-key-storage' import { @@ -43,6 +45,7 @@ export default function HomePage() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [uploadedFile, setUploadedFile] = useState(null) + const [audio, setAudio] = useState(null) const templateList = useMemo( () => [ @@ -146,6 +149,19 @@ export default function HomePage() { 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 = summaryMode === 'gemini' && activeTemplateMeta.live && tab === 'record' @@ -328,6 +344,8 @@ export default function HomePage() { {tab === 'record' ? ( )} diff --git a/src/components/minutes/MinutesViewer.tsx b/src/components/minutes/MinutesViewer.tsx index 8efacdf..533404b 100644 --- a/src/components/minutes/MinutesViewer.tsx +++ b/src/components/minutes/MinutesViewer.tsx @@ -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() diff --git a/src/components/recorder/LiveRecorder.tsx b/src/components/recorder/LiveRecorder.tsx index 9c368dd..dc580f5 100644 --- a/src/components/recorder/LiveRecorder.tsx +++ b/src/components/recorder/LiveRecorder.tsx @@ -3,11 +3,15 @@ import { useEffect, useRef } from 'react' import { useSpeechRecognition } from '@/hooks/useSpeechRecognition' import { useLiveSummary } from '@/hooks/useLiveSummary' +import { useAudioRecorder, type CompletedRecording } from '@/hooks/useAudioRecorder' import { formatTranscriptChunks } from '@/lib/transcript-formatter' +import { formatBytes, formatDuration } from '@/lib/recording' import type { SummaryDepth, TemplateId } from '@/lib/templates' interface LiveRecorderProps { onTranscriptReady: (transcript: string) => void + onRecordingReady?: (recording: CompletedRecording) => void + title: string liveSummaryEnabled: boolean template: TemplateId depth: SummaryDepth @@ -16,6 +20,8 @@ interface LiveRecorderProps { export function LiveRecorder({ onTranscriptReady, + onRecordingReady, + title, liveSummaryEnabled, template, depth, @@ -32,6 +38,21 @@ export function LiveRecorder({ resetChunks, } = useSpeechRecognition() + const { + isRecording, + isSupported: isRecordingSupported, + error: recordingError, + uploadWarning, + elapsedMs, + localBytes, + uploadedBytes, + recording, + startRecording, + stopRecording, + downloadRecording, + reset: resetRecording, + } = useAudioRecorder() + const { summary, isSummarizing, @@ -59,6 +80,11 @@ export function LiveRecorder({ summaryEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) }, [summary]) + // 녹음이 끝나 파일이 확정되면 상위로 올려 회의록과 함께 저장되게 한다. + useEffect(() => { + if (recording) onRecordingReady?.(recording) + }, [recording, onRecordingReady]) + if (isSupported === null) { return (
@@ -78,12 +104,22 @@ export function LiveRecorder({ ) } - function handleStop() { + async function handleStart() { + resetRecording() + // 오디오 녹음을 먼저 건다. 전사가 실패하더라도 원본은 남아야 한다. + await startRecording() + startListening() + } + + async function handleStop() { stopListening() + const transcript = formatTranscriptChunks(chunks) if (transcript.length > 0) { onTranscriptReady(transcript) } + + await stopRecording() } const lastUpdatedLabel = lastUpdatedAt @@ -136,7 +172,7 @@ export function LiveRecorder({ ) : ( )} + {recording && !isListening && ( + + )} + {chunks.length > 0 && !isListening && (
)} + + {isRecording && ( +
+ 🎧 + 오디오 {formatDuration(elapsedMs)} · 저장 {formatBytes(uploadedBytes)} + {localBytes > 0 && uploadedBytes < localBytes && ( + + (전송 대기 {formatBytes(localBytes - uploadedBytes)}) + + )} +
+ )} + {isRecordingSupported === false && ( +
+ 이 브라우저는 오디오 녹음을 지원하지 않습니다. 전사만 진행되며, + 놓친 발화를 나중에 복구할 수 없습니다. Chrome을 사용해주세요. +
+ )} + + {recordingError && ( +
+ 오디오 녹음 오류: {recordingError} +
+ )} + + {uploadWarning && ( +
+ ⚠️ 서버 저장 문제: {uploadWarning} +
+ )} + {recognitionError && (
음성 인식 오류: {recognitionError}
)} + {recording && !isListening && ( +
+ + 🎧 오디오 {formatDuration(recording.durationMs)} ·{' '} + {formatBytes(recording.blob.size)} 확보 + + {' — '} + {recording.recordingId + ? `서버에 ${formatBytes(recording.uploadedBytes)} 저장됨.` + : '서버 저장 실패 — 브라우저 사본만 있습니다.'}{' '} + 전사가 놓친 발화는 이 오디오로 다시 살릴 수 있습니다. + {!recording.recordingId && ( + 지금 내려받아 보관해주세요. + )} +
+ )} + {isListening && !hasTranscript && (

diff --git a/src/hooks/useAudioRecorder.ts b/src/hooks/useAudioRecorder.ts new file mode 100644 index 0000000..c71ed50 --- /dev/null +++ b/src/hooks/useAudioRecorder.ts @@ -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 + stopRecording: () => Promise + 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(null) + const [isRecording, setIsRecording] = useState(false) + const [error, setError] = useState(null) + const [uploadWarning, setUploadWarning] = useState(null) + const [elapsedMs, setElapsedMs] = useState(0) + const [localBytes, setLocalBytes] = useState(0) + const [uploadedBytes, setUploadedBytes] = useState(0) + const [recording, setRecording] = useState(null) + + const recorderRef = useRef(null) + const streamRef = useRef(null) + const partsRef = useRef([]) + const sessionIdRef = useRef(null) + const mimeTypeRef = useRef(null) + const startedAtRef = useRef(null) + const uploadChainRef = useRef>(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((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, + } +} diff --git a/src/lib/recording-store.ts b/src/lib/recording-store.ts new file mode 100644 index 0000000..83e2744 --- /dev/null +++ b/src/lib/recording-store.ts @@ -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>() + +function enqueue(id: string, task: () => Promise): Promise { + 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 { + try { + return (await stat(filePath)).size + } catch { + return 0 + } +} + +/** 새 녹음 세션을 만든다. 오디오 파일은 첫 조각이 도착할 때 생긴다. */ +export async function createRecording( + mimeType: string, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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) } +} diff --git a/src/lib/recording.ts b/src/lib/recording.ts new file mode 100644 index 0000000..0d417b9 --- /dev/null +++ b/src/lib/recording.ts @@ -0,0 +1,139 @@ +/** + * 회의 오디오 녹음 — 브라우저·서버가 공유하는 상수와 순수 함수. + * + * 녹음의 목적은 재생이 아니라 **재전사**다. 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}` +}