diff --git a/README.md b/README.md index 2c8031e..82a8507 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,19 @@ docker compose up -d > ⚠️ **Web Speech API는 발화를 상당량 놓칩니다.** 원거리·다인 대면 회의에서 > 실측 포착률이 10% 안팎이었습니다(46분 회의 / 489어절). 회의 전사용으로 -> 설계된 엔진이 아니라 근접 음성 명령용입니다. 그래서 **오디오 원본을 반드시 -> 함께 남깁니다** — 놓친 발화는 나중에 서버 STT로 다시 살릴 수 있습니다. +> 설계된 엔진이 아니라 근접 음성 명령용입니다. 그래서 이 패널은 **미리보기**이고, +> 확정본은 종료 후 [서버 STT 재전사](#-서버-stt-재전사-정확도의-본선)로 만듭니다. + +**유실 방지** — Chrome이 흘리는 것을 최대한 붙잡습니다. + +- **미확정 발화 보존** — Chrome은 `continuous`여도 침묵마다 세션을 닫으면서 확정 전 + 문장을 버립니다. 세션이 끝날 때마다 직접 확정시켜 살려냅니다 +- **마지막 발화 보존** — 중지 시점에 확정되지 않은 회의 끝머리도 함께 넘깁니다 +- **인식 실패 가시화** — Chrome이 "소리는 들었으나 못 알아듣겠다"고 답한 구간 + (`isFinal: true` + 빈 텍스트)은 요약 프롬프트를 오염시키지 않도록 청크에서 빼되, + **`⚠️ 인식 실패 N건` 배지로 세어 보여줍니다.** 실측 46분 회의에서 19건이었습니다 +- **실제 타임스탬프** — 발화가 처음 들린 시각을 시작으로 잡고, 오디오 녹음과 같은 + 시간축을 씁니다 (예전에는 `결과 이벤트 시각 − 2초` 하드코딩) ### 🎧 오디오 원본 보관 @@ -120,6 +131,23 @@ Gemini는 webm 오디오를 받지 않습니다. **실시간 녹음을 전사하 --- +### 📏 긴 회의 (map-reduce 요약) + +전사문이 6만 자를 넘으면 한 번에 모델에 넣지 못합니다. 예전에는 10만 자에서 +**413으로 거부**해, 3시간짜리 회의를 마치고 회의록을 만들려는 순간 아무것도 받지 +못했습니다. 유실을 막자고 만든 파이프라인 끝에서 결과를 통째로 버리는 셈이었습니다. + +지금은 구간별로 한 번 압축한 뒤(map), 압축본을 모아 **평소와 똑같은 템플릿 경로로** +회의록을 만듭니다(reduce). 최종 출력이 짧은 회의와 같은 프롬프트를 타므로 서식이 +흔들리지 않습니다. + +- 압축 창 4만 자 · 단일 통과 상한 6만 자 · 전체 상한 50만 자(약 14시간 분량) +- **한 구간이라도 실패하면 전체를 실패로 돌립니다.** 일부만 빠진 압축본으로 회의록을 + 만들면 사용자는 무엇이 빠졌는지 모른 채 멀쩡해 보이는 문서를 받게 됩니다 +- 압축을 거치면 회의록 상단에 몇 개 구간으로 나눴는지 안내합니다 + +--- + ### 🤖 AI 회의록 (6 템플릿 × 3 강도) 회의 외에도 다양한 용도에 맞춰 출력 구조를 선택할 수 있습니다. @@ -541,7 +569,7 @@ prisma/ | 제약 | 설명 | 대응 | |---|---|---| | **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | 서버 사이드 STT 전환 예정 | -| **Web Speech 포착률 낮음** | 원거리·다인 대면 회의 실측 10% 안팎 | 종료 후 **서버 STT 재전사**로 해결 | +| **Web Speech 포착률 낮음** | 원거리·다인 대면 회의 실측 10% 안팎 | 미리보기로 강등, 종료 후 **서버 STT 재전사**로 해결 | | **Gemini는 webm 전사 불가** | Gemini가 받는 오디오 형식에 webm이 없음 | 녹음 전사는 OpenAI 호환 프로바이더 사용 | | **긴 회의 전사 상한** | Whisper 25MB(≈1시간 40분) / Gemini inline 14MB(≈1시간) | 초과 시 오류 안내. 분할 전사는 후속 | | **화자 구분 없음** | 미구현 (PR #16 / #17 검토 중) | — | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 119ca52..9b88b60 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -197,9 +197,18 @@ flush해서 7%를 20~30%로는 올려도 90%로는 못 간다. 구현 결함이 - [ ] 상한 초과 회의 분할 전사 (Whisper 25MB / Gemini inline 14MB) - [ ] Gemini Files API 경로 (큰 파일 + webm 우회) - [ ] Web Speech를 "실시간 미리보기"로 명시적 강등 (현재는 둘 다 노출) -- [ ] **빈 청크 필터링** — 빈 발화 19개가 요약 프롬프트를 오염시키고 있음 -- [ ] **타임스탬프 실측화** — `useSpeechRecognition.ts`의 `startTime: now - 2` 하드코딩 제거 -- [ ] **10만 자 하드 실패 → 분할 요약** — 긴 회의가 마지막에 통째로 실패함 +- [x] **Web Speech 유실 지점 정리** + - [x] 빈 확정 결과를 청크에서 빼고 "인식 실패 N건"으로 노출 (실측 19건) + - [x] 세션 재시작 시 미확정 발화 보존 — Chrome이 버리던 것을 직접 확정 + - [x] 중지 시 마지막 발화 보존 — `stopListening()`이 최종 청크를 돌려줌 + - [x] 실시간 텍스트 패널을 "미리보기"로 강등 +- [x] **타임스탬프 실측화** — `startTime: now - 2` 하드코딩 제거 + - [x] interim이 처음 뜬 시각을 발화 시작으로 사용 + - [x] `startRecording()`이 캡처 시작 시각을 돌려주고 인식기가 같은 시간축 사용 +- [x] **10만 자 하드 실패 → 분할 요약** + - [x] 구간 압축(map) → 기존 템플릿 경로로 회의록(reduce) + - [x] 상한 10만 → 50만 자 (약 14시간 분량) + - [x] 한 구간이라도 실패하면 전체 실패 — 조용히 빠진 회의록을 만들지 않음 ## 🎯 P1 — 입력단 개선 diff --git a/src/__tests__/long-transcript.test.ts b/src/__tests__/long-transcript.test.ts new file mode 100644 index 0000000..17ac6c6 --- /dev/null +++ b/src/__tests__/long-transcript.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi } from 'vitest' +import { + CONDENSE_WINDOW_CHARS, + MAX_TRANSCRIPT_CHARS, + SINGLE_PASS_CHAR_LIMIT, + condenseTranscript, + planTranscriptWindows, +} from '@/lib/long-transcript' + +function okResponse(text: string) { + return { + ok: true, + status: 200, + json: async () => ({ + candidates: [{ content: { parts: [{ text }] } }], + }), + } as unknown as Response +} + +const settings = { provider: 'gemini' as const, apiKey: 'AIza-test' } + +describe('planTranscriptWindows', () => { + it('상한 안이면 통째로 하나', () => { + expect(planTranscriptWindows('짧은 전사문', 100)).toEqual(['짧은 전사문']) + }) + + it('빈 입력은 구간이 없다', () => { + expect(planTranscriptWindows(' ', 100)).toEqual([]) + }) + + it('줄 경계에서 나눈다', () => { + const lines = ['[00:00] 가나다', '[00:05] 라마바', '[00:10] 사아자'] + const windows = planTranscriptWindows(lines.join('\n'), 25) + + expect(windows.length).toBeGreaterThan(1) + for (const w of windows) { + // 줄 중간에서 끊기지 않았다 + for (const line of w.split('\n')) { + expect(lines).toContain(line) + } + } + }) + + it('어떤 내용도 잃지 않는다', () => { + const lines = Array.from({ length: 500 }, (_, i) => `[00:${i % 60}] 발화 ${i}`) + const source = lines.join('\n') + + const windows = planTranscriptWindows(source, 200) + + expect(windows.join('\n')).toBe(source) + }) + + it('상한을 넘는 구간은 없다', () => { + const source = Array.from({ length: 300 }, (_, i) => `줄 ${i}`).join('\n') + for (const w of planTranscriptWindows(source, 50)) { + expect(w.length).toBeLessThanOrEqual(50) + } + }) + + it('한 줄이 통째로 상한보다 길면 그 줄만 강제로 자른다', () => { + const long = 'ㄱ'.repeat(250) + const windows = planTranscriptWindows(long, 100) + + expect(windows).toHaveLength(3) + expect(windows.join('')).toBe(long) + }) + + it('maxChars가 0 이하면 거부한다', () => { + expect(() => planTranscriptWindows('가', 0)).toThrow() + }) + + it('압축 창은 단일 통과 상한보다 작다', () => { + // 압축한 결과를 다시 한 번에 넣을 수 있어야 map-reduce가 성립한다. + expect(CONDENSE_WINDOW_CHARS).toBeLessThan(SINGLE_PASS_CHAR_LIMIT) + expect(SINGLE_PASS_CHAR_LIMIT).toBeLessThan(MAX_TRANSCRIPT_CHARS) + }) +}) + +describe('condenseTranscript', () => { + it('구간마다 한 번씩 호출하고 결과를 이어 붙인다', async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(okResponse('앞부분 정리')) + .mockResolvedValueOnce(okResponse('뒷부분 정리')) + + const source = Array.from({ length: 40 }, (_, i) => `줄 ${i}`).join('\n') + const result = await condenseTranscript(source, { + provider: settings, + windowChars: 120, + fetchFn, + }) + + expect(result.success).toBe(true) + if (!result.success) return + + expect(fetchFn).toHaveBeenCalledTimes(2) + expect(result.windows).toBe(2) + expect(result.text).toContain('앞부분 정리') + expect(result.text).toContain('뒷부분 정리') + expect(result.text).toContain('구간 1/2') + }) + + it('프롬프트에 구간 위치를 알려 앞뒤를 지어내지 않게 한다', async () => { + const fetchFn = vi.fn().mockResolvedValue(okResponse('정리')) + const source = Array.from({ length: 40 }, (_, i) => `줄 ${i}`).join('\n') + + await condenseTranscript(source, { + provider: settings, + windowChars: 120, + fetchFn, + }) + + const body = JSON.parse( + (fetchFn.mock.calls[0][1] as RequestInit).body as string, + ) + const prompt = body.contents[0].parts[0].text + expect(prompt).toContain('1/2 구간') + expect(prompt).toContain('추측해 채우지 않습니다') + }) + + it('한 구간이라도 실패하면 전체를 실패로 돌린다', async () => { + // 일부만 빠진 압축본으로 회의록을 만들면 사용자는 무엇이 빠졌는지 모른다. + const fetchFn = vi + .fn() + .mockResolvedValueOnce(okResponse('앞부분')) + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({}), + } as unknown as Response) + + const source = Array.from({ length: 40 }, (_, i) => `줄 ${i}`).join('\n') + const result = await condenseTranscript(source, { + provider: settings, + windowChars: 120, + fetchFn, + }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error).toContain('2/2 구간') + }) + + it('빈 응답도 실패로 본다', async () => { + const fetchFn = vi.fn().mockResolvedValue(okResponse(' ')) + const result = await condenseTranscript('짧은 글', { + provider: settings, + fetchFn, + }) + + expect(result.success).toBe(false) + }) + + it('429는 rateLimited로 올려보낸다', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + json: async () => ({}), + } as unknown as Response) + + const result = await condenseTranscript('짧은 글', { + provider: settings, + fetchFn, + }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.rateLimited).toBe(true) + }) + + it('빈 전사문은 호출조차 하지 않는다', async () => { + const fetchFn = vi.fn() + const result = await condenseTranscript(' ', { + provider: settings, + fetchFn, + }) + + expect(fetchFn).not.toHaveBeenCalled() + expect(result.success).toBe(false) + }) +}) diff --git a/src/__tests__/use-audio-recorder.test.ts b/src/__tests__/use-audio-recorder.test.ts index 141fae9..d531900 100644 --- a/src/__tests__/use-audio-recorder.test.ts +++ b/src/__tests__/use-audio-recorder.test.ts @@ -99,6 +99,45 @@ describe('useAudioRecorder — 캡처 제약', () => { }) }) +describe('useAudioRecorder — 시간축', () => { + it('캡처 시작 시각을 돌려준다', async () => { + // 이 값을 인식기에 넘겨야 전사 타임스탬프가 오디오 파일과 같은 축을 쓴다. + const before = Date.now() + const view = renderHook(() => useAudioRecorder()) + await waitFor(() => expect(view.result.current.isSupported).toBe(true)) + + let startedAt: number | null = null + await act(async () => { + startedAt = await view.result.current.startRecording() + }) + + expect(startedAt).not.toBeNull() + expect(startedAt!).toBeGreaterThanOrEqual(before) + expect(startedAt!).toBeLessThanOrEqual(Date.now()) + }) + + it('마이크를 못 열면 null', async () => { + vi.stubGlobal('navigator', { + mediaDevices: { + getUserMedia: vi.fn(async () => { + throw Object.assign(new Error('denied'), { name: 'NotAllowedError' }) + }), + }, + }) + + const view = renderHook(() => useAudioRecorder()) + await waitFor(() => expect(view.result.current.isSupported).toBe(true)) + + let startedAt: number | null = 0 + await act(async () => { + startedAt = await view.result.current.startRecording() + }) + + expect(startedAt).toBeNull() + expect(view.result.current.error).toContain('마이크 권한') + }) +}) + describe('useAudioRecorder — 조각 업로드', () => { it('조각이 생길 때마다 서버로 올린다', async () => { const view = await startedHook() diff --git a/src/__tests__/use-speech-recognition.test.ts b/src/__tests__/use-speech-recognition.test.ts new file mode 100644 index 0000000..38438e7 --- /dev/null +++ b/src/__tests__/use-speech-recognition.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' +import { useSpeechRecognition } from '@/hooks/useSpeechRecognition' + +/** + * jsdom에는 SpeechRecognition이 없다. 여기서 검증하려는 것은 인식 품질이 아니라 + * **Chrome이 준 것을 우리가 흘리지 않는가**이므로, 이벤트를 직접 쏠 수 있는 + * 최소 구현이면 충분하다. + */ + +interface FakeResult { + isFinal: boolean + 0: { transcript: string } +} + +class FakeSpeechRecognition { + lang = '' + continuous = false + interimResults = false + + onresult: ((event: { resultIndex: number; results: FakeResult[] }) => void) | null = null + onend: (() => void) | null = null + onerror: ((event: { error: string }) => void) | null = null + + started = 0 + + constructor() { + instances.push(this) + } + + start() { + this.started += 1 + } + + stop() { + // 실제 Chrome은 stop() 뒤에도 onend를 쏘지만, 훅이 ref를 먼저 끊어 + // 재시작을 막는다. 그 동작을 그대로 흉내 낸다. + this.onend?.() + } + + /** Chrome이 결과를 내보내는 것을 흉내 낸다. */ + emit(...results: Array<{ text: string; final: boolean }>) { + this.onresult?.({ + resultIndex: 0, + results: results.map((r) => ({ + isFinal: r.final, + 0: { transcript: r.text }, + })), + }) + } +} + +let instances: FakeSpeechRecognition[] = [] + +beforeEach(() => { + instances = [] + vi.stubGlobal('SpeechRecognition', FakeSpeechRecognition) + vi.stubGlobal('webkitSpeechRecognition', FakeSpeechRecognition) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +async function listening(timeOrigin?: number) { + const view = renderHook(() => useSpeechRecognition()) + await waitFor(() => expect(view.result.current.isSupported).toBe(true)) + act(() => view.result.current.startListening(timeOrigin)) + return view +} + +describe('확정된 발화', () => { + it('청크로 쌓인다', async () => { + const view = await listening() + + act(() => instances[0].emit({ text: '안녕하세요', final: true })) + + expect(view.result.current.chunks).toHaveLength(1) + expect(view.result.current.chunks[0].text).toBe('안녕하세요') + expect(view.result.current.chunks[0].isFinal).toBe(true) + }) + + it('빈 확정 결과는 청크로 넣지 않고 유실로 센다', async () => { + // 실측 46분 회의에서 19번 나온 케이스. Chrome이 "소리는 들었는데 + // 못 알아듣겠다"고 답한 것으로, 청크로 넣으면 요약 프롬프트가 오염된다. + const view = await listening() + + act(() => instances[0].emit({ text: ' ', final: true })) + act(() => instances[0].emit({ text: '', final: true })) + + expect(view.result.current.chunks).toHaveLength(0) + expect(view.result.current.missedCount).toBe(2) + }) +}) + +describe('미확정 발화 보존', () => { + it('세션이 끊길 때 확정 전 발화를 살려낸다', async () => { + // Chrome은 continuous여도 침묵마다 세션을 닫고, 그때 확정 전 문장을 버린다. + const view = await listening() + + act(() => instances[0].emit({ text: '아직 확정 안 된 말', final: false })) + expect(view.result.current.interimText).toBe('아직 확정 안 된 말') + expect(view.result.current.chunks).toHaveLength(0) + + act(() => instances[0].onend?.()) + + expect(view.result.current.chunks).toHaveLength(1) + expect(view.result.current.chunks[0].text).toBe('아직 확정 안 된 말') + // 우리가 살려낸 값이지 Chrome이 확정해 준 값이 아니다. + expect(view.result.current.chunks[0].isFinal).toBe(false) + expect(view.result.current.interimText).toBe('') + }) + + it('중지할 때도 마지막 발화를 살려낸다', async () => { + // 회의 끝머리가 통째로 사라지던 자리. + const view = await listening() + + act(() => instances[0].emit({ text: '수고하셨습니다', final: false })) + + let finalChunks: ReturnType = [] + act(() => { + finalChunks = view.result.current.stopListening() + }) + + expect(finalChunks).toHaveLength(1) + expect(finalChunks[0].text).toBe('수고하셨습니다') + }) + + it('stopListening이 렌더 시점이 아닌 최신 청크를 돌려준다', async () => { + const view = await listening() + + act(() => instances[0].emit({ text: '첫 발화', final: true })) + act(() => instances[0].emit({ text: '끝 발화', final: false })) + + let finalChunks: ReturnType = [] + act(() => { + finalChunks = view.result.current.stopListening() + }) + + expect(finalChunks.map((c) => c.text)).toEqual(['첫 발화', '끝 발화']) + }) + + it('살려낼 미확정 발화가 없으면 아무것도 추가하지 않는다', async () => { + const view = await listening() + + act(() => instances[0].emit({ text: '확정된 말', final: true })) + act(() => instances[0].onend?.()) + + expect(view.result.current.chunks).toHaveLength(1) + }) + + it('재시작이 반복돼도 확정 전 발화가 매번 보존된다', async () => { + const view = await listening() + const recognition = instances[0] + + // flushInterim은 재시작 타이머보다 먼저, 동기로 돈다. + for (let i = 0; i < 3; i++) { + act(() => recognition.emit({ text: `구간 ${i}`, final: false })) + act(() => recognition.onend?.()) + } + + expect(view.result.current.chunks.map((c) => c.text)).toEqual([ + '구간 0', + '구간 1', + '구간 2', + ]) + }) +}) + +describe('타임스탬프', () => { + it('발화가 처음 들린 시각을 시작으로 잡는다', async () => { + const origin = Date.now() + const view = await listening(origin) + + const nowSpy = vi.spyOn(Date, 'now') + + nowSpy.mockReturnValue(origin + 5_000) + act(() => instances[0].emit({ text: '말하는 중', final: false })) + + nowSpy.mockReturnValue(origin + 12_000) + act(() => instances[0].emit({ text: '말하는 중입니다', final: true })) + + const chunk = view.result.current.chunks[0] + // 하드코딩된 `끝 − 2초`(10초)가 아니라 실제로 들리기 시작한 5초여야 한다. + expect(chunk.startTime).toBeCloseTo(5, 1) + expect(chunk.endTime).toBeCloseTo(12, 1) + + nowSpy.mockRestore() + }) + + it('timeOrigin을 주면 그 기준으로 시각을 잰다', async () => { + // 오디오 녹음 시작 시각을 넘기면 전사 타임스탬프가 파일 재생 위치와 맞는다. + const origin = Date.now() - 30_000 + const view = await listening(origin) + + act(() => instances[0].emit({ text: '발화', final: true })) + + expect(view.result.current.chunks[0].endTime).toBeGreaterThan(29) + }) + + it('interim 없이 확정만 오면 기본 길이로 되돌아간다', async () => { + const origin = Date.now() + const view = await listening(origin) + + const nowSpy = vi.spyOn(Date, 'now') + nowSpy.mockReturnValue(origin + 8_000) + act(() => instances[0].emit({ text: '갑자기 확정', final: true })) + + const chunk = view.result.current.chunks[0] + expect(chunk.endTime).toBeCloseTo(8, 1) + expect(chunk.startTime).toBeCloseTo(6, 1) + + nowSpy.mockRestore() + }) +}) + +describe('resetChunks', () => { + it('청크와 유실 카운트를 함께 지운다', async () => { + const view = await listening() + + act(() => instances[0].emit({ text: '발화', final: true })) + act(() => instances[0].emit({ text: '', final: true })) + act(() => view.result.current.resetChunks()) + + expect(view.result.current.chunks).toHaveLength(0) + expect(view.result.current.missedCount).toBe(0) + }) + + it('초기화 후 stopListening은 빈 배열', async () => { + const view = await listening() + + act(() => instances[0].emit({ text: '발화', final: true })) + act(() => view.result.current.resetChunks()) + + let finalChunks: ReturnType = [] + act(() => { + finalChunks = view.result.current.stopListening() + }) + expect(finalChunks).toEqual([]) + }) +}) diff --git a/src/app/api/summarize/route.ts b/src/app/api/summarize/route.ts index 741997b..da970e8 100644 --- a/src/app/api/summarize/route.ts +++ b/src/app/api/summarize/route.ts @@ -4,10 +4,13 @@ import { generateSimpleMinutes, } from '@/lib/minutes-generator' import { resolveProviderSettings } from '@/lib/api-keys' +import { + MAX_TRANSCRIPT_CHARS, + SINGLE_PASS_CHAR_LIMIT, + condenseTranscript, +} from '@/lib/long-transcript' import type { SummaryDepth, TemplateId } from '@/lib/templates' -const MAX_TRANSCRIPT_CHARS = 100_000 - export async function POST(request: NextRequest) { try { const body = await request.json() @@ -52,6 +55,30 @@ export async function POST(request: NextRequest) { }) } + // 한 번에 넣기엔 긴 회의는 구간별로 압축한 뒤 평소 경로로 회의록을 만든다. + // 예전에는 여기서 413으로 거부해, 3시간 회의가 마지막에 통째로 실패했다. + let condensedNotice: string | undefined + + if (transcript.length > SINGLE_PASS_CHAR_LIMIT) { + const condensed = await condenseTranscript(transcript, { + provider: settings, + }) + + if (!condensed.success) { + const fallback = generateSimpleMinutes(input) + return Response.json({ + markdown: fallback, + mode: 'simple', + warning: `긴 회의 정리에 실패하여 단순 변환으로 대체되었습니다: ${condensed.error}`, + }) + } + + input.transcript = condensed.text + condensedNotice = + `긴 회의(${transcript.length.toLocaleString()}자)라 ${condensed.windows}개 구간으로 ` + + '나눠 정리한 뒤 회의록을 작성했습니다.' + } + const result = await generateAiMinutes(input, { provider: settings }) if (!result.success) { @@ -63,7 +90,11 @@ export async function POST(request: NextRequest) { }) } - return Response.json({ markdown: result.markdown, mode: 'gemini' }) + return Response.json({ + markdown: result.markdown, + mode: 'gemini', + ...(condensedNotice ? { warning: condensedNotice } : {}), + }) } const markdown = generateSimpleMinutes(input) diff --git a/src/components/recorder/LiveRecorder.tsx b/src/components/recorder/LiveRecorder.tsx index 2589579..9e4a314 100644 --- a/src/components/recorder/LiveRecorder.tsx +++ b/src/components/recorder/LiveRecorder.tsx @@ -40,6 +40,7 @@ export function LiveRecorder({ chunks, interimText, error: recognitionError, + missedCount, startListening, stopListening, resetChunks, @@ -123,14 +124,18 @@ export function LiveRecorder({ resetRecording() resetTranscription() // 오디오 녹음을 먼저 건다. 전사가 실패하더라도 원본은 남아야 한다. - await startRecording() - startListening() + // 녹음 시작 시각을 인식기에 넘겨, 전사 타임스탬프가 오디오 파일의 + // 재생 위치와 같은 시간축을 쓰게 한다. + const audioStartedAt = await startRecording() + startListening(audioStartedAt ?? undefined) } async function handleStop() { - stopListening() + // 훅이 미확정 발화까지 굳혀서 돌려준다. 렌더 시점의 `chunks`를 쓰면 + // 회의 마지막 문장이 빠진다. + const finalChunks = stopListening() - const transcript = formatTranscriptChunks(chunks) + const transcript = formatTranscriptChunks(finalChunks) if (transcript.length > 0) { onTranscriptReady(transcript) } @@ -240,6 +245,15 @@ export function LiveRecorder({ )} + {missedCount > 0 && ( +
+ ⚠️ 인식 실패 {missedCount}건 +
+ )} + {isRecording && (
🎧 @@ -337,7 +351,7 @@ export function LiveRecorder({ }`} />

- 실시간 텍스트 + 실시간 텍스트 (미리보기)

diff --git a/src/hooks/useAudioRecorder.ts b/src/hooks/useAudioRecorder.ts index c71ed50..7632f8f 100644 --- a/src/hooks/useAudioRecorder.ts +++ b/src/hooks/useAudioRecorder.ts @@ -31,7 +31,8 @@ export interface AudioRecorderHook { localBytes: number uploadedBytes: number recording: CompletedRecording | null - startRecording: () => Promise + /** 캡처가 시작된 시각(epoch ms). 실패하면 null. */ + startRecording: () => Promise stopRecording: () => Promise downloadRecording: (title: string) => void reset: () => void @@ -164,8 +165,8 @@ export function useAudioRecorder(): AudioRecorderHook { }) }, []) - const startRecording = useCallback(async () => { - if (recorderRef.current) return + const startRecording = useCallback(async (): Promise => { + if (recorderRef.current) return null setError(null) setUploadWarning(null) @@ -180,7 +181,7 @@ export function useAudioRecorder(): AudioRecorderHook { if (isSupported !== true) { setError('이 브라우저는 오디오 녹음을 지원하지 않습니다. Chrome을 사용해주세요.') - return + return null } const mimeType = pickRecorderMimeType((type) => @@ -188,7 +189,7 @@ export function useAudioRecorder(): AudioRecorderHook { ) if (!mimeType) { setError('브라우저가 지원하는 녹음 형식을 찾지 못했습니다.') - return + return null } let stream: MediaStream @@ -198,7 +199,7 @@ export function useAudioRecorder(): AudioRecorderHook { }) } catch (err) { setError(describeCaptureError(err)) - return + return null } // 서버 세션은 있으면 좋고 없어도 녹음은 간다. 여기서 포기하면 @@ -242,13 +243,16 @@ export function useAudioRecorder(): AudioRecorderHook { setError('녹음 중 오류가 발생했습니다. 지금까지의 오디오는 보존되어 있습니다.') } + const startedAt = new Date() streamRef.current = stream recorderRef.current = recorder mimeTypeRef.current = mimeType - startedAtRef.current = new Date() + startedAtRef.current = startedAt recorder.start(CHUNK_INTERVAL_MS) setIsRecording(true) + + return startedAt.getTime() }, [isSupported, queueUpload]) const stopRecording = useCallback(async () => { diff --git a/src/hooks/useSpeechRecognition.ts b/src/hooks/useSpeechRecognition.ts index 9e45cd7..60d6111 100644 --- a/src/hooks/useSpeechRecognition.ts +++ b/src/hooks/useSpeechRecognition.ts @@ -9,8 +9,20 @@ interface SpeechRecognitionHook { chunks: TranscriptChunk[] interimText: string error: string | null - startListening: () => void - stopListening: () => void + /** + * Chrome이 "소리는 들었는데 못 알아듣겠다"고 답한 횟수. + * + * `isFinal: true`에 `transcript: ""`인 결과다. 실측 46분 회의에서 19번 나왔다. + * 조용히 버리면 유실이 보이지 않으므로 세어서 노출한다. + */ + missedCount: number + /** + * @param timeOrigin 시간축의 기준점(ms). 오디오 녹음 시작 시각을 넘기면 + * 전사 타임스탬프가 녹음 파일의 재생 위치와 맞는다. 생략하면 지금 시각. + */ + startListening: (timeOrigin?: number) => void + /** 미확정 발화까지 확정한 최종 청크를 돌려준다. */ + stopListening: () => TranscriptChunk[] resetChunks: () => void } @@ -30,16 +42,31 @@ function describeError(code: string): string { } } +/** 발화 시작을 못 잡았을 때 되돌아갈 기본 길이(초). */ +const FALLBACK_UTTERANCE_SECONDS = 2 + export function useSpeechRecognition(): SpeechRecognitionHook { const [isListening, setIsListening] = useState(false) const [chunks, setChunks] = useState([]) const [interimText, setInterimText] = useState('') const [error, setError] = useState(null) const [isSupported, setIsSupported] = useState(null) + const [missedCount, setMissedCount] = useState(0) + const recognitionRef = useRef(null) const startTimeRef = useRef(0) const networkRetryRef = useRef(0) + // 청크의 정본은 ref다. 인식 종료 직후 곧바로 최종 결과를 넘겨야 하는데 + // setState는 비동기라 그 시점의 값을 읽을 수 없다. + const chunksRef = useRef([]) + + // 아직 확정되지 않은 발화. 세션이 끊기면 Chrome은 이걸 그냥 버리므로 + // 우리가 들고 있다가 직접 확정시킨다. + const interimRef = useRef('') + // 현재 발화가 처음 들리기 시작한 시각(초). interim이 처음 뜰 때 기록한다. + const utteranceStartRef = useRef(null) + const MAX_NETWORK_RETRIES = 8 useEffect(() => { @@ -50,7 +77,44 @@ export function useSpeechRecognition(): SpeechRecognitionHook { ) }, []) - const startListening = useCallback(() => { + const elapsed = useCallback( + () => (Date.now() - startTimeRef.current) / 1000, + [], + ) + + const appendChunk = useCallback((chunk: TranscriptChunk) => { + chunksRef.current = [...chunksRef.current, chunk] + setChunks(chunksRef.current) + }, []) + + /** + * 들고 있던 미확정 발화를 청크로 굳힌다. + * + * 세션이 끝날 때마다 호출한다. Chrome은 `continuous`여도 침묵마다 세션을 + * 닫는데, 그때 확정 전이던 문장이 통째로 사라지는 것이 유실의 큰 축이었다. + */ + const flushInterim = useCallback(() => { + const pending = interimRef.current.trim() + interimRef.current = '' + setInterimText('') + + if (pending.length === 0) { + utteranceStartRef.current = null + return + } + + const end = elapsed() + appendChunk({ + text: pending, + startTime: utteranceStartRef.current ?? Math.max(0, end - FALLBACK_UTTERANCE_SECONDS), + endTime: end, + // Chrome이 확정해 준 결과가 아니라 우리가 살려낸 값이다. + isFinal: false, + }) + utteranceStartRef.current = null + }, [appendChunk, elapsed]) + + const startListening = useCallback((timeOrigin?: number) => { if (isSupported !== true) { setError('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome을 사용해주세요.') return @@ -64,8 +128,10 @@ export function useSpeechRecognition(): SpeechRecognitionHook { recognition.continuous = true recognition.interimResults = true - startTimeRef.current = Date.now() + startTimeRef.current = timeOrigin ?? Date.now() networkRetryRef.current = 0 + interimRef.current = '' + utteranceStartRef.current = null setError(null) recognition.onresult = (event: SpeechRecognitionEvent) => { @@ -74,32 +140,47 @@ export function useSpeechRecognition(): SpeechRecognitionHook { setError(null) } - const now = (Date.now() - startTimeRef.current) / 1000 + const now = elapsed() for (let i = event.resultIndex; i < event.results.length; i++) { const result = event.results[i] const text = result[0].transcript.trim() - if (result.isFinal) { - setChunks((prev) => [ - ...prev, - { - text, - startTime: Math.max(0, now - 2), - endTime: now, - isFinal: true, - }, - ]) - setInterimText('') - } else { + if (!result.isFinal) { + // 발화의 첫 조각이 들린 순간이 곧 시작 시각이다. + if (utteranceStartRef.current === null) { + utteranceStartRef.current = now + } + interimRef.current = text setInterimText(text) + continue } + + const start = + utteranceStartRef.current ?? + Math.max(0, now - FALLBACK_UTTERANCE_SECONDS) + + interimRef.current = '' + utteranceStartRef.current = null + setInterimText('') + + if (text.length === 0) { + // 소리는 감지했지만 인식에 실패한 구간. 프롬프트를 오염시키지 않도록 + // 청크로 넣지 않되, 유실이 있었다는 사실은 남긴다. + setMissedCount((prev) => prev + 1) + continue + } + + appendChunk({ text, startTime: start, endTime: now, isFinal: true }) } } recognition.onend = () => { if (recognitionRef.current !== recognition) return + // 재시작 전에 반드시 비운다. 새 세션은 이전 오디오를 다시 주지 않는다. + flushInterim() + const delay = networkRetryRef.current > 0 ? 1500 : 0 setTimeout(() => { if (recognitionRef.current !== recognition) return @@ -151,22 +232,31 @@ export function useSpeechRecognition(): SpeechRecognitionHook { ) recognitionRef.current = null } - }, [isSupported]) + }, [appendChunk, elapsed, flushInterim, isSupported]) - const stopListening = useCallback(() => { - if (recognitionRef.current) { - const recognition = recognitionRef.current + const stopListening = useCallback((): TranscriptChunk[] => { + const recognition = recognitionRef.current + if (recognition) { recognitionRef.current = null recognition.stop() setIsListening(false) - setInterimText('') } - }, []) + + // 마지막 발화는 확정 전에 세션이 닫히는 경우가 대부분이다. + // 여기서 굳히지 않으면 회의 끝머리가 통째로 사라진다. + flushInterim() + + return chunksRef.current + }, [flushInterim]) const resetChunks = useCallback(() => { + chunksRef.current = [] + interimRef.current = '' + utteranceStartRef.current = null setChunks([]) setInterimText('') setError(null) + setMissedCount(0) }, []) return { @@ -175,6 +265,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook { chunks, interimText, error, + missedCount, startListening, stopListening, resetChunks, diff --git a/src/lib/long-transcript.ts b/src/lib/long-transcript.ts new file mode 100644 index 0000000..6c411af --- /dev/null +++ b/src/lib/long-transcript.ts @@ -0,0 +1,159 @@ +import { complete, toProviderSettings, type ProviderSettings } from './providers' + +/** + * 긴 회의 처리. + * + * 예전에는 10만 자를 넘으면 `/api/summarize`가 413으로 **거부**했다. 3시간짜리 + * 회의를 마치고 회의록을 만들려는 순간 아무것도 못 받는다는 뜻이다. 유실을 + * 막자고 만든 파이프라인의 끝에서 결과를 통째로 버리는 셈이었다. + * + * 이제는 구간별로 한 번 압축한 뒤(map), 압축본을 모아 평소와 똑같은 템플릿 + * 경로로 회의록을 만든다(reduce). 최종 출력이 짧은 회의와 같은 프롬프트를 + * 타므로 서식이 흔들리지 않는다. + */ + +/** 이 길이를 넘으면 한 번에 못 넣는다고 보고 압축 단계를 거친다. */ +export const SINGLE_PASS_CHAR_LIMIT = 60_000 + +/** 압축 단계에서 한 번에 넣을 구간 크기. */ +export const CONDENSE_WINDOW_CHARS = 40_000 + +/** 이걸 넘으면 압축을 해도 감당이 안 된다고 보고 거부한다(약 14시간 분량). */ +export const MAX_TRANSCRIPT_CHARS = 500_000 + +/** + * 전사문을 줄 경계에서 잘라 구간으로 나눈다. + * + * 한 줄이 통째로 상한을 넘으면(타임스탬프 없는 긴 문단) 그 줄만 강제로 자른다. + * 어떤 경우에도 원문의 어느 부분도 버리지 않는다. + */ +export function planTranscriptWindows( + transcript: string, + maxChars: number = CONDENSE_WINDOW_CHARS, +): string[] { + if (maxChars <= 0) throw new Error('maxChars는 1 이상이어야 한다') + + const text = transcript.trim() + if (text.length === 0) return [] + if (text.length <= maxChars) return [text] + + const windows: string[] = [] + let current: string[] = [] + let currentLength = 0 + + function flush() { + if (current.length === 0) return + windows.push(current.join('\n')) + current = [] + currentLength = 0 + } + + for (const line of text.split('\n')) { + // 줄 하나가 상한보다 길면 쪼개는 수밖에 없다. + if (line.length > maxChars) { + flush() + for (let i = 0; i < line.length; i += maxChars) { + windows.push(line.slice(i, i + maxChars)) + } + continue + } + + const projected = currentLength + line.length + (current.length > 0 ? 1 : 0) + if (projected > maxChars) flush() + + current.push(line) + currentLength += line.length + (current.length > 1 ? 1 : 0) + } + + flush() + return windows +} + +function buildCondensePrompt( + window: string, + index: number, + total: number, +): string { + return [ + `다음은 회의 전사문의 ${index + 1}/${total} 구간입니다.`, + '', + '이 구간에서 오간 내용을 빠짐없이 정리하세요. 이 정리본만 보고 회의록을', + '작성하게 되므로, 뒤에서 쓰일 정보를 잃으면 안 됩니다.', + '', + '규칙:', + '- 논의된 주제, 결정된 사항, 할 일, 숫자·날짜·고유명사를 모두 남깁니다.', + '- 누가 말했는지 드러나면 함께 적습니다.', + '- 인사말·잡담·중복은 덜어냅니다.', + '- 회의록 서식으로 만들지 말고, 사실을 담은 개조식 메모로만 정리합니다.', + '- 앞뒤 구간을 추측해 채우지 않습니다. 이 구간에 있는 내용만 씁니다.', + '', + '---', + window, + ].join('\n') +} + +export interface CondenseOptions { + provider?: ProviderSettings + apiKey?: string + windowChars?: number + fetchFn?: typeof fetch +} + +export type CondenseResult = + | { success: true; text: string; windows: number } + | { success: false; error: string; rateLimited?: boolean } + +/** + * 긴 전사문을 구간별로 압축한다. + * + * 한 구간이라도 실패하면 전체를 실패로 돌린다. 일부만 빠진 압축본으로 회의록을 + * 만들면 사용자는 무엇이 빠졌는지 모른 채 멀쩡해 보이는 문서를 받게 된다. + */ +export async function condenseTranscript( + transcript: string, + options: CondenseOptions = {}, +): Promise { + const settings = toProviderSettings(options.provider, options.apiKey) + const windows = planTranscriptWindows( + transcript, + options.windowChars ?? CONDENSE_WINDOW_CHARS, + ) + + if (windows.length === 0) { + return { success: false, error: '요약할 텍스트가 비어 있습니다.' } + } + + const condensed: string[] = [] + + for (const [index, window] of windows.entries()) { + const result = await complete( + buildCondensePrompt(window, index, windows.length), + settings, + { fetchFn: options.fetchFn }, + ) + + if (!result.success) { + return { + success: false, + error: `${index + 1}/${windows.length} 구간 정리 실패: ${result.error}`, + ...(result.rateLimited ? { rateLimited: true } : {}), + } + } + + const text = result.text.trim() + if (text.length === 0) { + return { + success: false, + error: `${index + 1}/${windows.length} 구간 정리 결과가 비어 있습니다.`, + } + } + + condensed.push(`## 구간 ${index + 1}/${windows.length}\n\n${text}`) + } + + return { + success: true, + text: condensed.join('\n\n'), + windows: windows.length, + } +}