feat: 회의 오디오 원본 보관 — 전사가 놓친 발화를 되살릴 수 있게

46분 대면 회의 실측에서 Web Speech 포착률이 7~10%에 그쳤다(489어절 /
분당 10.6어절, 30초 이상 공백 26곳 26분 29초). 빈 텍스트 청크 19개는
Chrome이 `isFinal: true`에 `transcript: ""`를 준 것으로, 소리는 감지했으나
인식에 실패했다는 신호다.

지금까지는 오디오를 어디에도 남기지 않아 놓친 발화를 복구할 수도, 얼마나
놓쳤는지 잴 수도 없었다. 전사와 독립적으로 원음을 파일로 남긴다.

## 유실 방지 설계

메모리에 모았다가 종료 시 한 번에 쓰지 않는다. 그러면 탭이나 서버가 죽는
순간 회의 전체가 사라진다. 15초 조각마다 디스크에 append 하므로 어느
시점에 중단되든 그때까지의 오디오는 남는다.

- 서버 업로드가 실패해도 녹음을 멈추지 않고 브라우저 사본으로 회수한다
- 조각이 3회 재시도 후에도 실패하면 뒤를 이어 붙이지 않고 멈춘다.
  중간이 빈 파일은 짧은 파일보다 나쁘다 — 재생도 전사도 안 된다
- 한 조각도 못 받았으면 "보관됨"이라고 하지 않고 오류를 띄운다
- 서버 사본이 로컬보다 짧으면 경고한다

## 캡처 제약 변경

`noiseSuppression: false, echoCancellation: false, autoGainControl: true`.
브라우저 기본값은 전화 통화용 튜닝이라 멀리 앉은 화자를 노이즈로 지운다.
AGC는 조용한 화자를 끌어올려 주므로 남긴다. 실측으로 검증할 가설이며
회귀 방지 테스트를 걸어 뒀다.

## 검증

- 서버 파이프라인: 184조각 755KB 업로드 → 다운로드 SHA256 바이트 일치
- 클라이언트 전 경로: 합성 스트림으로 48초 녹음 → 로컬·서버 크기 일치,
  서버 파일이 decodeAudioData로 48.12초 실제 오디오로 디코딩됨
- 경로 조작 400 / 없는 세션 404 / 빈 조각 400 / 미허용 mimeType 400
- 테스트 162 → 206 통과, 신규 타입 에러 0, 린트 baseline과 동일

실제 마이크 경로는 테스트하지 못했다. Web Speech와 getUserMedia가 같은
마이크를 두고 경합하는지 실사용 확인이 필요하다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzAH7GPWSYTe2AoBV6CZDP
This commit is contained in:
csbaeandClaude Opus 5 committed 2026-09-09 15:04:04 +09:00
1 parent 9af00c3016
commit cf3ce3f40c
17 files changed
+1696 -7

No files matched your search

