mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
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
165 lines
5.6 KiB
TypeScript
165 lines
5.6 KiB
TypeScript
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()
|
|
})
|
|
})
|