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
87 lines
2.7 KiB
TypeScript
87 lines
2.7 KiB
TypeScript
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) },
|
|
})
|
|
}
|