+164
View File
@@ -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()
})
})
+149
View File
@@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest'
import {
PREFERRED_MIME_TYPES,
RECORDING_AUDIO_CONSTRAINTS,
extensionForMimeType,
formatBytes,
formatDuration,
isValidRecordingId,
pickRecorderMimeType,
recordingDownloadName,
} from '@/lib/recording'
describe('pickRecorderMimeType', () => {
it('가장 앞선 후보를 고른다', () => {
const picked = pickRecorderMimeType(() => true)
expect(picked).toBe(PREFERRED_MIME_TYPES[0])
})
it('지원하지 않는 후보는 건너뛴다', () => {
const picked = pickRecorderMimeType((type) => type === 'audio/mp4')
expect(picked).toBe('audio/mp4')
})
it('아무것도 지원하지 않으면 null', () => {
expect(pickRecorderMimeType(() => false)).toBeNull()
})
})
describe('extensionForMimeType', () => {
it('코덱 파라미터를 떼고 판단한다', () => {
expect(extensionForMimeType('audio/webm;codecs=opus')).toBe('webm')
expect(extensionForMimeType('audio/ogg; codecs=opus')).toBe('ogg')
})
it('주요 컨테이너를 매핑한다', () => {
expect(extensionForMimeType('audio/webm')).toBe('webm')
expect(extensionForMimeType('audio/mp4')).toBe('m4a')
expect(extensionForMimeType('audio/mpeg')).toBe('mp3')
expect(extensionForMimeType('audio/wav')).toBe('wav')
})
it('대소문자를 가리지 않는다', () => {
expect(extensionForMimeType('AUDIO/WEBM')).toBe('webm')
})
it('모르는 형식은 bin으로 떨어진다', () => {
expect(extensionForMimeType('application/octet-stream')).toBe('bin')
})
})
describe('isValidRecordingId', () => {
const valid = 'a'.repeat(32)
it('32자 hex만 통과시킨다', () => {
expect(isValidRecordingId(valid)).toBe(true)
expect(isValidRecordingId('0123456789abcdef0123456789abcdef')).toBe(true)
})
it('경로 조작 시도를 막는다', () => {
expect(isValidRecordingId('../../etc/passwd')).toBe(false)
expect(isValidRecordingId(`${valid}/../x`)).toBe(false)
expect(isValidRecordingId('..')).toBe(false)
expect(isValidRecordingId(`../${valid}`)).toBe(false)
})
it('길이나 문자셋이 어긋나면 거부한다', () => {
expect(isValidRecordingId('a'.repeat(31))).toBe(false)
expect(isValidRecordingId('a'.repeat(33))).toBe(false)
expect(isValidRecordingId('A'.repeat(32))).toBe(false)
expect(isValidRecordingId('g'.repeat(32))).toBe(false)
expect(isValidRecordingId('')).toBe(false)
})
it('문자열이 아니면 거부한다', () => {
expect(isValidRecordingId(null)).toBe(false)
expect(isValidRecordingId(undefined)).toBe(false)
expect(isValidRecordingId(123)).toBe(false)
})
})
describe('recordingDownloadName', () => {
const at = new Date(2026, 8, 8, 14, 5)
it('날짜와 제목을 붙인다', () => {
expect(recordingDownloadName('주간 회의', at, 'audio/webm')).toBe(
'20260908-1405_주간_회의.webm',
)
})
it('제목이 없으면 날짜만 쓴다', () => {
expect(recordingDownloadName(' ', at, 'audio/webm')).toBe(
'20260908-1405.webm',
)
})
it('파일명에 못 쓰는 문자를 지운다', () => {
const name = recordingDownloadName('a/b\\c:d*e?f"g<h>i|j', at, 'audio/webm')
expect(name).toBe('20260908-1405_abcdefghij.webm')
expect(name).not.toMatch(/[\\/:*?"<>|]/)
})
it('아주 긴 제목을 자른다', () => {
const name = recordingDownloadName('가'.repeat(200), at, 'audio/webm')
expect(name.length).toBeLessThan(90)
})
})
describe('formatBytes', () => {
it('단위를 바꿔가며 표기한다', () => {
expect(formatBytes(512)).toBe('512B')
expect(formatBytes(2048)).toBe('2KB')
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0MB')
})
it('비정상 값은 0으로 떨어진다', () => {
expect(formatBytes(-1)).toBe('0B')
expect(formatBytes(NaN)).toBe('0B')
})
})
describe('formatDuration', () => {
it('한 시간 미만은 분:초', () => {
expect(formatDuration(0)).toBe('00:00')
expect(formatDuration(65_000)).toBe('01:05')
expect(formatDuration(46 * 60_000 + 6_000)).toBe('46:06')
})
it('한 시간 이상은 시:분:초', () => {
expect(formatDuration(3_600_000)).toBe('1:00:00')
expect(formatDuration(3_725_000)).toBe('1:02:05')
})
it('음수는 0으로 본다', () => {
expect(formatDuration(-5_000)).toBe('00:00')
})
})
describe('RECORDING_AUDIO_CONSTRAINTS', () => {
it('원거리 화자를 지우는 전처리를 끈다', () => {
// 46분 대면 회의에서 포착률이 10% 안팎에 그친 원인 중 하나.
// 이 값이 다시 true로 돌아가면 회귀다.
expect(RECORDING_AUDIO_CONSTRAINTS.noiseSuppression).toBe(false)
expect(RECORDING_AUDIO_CONSTRAINTS.echoCancellation).toBe(false)
})
it('조용한 화자를 끌어올리는 AGC는 남긴다', () => {
expect(RECORDING_AUDIO_CONSTRAINTS.autoGainControl).toBe(true)
})
})
+239
View File
@@ -0,0 +1,239 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useAudioRecorder } from '@/hooks/useAudioRecorder'
/**
* jsdom에는 MediaRecorder도 getUserMedia도 없다. 훅이 다루는 것은
* "조각이 언제 오고 어디로 가는가"이므로 그 둘만 흉내 내면 충분하다.
*/
class FakeMediaRecorder {
static isTypeSupported = () => true
state: 'inactive' | 'recording' = 'inactive'
ondataavailable: ((event: { data: Blob }) => void) | null = null
onerror: (() => void) | null = null
private listeners: Record<string, Array<() => void>> = {}
constructor(
public stream: MediaStream,
public options?: MediaRecorderOptions,
) {
instances.push(this)
}
start() {
this.state = 'recording'
}
stop() {
this.state = 'inactive'
// 실제 MediaRecorder는 stop() 시 남은 버퍼를 한 번 더 내보낸 뒤 stop을 쏜다.
for (const fn of this.listeners.stop ?? []) fn()
}
addEventListener(type: string, fn: () => void) {
;(this.listeners[type] ??= []).push(fn)
}
emit(bytes: number) {
this.ondataavailable?.({ data: new Blob([new Uint8Array(bytes)]) })
}
}
let instances: FakeMediaRecorder[] = []
let stopTracks: ReturnType<typeof vi.fn>
let fetchMock: ReturnType<typeof vi.fn>
function jsonResponse(body: unknown, ok = true, status = 200) {
return { ok, status, json: async () => body }
}
beforeEach(() => {
instances = []
stopTracks = vi.fn()
vi.stubGlobal('MediaRecorder', FakeMediaRecorder)
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(async () => ({
getTracks: () => [{ stop: stopTracks }],
})),
},
})
fetchMock = vi.fn(async (url: string) => {
if (url === '/api/recordings') {
return jsonResponse({ id: 'a'.repeat(32), mimeType: 'audio/webm' }, true, 201)
}
if (url.endsWith('/chunk')) return jsonResponse({ bytes: 1024 })
return jsonResponse({ bytes: 1024 })
})
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
async function startedHook() {
const view = renderHook(() => useAudioRecorder())
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
await act(async () => {
await view.result.current.startRecording()
})
return view
}
describe('useAudioRecorder — 캡처 제약', () => {
it('원거리 화자를 지우는 전처리를 끄고 마이크를 연다', async () => {
await startedHook()
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({
audio: expect.objectContaining({
noiseSuppression: false,
echoCancellation: false,
autoGainControl: true,
}),
})
})
})
describe('useAudioRecorder — 조각 업로드', () => {
it('조각이 생길 때마다 서버로 올린다', async () => {
const view = await startedHook()
await act(async () => {
instances[0].emit(2048)
})
await waitFor(() => {
const chunkCalls = fetchMock.mock.calls.filter((c) =>
String(c[0]).endsWith('/chunk'),
)
expect(chunkCalls).toHaveLength(1)
})
expect(view.result.current.localBytes).toBe(2048)
})
it('빈 조각은 올리지 않는다', async () => {
await startedHook()
await act(async () => {
instances[0].emit(0)
})
const chunkCalls = fetchMock.mock.calls.filter((c) =>
String(c[0]).endsWith('/chunk'),
)
expect(chunkCalls).toHaveLength(0)
})
it('업로드가 끝내 실패하면 뒤 조각을 이어 붙이지 않고 경고한다', async () => {
// 중간이 빈 파일은 짧은 파일보다 나쁘다 — 재생도 전사도 안 된다.
fetchMock.mockImplementation(async (url: string) => {
if (url === '/api/recordings') {
return jsonResponse({ id: 'a'.repeat(32) }, true, 201)
}
if (url.endsWith('/chunk')) return jsonResponse({ error: 'nope' }, false, 500)
return jsonResponse({})
})
const view = await startedHook()
await act(async () => {
instances[0].emit(1024)
})
// 500은 일시 장애일 수 있으므로 백오프를 두고 3회까지 재시도한다.
await waitFor(
() => {
expect(view.result.current.uploadWarning).toContain('서버 저장이 중단')
},
{ timeout: 5_000 },
)
const callsAfterBreak = fetchMock.mock.calls.filter((c) =>
String(c[0]).endsWith('/chunk'),
).length
await act(async () => {
instances[0].emit(1024)
})
expect(
fetchMock.mock.calls.filter((c) => String(c[0]).endsWith('/chunk')).length,
).toBe(callsAfterBreak)
})
it('세션 생성이 실패해도 녹음은 계속된다', async () => {
fetchMock.mockImplementation(async (url: string) => {
if (url === '/api/recordings') {
return jsonResponse({ error: 'down' }, false, 500)
}
return jsonResponse({})
})
const view = await startedHook()
expect(view.result.current.isRecording).toBe(true)
expect(view.result.current.uploadWarning).toContain('내려받아')
await act(async () => {
instances[0].emit(4096)
})
expect(view.result.current.localBytes).toBe(4096)
})
})
describe('useAudioRecorder — 종료', () => {
it('오디오를 한 조각도 못 받았으면 보관됐다고 말하지 않는다', async () => {
const view = await startedHook()
await act(async () => {
await view.result.current.stopRecording()
})
expect(view.result.current.recording).toBeNull()
expect(view.result.current.error).toContain('한 조각도 캡처되지 않았습니다')
expect(view.result.current.isRecording).toBe(false)
})
it('받은 조각이 있으면 결과를 넘기고 마이크를 놓는다', async () => {
const view = await startedHook()
await act(async () => {
instances[0].emit(1024)
})
await act(async () => {
await view.result.current.stopRecording()
})
expect(view.result.current.recording?.blob.size).toBe(1024)
expect(view.result.current.recording?.recordingId).toBe('a'.repeat(32))
expect(stopTracks).toHaveBeenCalled()
expect(view.result.current.isRecording).toBe(false)
})
it('서버 사본이 로컬보다 짧으면 경고한다', async () => {
fetchMock.mockImplementation(async (url: string) => {
if (url === '/api/recordings') {
return jsonResponse({ id: 'a'.repeat(32) }, true, 201)
}
if (url.endsWith('/chunk')) return jsonResponse({ bytes: 10 })
return jsonResponse({ bytes: 10 })
})
const view = await startedHook()
await act(async () => {
instances[0].emit(4096)
})
await act(async () => {
await view.result.current.stopRecording()
})
expect(view.result.current.uploadWarning).toContain('서버 사본이 로컬보다 짧습니다')
})
})