mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dacf3e617f |
No files matched your search
@@ -58,8 +58,19 @@ docker compose up -d
|
|||||||
|
|
||||||
> ⚠️ **Web Speech API는 발화를 상당량 놓칩니다.** 원거리·다인 대면 회의에서
|
> ⚠️ **Web Speech API는 발화를 상당량 놓칩니다.** 원거리·다인 대면 회의에서
|
||||||
> 실측 포착률이 10% 안팎이었습니다(46분 회의 / 489어절). 회의 전사용으로
|
> 실측 포착률이 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 강도)
|
### 🤖 AI 회의록 (6 템플릿 × 3 강도)
|
||||||
|
|
||||||
회의 외에도 다양한 용도에 맞춰 출력 구조를 선택할 수 있습니다.
|
회의 외에도 다양한 용도에 맞춰 출력 구조를 선택할 수 있습니다.
|
||||||
@@ -541,7 +569,7 @@ prisma/
|
|||||||
| 제약 | 설명 | 대응 |
|
| 제약 | 설명 | 대응 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | 서버 사이드 STT 전환 예정 |
|
| **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | 서버 사이드 STT 전환 예정 |
|
||||||
| **Web Speech 포착률 낮음** | 원거리·다인 대면 회의 실측 10% 안팎 | 종료 후 **서버 STT 재전사**로 해결 |
|
| **Web Speech 포착률 낮음** | 원거리·다인 대면 회의 실측 10% 안팎 | 미리보기로 강등, 종료 후 **서버 STT 재전사**로 해결 |
|
||||||
| **Gemini는 webm 전사 불가** | Gemini가 받는 오디오 형식에 webm이 없음 | 녹음 전사는 OpenAI 호환 프로바이더 사용 |
|
| **Gemini는 webm 전사 불가** | Gemini가 받는 오디오 형식에 webm이 없음 | 녹음 전사는 OpenAI 호환 프로바이더 사용 |
|
||||||
| **긴 회의 전사 상한** | Whisper 25MB(≈1시간 40분) / Gemini inline 14MB(≈1시간) | 초과 시 오류 안내. 분할 전사는 후속 |
|
| **긴 회의 전사 상한** | Whisper 25MB(≈1시간 40분) / Gemini inline 14MB(≈1시간) | 초과 시 오류 안내. 분할 전사는 후속 |
|
||||||
| **화자 구분 없음** | 미구현 (PR #16 / #17 검토 중) | — |
|
| **화자 구분 없음** | 미구현 (PR #16 / #17 검토 중) | — |
|
||||||
|
|||||||
+12
-3
@@ -197,9 +197,18 @@ flush해서 7%를 20~30%로는 올려도 90%로는 못 간다. 구현 결함이
|
|||||||
- [ ] 상한 초과 회의 분할 전사 (Whisper 25MB / Gemini inline 14MB)
|
- [ ] 상한 초과 회의 분할 전사 (Whisper 25MB / Gemini inline 14MB)
|
||||||
- [ ] Gemini Files API 경로 (큰 파일 + webm 우회)
|
- [ ] Gemini Files API 경로 (큰 파일 + webm 우회)
|
||||||
- [ ] Web Speech를 "실시간 미리보기"로 명시적 강등 (현재는 둘 다 노출)
|
- [ ] Web Speech를 "실시간 미리보기"로 명시적 강등 (현재는 둘 다 노출)
|
||||||
- [ ] **빈 청크 필터링** — 빈 발화 19개가 요약 프롬프트를 오염시키고 있음
|
- [x] **Web Speech 유실 지점 정리**
|
||||||
- [ ] **타임스탬프 실측화** — `useSpeechRecognition.ts`의 `startTime: now - 2` 하드코딩 제거
|
- [x] 빈 확정 결과를 청크에서 빼고 "인식 실패 N건"으로 노출 (실측 19건)
|
||||||
- [ ] **10만 자 하드 실패 → 분할 요약** — 긴 회의가 마지막에 통째로 실패함
|
- [x] 세션 재시작 시 미확정 발화 보존 — Chrome이 버리던 것을 직접 확정
|
||||||
|
- [x] 중지 시 마지막 발화 보존 — `stopListening()`이 최종 청크를 돌려줌
|
||||||
|
- [x] 실시간 텍스트 패널을 "미리보기"로 강등
|
||||||
|
- [x] **타임스탬프 실측화** — `startTime: now - 2` 하드코딩 제거
|
||||||
|
- [x] interim이 처음 뜬 시각을 발화 시작으로 사용
|
||||||
|
- [x] `startRecording()`이 캡처 시작 시각을 돌려주고 인식기가 같은 시간축 사용
|
||||||
|
- [x] **10만 자 하드 실패 → 분할 요약**
|
||||||
|
- [x] 구간 압축(map) → 기존 템플릿 경로로 회의록(reduce)
|
||||||
|
- [x] 상한 10만 → 50만 자 (약 14시간 분량)
|
||||||
|
- [x] 한 구간이라도 실패하면 전체 실패 — 조용히 빠진 회의록을 만들지 않음
|
||||||
|
|
||||||
## 🎯 P1 — 입력단 개선
|
## 🎯 P1 — 입력단 개선
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 — 조각 업로드', () => {
|
describe('useAudioRecorder — 조각 업로드', () => {
|
||||||
it('조각이 생길 때마다 서버로 올린다', async () => {
|
it('조각이 생길 때마다 서버로 올린다', async () => {
|
||||||
const view = await startedHook()
|
const view = await startedHook()
|
||||||
|
|||||||
@@ -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<typeof view.result.current.stopListening> = []
|
||||||
|
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<typeof view.result.current.stopListening> = []
|
||||||
|
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<typeof view.result.current.stopListening> = []
|
||||||
|
act(() => {
|
||||||
|
finalChunks = view.result.current.stopListening()
|
||||||
|
})
|
||||||
|
expect(finalChunks).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,10 +4,13 @@ import {
|
|||||||
generateSimpleMinutes,
|
generateSimpleMinutes,
|
||||||
} from '@/lib/minutes-generator'
|
} from '@/lib/minutes-generator'
|
||||||
import { resolveProviderSettings } from '@/lib/api-keys'
|
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'
|
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||||
|
|
||||||
const MAX_TRANSCRIPT_CHARS = 100_000
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
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 })
|
const result = await generateAiMinutes(input, { provider: settings })
|
||||||
|
|
||||||
if (!result.success) {
|
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)
|
const markdown = generateSimpleMinutes(input)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function LiveRecorder({
|
|||||||
chunks,
|
chunks,
|
||||||
interimText,
|
interimText,
|
||||||
error: recognitionError,
|
error: recognitionError,
|
||||||
|
missedCount,
|
||||||
startListening,
|
startListening,
|
||||||
stopListening,
|
stopListening,
|
||||||
resetChunks,
|
resetChunks,
|
||||||
@@ -123,14 +124,18 @@ export function LiveRecorder({
|
|||||||
resetRecording()
|
resetRecording()
|
||||||
resetTranscription()
|
resetTranscription()
|
||||||
// 오디오 녹음을 먼저 건다. 전사가 실패하더라도 원본은 남아야 한다.
|
// 오디오 녹음을 먼저 건다. 전사가 실패하더라도 원본은 남아야 한다.
|
||||||
await startRecording()
|
// 녹음 시작 시각을 인식기에 넘겨, 전사 타임스탬프가 오디오 파일의
|
||||||
startListening()
|
// 재생 위치와 같은 시간축을 쓰게 한다.
|
||||||
|
const audioStartedAt = await startRecording()
|
||||||
|
startListening(audioStartedAt ?? undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStop() {
|
async function handleStop() {
|
||||||
stopListening()
|
// 훅이 미확정 발화까지 굳혀서 돌려준다. 렌더 시점의 `chunks`를 쓰면
|
||||||
|
// 회의 마지막 문장이 빠진다.
|
||||||
|
const finalChunks = stopListening()
|
||||||
|
|
||||||
const transcript = formatTranscriptChunks(chunks)
|
const transcript = formatTranscriptChunks(finalChunks)
|
||||||
if (transcript.length > 0) {
|
if (transcript.length > 0) {
|
||||||
onTranscriptReady(transcript)
|
onTranscriptReady(transcript)
|
||||||
}
|
}
|
||||||
@@ -240,6 +245,15 @@ export function LiveRecorder({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{missedCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 rounded-full bg-amber-50 px-4 py-1.5 text-xs text-amber-700"
|
||||||
|
title="Chrome이 소리는 감지했지만 무슨 말인지 인식하지 못한 구간입니다."
|
||||||
|
>
|
||||||
|
⚠️ 인식 실패 {missedCount}건
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isRecording && (
|
{isRecording && (
|
||||||
<div className="flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-1.5 text-xs text-neutral-600">
|
<div className="flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-1.5 text-xs text-neutral-600">
|
||||||
<span className="text-sm">🎧</span>
|
<span className="text-sm">🎧</span>
|
||||||
@@ -337,7 +351,7 @@ export function LiveRecorder({
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<h3 className="text-xs font-semibold text-neutral-600 uppercase tracking-wider">
|
<h3 className="text-xs font-semibold text-neutral-600 uppercase tracking-wider">
|
||||||
실시간 텍스트
|
실시간 텍스트 <span className="normal-case font-normal text-neutral-400">(미리보기)</span>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-neutral-500">
|
<span className="text-xs text-neutral-500">
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export interface AudioRecorderHook {
|
|||||||
localBytes: number
|
localBytes: number
|
||||||
uploadedBytes: number
|
uploadedBytes: number
|
||||||
recording: CompletedRecording | null
|
recording: CompletedRecording | null
|
||||||
startRecording: () => Promise<void>
|
/** 캡처가 시작된 시각(epoch ms). 실패하면 null. */
|
||||||
|
startRecording: () => Promise<number | null>
|
||||||
stopRecording: () => Promise<void>
|
stopRecording: () => Promise<void>
|
||||||
downloadRecording: (title: string) => void
|
downloadRecording: (title: string) => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
@@ -164,8 +165,8 @@ export function useAudioRecorder(): AudioRecorderHook {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async (): Promise<number | null> => {
|
||||||
if (recorderRef.current) return
|
if (recorderRef.current) return null
|
||||||
|
|
||||||
setError(null)
|
setError(null)
|
||||||
setUploadWarning(null)
|
setUploadWarning(null)
|
||||||
@@ -180,7 +181,7 @@ export function useAudioRecorder(): AudioRecorderHook {
|
|||||||
|
|
||||||
if (isSupported !== true) {
|
if (isSupported !== true) {
|
||||||
setError('이 브라우저는 오디오 녹음을 지원하지 않습니다. Chrome을 사용해주세요.')
|
setError('이 브라우저는 오디오 녹음을 지원하지 않습니다. Chrome을 사용해주세요.')
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const mimeType = pickRecorderMimeType((type) =>
|
const mimeType = pickRecorderMimeType((type) =>
|
||||||
@@ -188,7 +189,7 @@ export function useAudioRecorder(): AudioRecorderHook {
|
|||||||
)
|
)
|
||||||
if (!mimeType) {
|
if (!mimeType) {
|
||||||
setError('브라우저가 지원하는 녹음 형식을 찾지 못했습니다.')
|
setError('브라우저가 지원하는 녹음 형식을 찾지 못했습니다.')
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
let stream: MediaStream
|
let stream: MediaStream
|
||||||
@@ -198,7 +199,7 @@ export function useAudioRecorder(): AudioRecorderHook {
|
|||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(describeCaptureError(err))
|
setError(describeCaptureError(err))
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서버 세션은 있으면 좋고 없어도 녹음은 간다. 여기서 포기하면
|
// 서버 세션은 있으면 좋고 없어도 녹음은 간다. 여기서 포기하면
|
||||||
@@ -242,13 +243,16 @@ export function useAudioRecorder(): AudioRecorderHook {
|
|||||||
setError('녹음 중 오류가 발생했습니다. 지금까지의 오디오는 보존되어 있습니다.')
|
setError('녹음 중 오류가 발생했습니다. 지금까지의 오디오는 보존되어 있습니다.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startedAt = new Date()
|
||||||
streamRef.current = stream
|
streamRef.current = stream
|
||||||
recorderRef.current = recorder
|
recorderRef.current = recorder
|
||||||
mimeTypeRef.current = mimeType
|
mimeTypeRef.current = mimeType
|
||||||
startedAtRef.current = new Date()
|
startedAtRef.current = startedAt
|
||||||
|
|
||||||
recorder.start(CHUNK_INTERVAL_MS)
|
recorder.start(CHUNK_INTERVAL_MS)
|
||||||
setIsRecording(true)
|
setIsRecording(true)
|
||||||
|
|
||||||
|
return startedAt.getTime()
|
||||||
}, [isSupported, queueUpload])
|
}, [isSupported, queueUpload])
|
||||||
|
|
||||||
const stopRecording = useCallback(async () => {
|
const stopRecording = useCallback(async () => {
|
||||||
|
|||||||
@@ -9,8 +9,20 @@ interface SpeechRecognitionHook {
|
|||||||
chunks: TranscriptChunk[]
|
chunks: TranscriptChunk[]
|
||||||
interimText: string
|
interimText: string
|
||||||
error: string | null
|
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
|
resetChunks: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,16 +42,31 @@ function describeError(code: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 발화 시작을 못 잡았을 때 되돌아갈 기본 길이(초). */
|
||||||
|
const FALLBACK_UTTERANCE_SECONDS = 2
|
||||||
|
|
||||||
export function useSpeechRecognition(): SpeechRecognitionHook {
|
export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||||
const [isListening, setIsListening] = useState(false)
|
const [isListening, setIsListening] = useState(false)
|
||||||
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
||||||
const [interimText, setInterimText] = useState('')
|
const [interimText, setInterimText] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isSupported, setIsSupported] = useState<boolean | null>(null)
|
const [isSupported, setIsSupported] = useState<boolean | null>(null)
|
||||||
|
const [missedCount, setMissedCount] = useState(0)
|
||||||
|
|
||||||
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||||
const startTimeRef = useRef<number>(0)
|
const startTimeRef = useRef<number>(0)
|
||||||
const networkRetryRef = useRef<number>(0)
|
const networkRetryRef = useRef<number>(0)
|
||||||
|
|
||||||
|
// 청크의 정본은 ref다. 인식 종료 직후 곧바로 최종 결과를 넘겨야 하는데
|
||||||
|
// setState는 비동기라 그 시점의 값을 읽을 수 없다.
|
||||||
|
const chunksRef = useRef<TranscriptChunk[]>([])
|
||||||
|
|
||||||
|
// 아직 확정되지 않은 발화. 세션이 끊기면 Chrome은 이걸 그냥 버리므로
|
||||||
|
// 우리가 들고 있다가 직접 확정시킨다.
|
||||||
|
const interimRef = useRef('')
|
||||||
|
// 현재 발화가 처음 들리기 시작한 시각(초). interim이 처음 뜰 때 기록한다.
|
||||||
|
const utteranceStartRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const MAX_NETWORK_RETRIES = 8
|
const MAX_NETWORK_RETRIES = 8
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
if (isSupported !== true) {
|
||||||
setError('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome을 사용해주세요.')
|
setError('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome을 사용해주세요.')
|
||||||
return
|
return
|
||||||
@@ -64,8 +128,10 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
recognition.continuous = true
|
recognition.continuous = true
|
||||||
recognition.interimResults = true
|
recognition.interimResults = true
|
||||||
|
|
||||||
startTimeRef.current = Date.now()
|
startTimeRef.current = timeOrigin ?? Date.now()
|
||||||
networkRetryRef.current = 0
|
networkRetryRef.current = 0
|
||||||
|
interimRef.current = ''
|
||||||
|
utteranceStartRef.current = null
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||||
@@ -74,32 +140,47 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = (Date.now() - startTimeRef.current) / 1000
|
const now = elapsed()
|
||||||
|
|
||||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
const result = event.results[i]
|
const result = event.results[i]
|
||||||
const text = result[0].transcript.trim()
|
const text = result[0].transcript.trim()
|
||||||
|
|
||||||
if (result.isFinal) {
|
if (!result.isFinal) {
|
||||||
setChunks((prev) => [
|
// 발화의 첫 조각이 들린 순간이 곧 시작 시각이다.
|
||||||
...prev,
|
if (utteranceStartRef.current === null) {
|
||||||
{
|
utteranceStartRef.current = now
|
||||||
text,
|
|
||||||
startTime: Math.max(0, now - 2),
|
|
||||||
endTime: now,
|
|
||||||
isFinal: true,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setInterimText('')
|
|
||||||
} else {
|
|
||||||
setInterimText(text)
|
|
||||||
}
|
}
|
||||||
|
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 = () => {
|
recognition.onend = () => {
|
||||||
if (recognitionRef.current !== recognition) return
|
if (recognitionRef.current !== recognition) return
|
||||||
|
|
||||||
|
// 재시작 전에 반드시 비운다. 새 세션은 이전 오디오를 다시 주지 않는다.
|
||||||
|
flushInterim()
|
||||||
|
|
||||||
const delay = networkRetryRef.current > 0 ? 1500 : 0
|
const delay = networkRetryRef.current > 0 ? 1500 : 0
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (recognitionRef.current !== recognition) return
|
if (recognitionRef.current !== recognition) return
|
||||||
@@ -151,22 +232,31 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
)
|
)
|
||||||
recognitionRef.current = null
|
recognitionRef.current = null
|
||||||
}
|
}
|
||||||
}, [isSupported])
|
}, [appendChunk, elapsed, flushInterim, isSupported])
|
||||||
|
|
||||||
const stopListening = useCallback(() => {
|
const stopListening = useCallback((): TranscriptChunk[] => {
|
||||||
if (recognitionRef.current) {
|
|
||||||
const recognition = recognitionRef.current
|
const recognition = recognitionRef.current
|
||||||
|
if (recognition) {
|
||||||
recognitionRef.current = null
|
recognitionRef.current = null
|
||||||
recognition.stop()
|
recognition.stop()
|
||||||
setIsListening(false)
|
setIsListening(false)
|
||||||
setInterimText('')
|
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
|
// 마지막 발화는 확정 전에 세션이 닫히는 경우가 대부분이다.
|
||||||
|
// 여기서 굳히지 않으면 회의 끝머리가 통째로 사라진다.
|
||||||
|
flushInterim()
|
||||||
|
|
||||||
|
return chunksRef.current
|
||||||
|
}, [flushInterim])
|
||||||
|
|
||||||
const resetChunks = useCallback(() => {
|
const resetChunks = useCallback(() => {
|
||||||
|
chunksRef.current = []
|
||||||
|
interimRef.current = ''
|
||||||
|
utteranceStartRef.current = null
|
||||||
setChunks([])
|
setChunks([])
|
||||||
setInterimText('')
|
setInterimText('')
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setMissedCount(0)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -175,6 +265,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
chunks,
|
chunks,
|
||||||
interimText,
|
interimText,
|
||||||
error,
|
error,
|
||||||
|
missedCount,
|
||||||
startListening,
|
startListening,
|
||||||
stopListening,
|
stopListening,
|
||||||
resetChunks,
|
resetChunks,
|
||||||
|
|||||||
@@ -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<CondenseResult> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user