mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
feat: 서버 STT 재전사 — 오디오 원본에서 확정 전사문을 뽑는다
#18이 오디오를 남기게 했다면, 이 PR은 그 오디오를 실제로 쓴다.
Web Speech 포착률 7~10%를 끌어올리는 유일한 방법은 인식 엔진 교체다.
## 두 경로가 하나로 만난다
실시간 녹음 ─┐
├→ recordingId → /api/transcribe → 확정 전사문
파일 업로드 ─┘
`/api/upload`는 파일을 디스크에 떨궈만 두고 "실시간 녹음 탭에서 하세요"라고
안내하던 막다른 길이었다. 이제 녹음 저장소에 넣고 recordingId를 돌려주므로
브라우저 녹음과 완전히 같은 파이프라인을 탄다.
## 실제 타임스탬프
Whisper `verbose_json`이 구간별 실제 오디오 시각을 준다. Web Speech 경로가 쓰던
`결과 이벤트 시각 − 2초` 추정값과 달리 구간 재생·화자 분리에 그대로 쓸 수 있다.
이 값이 있어야 PR #17의 diarization 화자 배정이 비로소 의미를 갖는다.
verbose_json을 지원하지 않는 모델(gpt-4o-transcribe 등)은 400을 주므로 `json`
으로 한 번 더 시도해 텍스트만이라도 받는다.
## 프로바이더별 지원
| 프로바이더 | webm 녹음 | 업로드 파일 | 기본 모델 |
|---|:---:|:---:|---|
| OpenAI · OrcaRouter · 로컬 whisper | O | O | whisper-1 |
| Gemini | X | O | gemini-3.6-flash |
Gemini가 문서로 밝힌 오디오 형식에 webm이 없다. 조용히 실패시키지 않고
"OpenAI 호환 프로바이더를 쓰라"고 명시적으로 안내하며, 설정 화면에도 경고를 띄운다.
## 비트레이트 128k → 32k
이 오디오는 감상용이 아니라 STT 입력이다. 128kbps면 46분 회의가 44MB가 되어
Whisper 상한(25MB)도 Gemini inline 상한도 넘긴다. 32kbps mono Opus는 음성
인식 정확도에 영향이 없으면서 46분을 11MB로 줄인다.
## 참석자·용어 힌트
홈 화면에 입력란을 두고 전사 요청의 어휘 힌트로 보낸다. 실측 transcript에서
"계명대동산병원 → 저희 키스해 주셔서", "양식대로 → 양복점" 같은 고유명사
붕괴가 심했던 부분이다.
## 검증
가짜 Whisper 서버를 세워 종단간 확인:
- 업로드 → recordingId → 전사 → `[00:00]/[00:03]/[00:12]` 전사문
- multipart 필드 검증: model · response_format=verbose_json · language=ko ·
prompt(어휘 힌트) · Bearer 인증 · 파일 바이트 정확히 일치
- webm 녹음도 Whisper로 정상 전사 (5000 bytes 그대로 전달)
- Gemini+webm 거부 / 미설정 400 / 경로조작 400 / 없는 녹음 404
테스트 206 → 229 통과, 신규 타입 에러 0, 린트 baseline과 동일, 빌드 성공.
### 검증하지 못한 것
실제 Whisper·Gemini API를 호출하지 못했다(키 없음). 모든 프로바이더 코드는
목 fetch와 가짜 서버로만 검증했다. 요청 형식은 문서를 따랐으나 실제 응답에
대한 확인이 필요하다.
포착률이 실제로 얼마나 오르는지도 아직 모른다. 다음 회의에서 같은 오디오로
Web Speech와 STT를 나란히 놓고 재야 한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzAH7GPWSYTe2AoBV6CZDP
This commit is contained in:
1 parent
cf3ce3f40c
commit
b115663ecf
21 files changed
+1340
-30
No files matched your search
@@ -71,6 +71,8 @@ describe('프로바이더 설정 저장', () => {
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||
model: 'google/gemini-2.5-flash-lite',
|
||||
// 예전에 저장된 설정에는 sttModel이 없다. 빈 값으로 채워 마이그레이션 없이 읽힌다.
|
||||
sttModel: '',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,6 +107,7 @@ describe('getProviderRequestPayload', () => {
|
||||
apiKey: 'AIza-legacy',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
sttModel: '',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,6 +117,7 @@ describe('getProviderRequestPayload', () => {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
sttModel: '',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -130,6 +134,7 @@ describe('getProviderRequestPayload', () => {
|
||||
apiKey: '',
|
||||
baseUrl: 'http://localhost:11434/v1',
|
||||
model: 'llama3.1',
|
||||
sttModel: '',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
isGeminiSupportedAudioMimeType,
|
||||
isWhisperSupportedMimeType,
|
||||
parseTimestampedTranscript,
|
||||
resolveSttModel,
|
||||
transcribe,
|
||||
GEMINI_INLINE_AUDIO_LIMIT,
|
||||
DEFAULT_OPENAI_STT_MODEL,
|
||||
DEFAULT_GEMINI_STT_MODEL,
|
||||
} from '@/lib/providers'
|
||||
import { resolveTranscriptionSettings } from '@/lib/api-keys'
|
||||
import {
|
||||
formatTranscriptionSegments,
|
||||
segmentsToChunks,
|
||||
} from '@/lib/transcript-formatter'
|
||||
import type { TranscriptionInput } from '@/lib/providers/types'
|
||||
|
||||
function audio(overrides: Partial<TranscriptionInput> = {}): TranscriptionInput {
|
||||
return {
|
||||
bytes: new Uint8Array([1, 2, 3, 4]),
|
||||
mimeType: 'audio/webm;codecs=opus',
|
||||
fileName: 'meeting.webm',
|
||||
language: 'ko',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function okResponse(body: unknown) {
|
||||
return { ok: true, status: 200, json: async () => body } as unknown as Response
|
||||
}
|
||||
|
||||
/** 목의 호출 인자를 좁혀서 읽는다. vi.fn의 추론 타입은 fetch 오버로드와 안 맞는다. */
|
||||
function callArgs(
|
||||
mock: { mock: { calls: unknown[][] } },
|
||||
index = 0,
|
||||
): { url: string; init: RequestInit } {
|
||||
const [url, init] = mock.mock.calls[index] as [string, RequestInit]
|
||||
return { url, init }
|
||||
}
|
||||
|
||||
function errResponse(status: number) {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
json: async () => ({ error: 'nope' }),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('mime 지원 판정', () => {
|
||||
it('Whisper는 브라우저 녹음(webm)을 받는다', () => {
|
||||
expect(isWhisperSupportedMimeType('audio/webm;codecs=opus')).toBe(true)
|
||||
expect(isWhisperSupportedMimeType('audio/mpeg')).toBe(true)
|
||||
expect(isWhisperSupportedMimeType('audio/x-aiff')).toBe(false)
|
||||
})
|
||||
|
||||
it('Gemini는 webm을 받지 않는다', () => {
|
||||
// 이게 true로 바뀌면 UI의 안내 문구도 같이 고쳐야 한다.
|
||||
expect(isGeminiSupportedAudioMimeType('audio/webm')).toBe(false)
|
||||
expect(isGeminiSupportedAudioMimeType('audio/mpeg')).toBe(true)
|
||||
expect(isGeminiSupportedAudioMimeType('audio/flac')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transcribe — OpenAI 호환', () => {
|
||||
const settings = {
|
||||
provider: 'openai-compatible' as const,
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
}
|
||||
|
||||
it('multipart로 보내고 verbose_json 구간을 파싱한다', async () => {
|
||||
const fetchFn = vi.fn(async () =>
|
||||
okResponse({
|
||||
text: '안녕하세요 회의 시작합니다',
|
||||
segments: [
|
||||
{ start: 0, end: 2.5, text: ' 안녕하세요' },
|
||||
{ start: 2.5, end: 5, text: ' 회의 시작합니다' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await transcribe(audio(), settings, { fetchFn })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
|
||||
expect(result.text).toBe('안녕하세요 회의 시작합니다')
|
||||
expect(result.segments).toEqual([
|
||||
{ start: 0, end: 2.5, text: '안녕하세요' },
|
||||
{ start: 2.5, end: 5, text: '회의 시작합니다' },
|
||||
])
|
||||
expect(result.model).toBe(DEFAULT_OPENAI_STT_MODEL)
|
||||
|
||||
const { url, init } = callArgs(fetchFn)
|
||||
expect(url).toBe('https://api.example.com/v1/audio/transcriptions')
|
||||
expect(init.body).toBeInstanceOf(FormData)
|
||||
|
||||
const form = init.body as FormData
|
||||
expect(form.get('model')).toBe(DEFAULT_OPENAI_STT_MODEL)
|
||||
expect(form.get('response_format')).toBe('verbose_json')
|
||||
expect(form.get('language')).toBe('ko')
|
||||
})
|
||||
|
||||
it('어휘 힌트를 prompt로 보낸다', async () => {
|
||||
const fetchFn = vi.fn(async () =>
|
||||
okResponse({ text: '내용' }),
|
||||
)
|
||||
|
||||
await transcribe(audio({ vocabularyHint: '김효천, PACS' }), settings, {
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
const form = callArgs(fetchFn).init.body as FormData
|
||||
expect(form.get('prompt')).toBe('김효천, PACS')
|
||||
})
|
||||
|
||||
it('verbose_json 미지원(400)이면 json으로 한 번 더 시도한다', async () => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(errResponse(400))
|
||||
.mockResolvedValueOnce(okResponse({ text: '타임스탬프 없는 결과' }))
|
||||
|
||||
const result = await transcribe(audio(), settings, { fetchFn })
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2)
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
expect(result.segments).toEqual([])
|
||||
|
||||
const second = callArgs(fetchFn, 1).init.body as FormData
|
||||
expect(second.get('response_format')).toBe('json')
|
||||
})
|
||||
|
||||
it('429는 rateLimited로 표시한다', async () => {
|
||||
const fetchFn = vi.fn(async () => errResponse(429))
|
||||
const result = await transcribe(audio(), settings, { fetchFn })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (result.success) return
|
||||
expect(result.rateLimited).toBe(true)
|
||||
})
|
||||
|
||||
it('빈 전사 결과를 성공으로 넘기지 않는다', async () => {
|
||||
const fetchFn = vi.fn(async () => okResponse({ text: ' ' }))
|
||||
const result = await transcribe(audio(), settings, { fetchFn })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (result.success) return
|
||||
expect(result.error).toContain('비어 있습니다')
|
||||
})
|
||||
|
||||
it('base URL이 잘못되면 호출조차 하지 않는다', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
const result = await transcribe(
|
||||
audio(),
|
||||
{ ...settings, baseUrl: 'file:///etc/passwd' },
|
||||
{ fetchFn },
|
||||
)
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('망가진 구간은 버리고 정상 구간만 남긴다', async () => {
|
||||
const fetchFn = vi.fn(async () =>
|
||||
okResponse({
|
||||
text: 'ok',
|
||||
segments: [
|
||||
{ start: 0, end: 1, text: '정상' },
|
||||
{ start: 'x', end: 2, text: '시작이 숫자가 아님' },
|
||||
{ start: 3, end: 4, text: ' ' },
|
||||
{ start: 5, end: 6, text: '또 정상' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await transcribe(audio(), settings, { fetchFn })
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
expect(result.segments.map((s) => s.text)).toEqual(['정상', '또 정상'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('transcribe — Gemini', () => {
|
||||
const settings = { provider: 'gemini' as const, apiKey: 'AIza-test' }
|
||||
|
||||
it('webm은 거부하고 대안을 안내한다', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
const result = await transcribe(audio(), settings, { fetchFn })
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(false)
|
||||
if (result.success) return
|
||||
expect(result.error).toContain('OpenAI 호환')
|
||||
})
|
||||
|
||||
it('지원 형식은 inline_data로 보낸다', async () => {
|
||||
const fetchFn = vi.fn(async () =>
|
||||
okResponse({
|
||||
candidates: [{ content: { parts: [{ text: '[00:03] 안녕하세요' }] } }],
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await transcribe(
|
||||
audio({ mimeType: 'audio/mpeg', fileName: 'a.mp3' }),
|
||||
settings,
|
||||
{ fetchFn },
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
expect(result.segments).toEqual([{ start: 3, end: 4, text: '안녕하세요' }])
|
||||
|
||||
const body = JSON.parse(callArgs(fetchFn).init.body as string)
|
||||
expect(body.contents[0].parts[1].inline_data.mime_type).toBe('audio/mpeg')
|
||||
expect(body.contents[0].parts[1].inline_data.data).toBe(
|
||||
Buffer.from([1, 2, 3, 4]).toString('base64'),
|
||||
)
|
||||
})
|
||||
|
||||
it('inline 상한을 넘으면 호출하지 않고 대안을 안내한다', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
const result = await transcribe(
|
||||
audio({
|
||||
mimeType: 'audio/mpeg',
|
||||
bytes: new Uint8Array(GEMINI_INLINE_AUDIO_LIMIT + 1),
|
||||
}),
|
||||
settings,
|
||||
{ fetchFn },
|
||||
)
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(false)
|
||||
if (result.success) return
|
||||
expect(result.error).toContain('너무 큽니다')
|
||||
})
|
||||
|
||||
it('키가 없으면 거부한다', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
const result = await transcribe(
|
||||
audio({ mimeType: 'audio/mpeg' }),
|
||||
{ provider: 'gemini', apiKey: ' ' },
|
||||
{ fetchFn },
|
||||
)
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTimestampedTranscript', () => {
|
||||
it('MM:SS와 HH:MM:SS를 모두 읽는다', () => {
|
||||
const segments = parseTimestampedTranscript(
|
||||
['[00:03] 첫 발화', '[01:10] 두 번째', '[1:02:05] 한참 뒤'].join('\n'),
|
||||
)
|
||||
|
||||
expect(segments.map((s) => s.start)).toEqual([3, 70, 3725])
|
||||
})
|
||||
|
||||
it('다음 구간 시작을 이전 구간의 끝으로 잡는다', () => {
|
||||
const segments = parseTimestampedTranscript('[00:00] 가\n[00:10] 나')
|
||||
expect(segments[0]).toEqual({ start: 0, end: 10, text: '가' })
|
||||
expect(segments[1].end).toBeGreaterThan(segments[1].start)
|
||||
})
|
||||
|
||||
it('타임스탬프 없는 줄은 무시한다', () => {
|
||||
expect(parseTimestampedTranscript('그냥 텍스트\n또 텍스트')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTranscriptionSegments', () => {
|
||||
it('실제 오디오 타임라인으로 전사문을 만든다', () => {
|
||||
const text = formatTranscriptionSegments([
|
||||
{ start: 3, text: '어떤 거죠' },
|
||||
{ start: 3725, text: '한참 뒤' },
|
||||
])
|
||||
|
||||
expect(text).toBe('[00:03] 어떤 거죠\n[01:02:05] 한참 뒤')
|
||||
})
|
||||
|
||||
it('빈 구간은 넣지 않는다', () => {
|
||||
// Web Speech 경로에서 빈 청크 19개가 요약 프롬프트를 오염시켰다.
|
||||
expect(
|
||||
formatTranscriptionSegments([
|
||||
{ start: 0, text: ' ' },
|
||||
{ start: 1, text: '내용' },
|
||||
]),
|
||||
).toBe('[00:01] 내용')
|
||||
})
|
||||
})
|
||||
|
||||
describe('segmentsToChunks', () => {
|
||||
it('청크 구조로 옮기면서 시간을 보존한다', () => {
|
||||
expect(
|
||||
segmentsToChunks([{ start: 1.5, end: 4.25, text: ' 발화 ' }]),
|
||||
).toEqual([{ text: '발화', startTime: 1.5, endTime: 4.25, isFinal: true }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTranscriptionSettings', () => {
|
||||
it('대화 모델이 없어도 전사는 가능하다', () => {
|
||||
// 요약용 resolveProviderSettings는 model이 비면 null을 준다.
|
||||
const settings = resolveTranscriptionSettings({
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
apiKey: 'sk-x',
|
||||
})
|
||||
|
||||
expect(settings).not.toBeNull()
|
||||
expect(resolveSttModel(settings!)).toBe(DEFAULT_OPENAI_STT_MODEL)
|
||||
})
|
||||
|
||||
it('sttModel을 지정하면 그것을 쓴다', () => {
|
||||
const settings = resolveTranscriptionSettings({
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
sttModel: 'gpt-4o-transcribe',
|
||||
})
|
||||
expect(resolveSttModel(settings!)).toBe('gpt-4o-transcribe')
|
||||
})
|
||||
|
||||
it('gemini 기본 STT 모델은 오디오를 받는 모델이다', () => {
|
||||
const settings = resolveTranscriptionSettings({
|
||||
provider: 'gemini',
|
||||
apiKey: 'AIza-x',
|
||||
})
|
||||
expect(resolveSttModel(settings!)).toBe(DEFAULT_GEMINI_STT_MODEL)
|
||||
})
|
||||
|
||||
it('base URL이 없으면 null', () => {
|
||||
expect(
|
||||
resolveTranscriptionSettings({ provider: 'openai-compatible' }),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { readFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { resolveTranscriptionSettings } from '@/lib/api-keys'
|
||||
import { resolveSttModel, transcribe } from '@/lib/providers'
|
||||
import { getRecording, RECORDINGS_DIR } from '@/lib/recording-store'
|
||||
import { isValidRecordingId } from '@/lib/recording'
|
||||
import { formatTranscriptionSegments } from '@/lib/transcript-formatter'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** 어휘 힌트는 Whisper가 약 224토큰까지만 본다. 넘겨도 버려지므로 잘라 보낸다. */
|
||||
const MAX_VOCABULARY_HINT_CHARS = 800
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { recordingId, language, vocabularyHint } = body
|
||||
|
||||
if (!isValidRecordingId(recordingId)) {
|
||||
return Response.json(
|
||||
{ error: '잘못된 녹음 세션 ID입니다.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const meta = await getRecording(recordingId)
|
||||
if (!meta) {
|
||||
return Response.json(
|
||||
{ error: '녹음을 찾을 수 없습니다.' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
const settings = resolveTranscriptionSettings(body)
|
||||
if (!settings) {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
'AI 프로바이더가 설정되지 않았습니다. /settings에서 먼저 설정해주세요.',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
let bytes: Buffer
|
||||
try {
|
||||
bytes = await readFile(path.join(RECORDINGS_DIR, meta.fileName))
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: '녹음 파일을 읽을 수 없습니다. 오디오가 저장되지 않았을 수 있습니다.' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
if (bytes.byteLength === 0) {
|
||||
return Response.json(
|
||||
{ error: '녹음 파일이 비어 있습니다.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const result = await transcribe(
|
||||
{
|
||||
bytes,
|
||||
mimeType: meta.mimeType,
|
||||
fileName: meta.fileName,
|
||||
language: typeof language === 'string' && language ? language : 'ko',
|
||||
vocabularyHint:
|
||||
typeof vocabularyHint === 'string' && vocabularyHint.trim().length > 0
|
||||
? vocabularyHint.trim().slice(0, MAX_VOCABULARY_HINT_CHARS)
|
||||
: undefined,
|
||||
},
|
||||
settings,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return Response.json(
|
||||
{ error: result.error },
|
||||
{ status: result.rateLimited ? 429 : 502 },
|
||||
)
|
||||
}
|
||||
|
||||
// 구간 타임스탬프가 있으면 `[MM:SS]` 형식으로 맞춘다. 없으면 원문 그대로.
|
||||
const transcript =
|
||||
result.segments.length > 0
|
||||
? formatTranscriptionSegments(result.segments)
|
||||
: result.text
|
||||
|
||||
return Response.json({
|
||||
transcript,
|
||||
text: result.text,
|
||||
segments: result.segments,
|
||||
model: result.model,
|
||||
hasTimestamps: result.segments.length > 0,
|
||||
audioBytes: bytes.byteLength,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return Response.json({ error: `전사 실패: ${message}` }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** 이 프로바이더 설정으로 전사가 가능한지 미리 확인. */
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url)
|
||||
const settings = resolveTranscriptionSettings({
|
||||
provider: url.searchParams.get('provider'),
|
||||
baseUrl: url.searchParams.get('baseUrl'),
|
||||
apiKey: url.searchParams.get('apiKey'),
|
||||
})
|
||||
|
||||
return Response.json({
|
||||
configured: settings !== null,
|
||||
model: settings ? resolveSttModel(settings) : null,
|
||||
})
|
||||
}
|
||||
+22
-10
@@ -1,10 +1,20 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleUpload } from '@/lib/upload-handler'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import {
|
||||
appendChunk,
|
||||
createRecording,
|
||||
finalizeRecording,
|
||||
} from '@/lib/recording-store'
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), 'uploads')
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* 오디오 파일 업로드.
|
||||
*
|
||||
* 예전에는 `uploads/`에 파일만 떨궈 두고 아무것도 하지 않는 막다른 길이었다.
|
||||
* 지금은 녹음 저장소에 그대로 넣어 `recordingId`를 돌려주므로, 브라우저 녹음과
|
||||
* 똑같이 `/api/transcribe`로 전사할 수 있다. 두 경로가 한 파이프라인으로 만난다.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
@@ -15,19 +25,21 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const audioFile = formData.get('audio') as File
|
||||
const buffer = Buffer.from(await audioFile.arrayBuffer())
|
||||
const bytes = Buffer.from(await audioFile.arrayBuffer())
|
||||
|
||||
await mkdir(UPLOAD_DIR, { recursive: true })
|
||||
const meta = await createRecording(result.data.mimeType)
|
||||
const appended = await appendChunk(meta.id, bytes)
|
||||
|
||||
const timestamp = Date.now()
|
||||
const safeFileName = `${timestamp}_${result.data.fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`
|
||||
const filePath = path.join(UPLOAD_DIR, safeFileName)
|
||||
if (!appended.ok) {
|
||||
return Response.json({ error: appended.error }, { status: appended.status })
|
||||
}
|
||||
|
||||
await writeFile(filePath, buffer)
|
||||
await finalizeRecording(meta.id, null)
|
||||
|
||||
return Response.json({
|
||||
...result.data,
|
||||
savedAs: safeFileName,
|
||||
recordingId: meta.id,
|
||||
savedAs: meta.fileName,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
|
||||
+72
-4
@@ -6,6 +6,7 @@ import { AudioUploader } from '@/components/upload/AudioUploader'
|
||||
import { LiveRecorder } from '@/components/recorder/LiveRecorder'
|
||||
import type { CompletedRecording } from '@/hooks/useAudioRecorder'
|
||||
import { extensionForMimeType } from '@/lib/recording'
|
||||
import { useTranscription } from '@/hooks/useTranscription'
|
||||
import { MinutesViewer } from '@/components/minutes/MinutesViewer'
|
||||
import { getProviderRequestPayload } from '@/lib/api-key-storage'
|
||||
import {
|
||||
@@ -46,6 +47,8 @@ export default function HomePage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
||||
const [audio, setAudio] = useState<CompletedRecording | null>(null)
|
||||
const [uploadedRecordingId, setUploadedRecordingId] = useState<string | null>(null)
|
||||
const [attendees, setAttendees] = useState('')
|
||||
|
||||
const templateList = useMemo(
|
||||
() => [
|
||||
@@ -80,6 +83,7 @@ export default function HomePage() {
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setUploadedFile(file)
|
||||
setUploadedRecordingId(null)
|
||||
setError(null)
|
||||
|
||||
const formData = new FormData()
|
||||
@@ -96,6 +100,7 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
if (!title) setTitle(data.title)
|
||||
setUploadedRecordingId(data.recordingId ?? null)
|
||||
} catch {
|
||||
setError('파일 업로드에 실패했습니다.')
|
||||
}
|
||||
@@ -153,6 +158,24 @@ export default function HomePage() {
|
||||
setAudio(recording)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
isTranscribing: isTranscribingUpload,
|
||||
error: uploadTranscribeError,
|
||||
result: uploadTranscription,
|
||||
transcribeRecording: transcribeUpload,
|
||||
} = useTranscription()
|
||||
|
||||
async function transcribeUploadedFile() {
|
||||
if (!uploadedRecordingId) return
|
||||
const outcome = await transcribeUpload(uploadedRecordingId, {
|
||||
vocabularyHint: attendees,
|
||||
})
|
||||
if (outcome?.transcript) {
|
||||
setTranscript(outcome.transcript)
|
||||
generateMinutes(outcome.transcript, summaryMode)
|
||||
}
|
||||
}
|
||||
|
||||
// 회의록과 함께 저장할 오디오 정보. 서버 사본이 없으면 붙일 게 없다.
|
||||
const audioMeta = audio?.recordingId
|
||||
? {
|
||||
@@ -204,6 +227,22 @@ export default function HomePage() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<label className="block text-sm font-medium text-neutral-600 mb-2">
|
||||
참석자 · 용어 <span className="font-normal text-neutral-400">(선택)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={attendees}
|
||||
onChange={(e) => setAttendees(e.target.value)}
|
||||
placeholder="예: 김효천, 이지안, 계명대동산병원, PACS"
|
||||
className="w-full rounded-xl border border-neutral-300 px-4 py-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-neutral-500">
|
||||
이름·제품명·사내 용어를 적어두면 전사가 고유명사를 훨씬 정확히 잡습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8 space-y-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium text-neutral-600 min-w-20">
|
||||
@@ -345,7 +384,9 @@ export default function HomePage() {
|
||||
<LiveRecorder
|
||||
onTranscriptReady={handleTranscriptReady}
|
||||
onRecordingReady={handleRecordingReady}
|
||||
onTranscriptReplaced={setTranscript}
|
||||
title={title}
|
||||
vocabularyHint={attendees}
|
||||
liveSummaryEnabled={liveSummaryActive}
|
||||
template={template}
|
||||
depth={depth}
|
||||
@@ -370,12 +411,39 @@ export default function HomePage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{uploadedFile && !transcript && (
|
||||
<section className="mb-8">
|
||||
{uploadedFile && (
|
||||
<section className="mb-8 space-y-3">
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-700">
|
||||
<strong>{uploadedFile.name}</strong> 업로드 완료.
|
||||
실시간 녹음 탭에서 음성 인식을 시작하거나, 텍스트를 직접 입력해주세요.
|
||||
<strong>{uploadedFile.name}</strong> 업로드 완료
|
||||
{uploadedRecordingId
|
||||
? ' — 아래 버튼으로 전사를 시작하세요.'
|
||||
: ' — 서버 저장에 실패해 전사할 수 없습니다.'}
|
||||
</div>
|
||||
|
||||
{uploadedRecordingId && (
|
||||
<button
|
||||
onClick={transcribeUploadedFile}
|
||||
disabled={isTranscribingUpload}
|
||||
className="w-full rounded-xl bg-neutral-900 px-6 py-3 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isTranscribingUpload
|
||||
? '전사 중… 회의 길이에 따라 몇 분 걸릴 수 있습니다'
|
||||
: '🔤 전사 시작'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{uploadTranscribeError && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<strong>전사 오류:</strong> {uploadTranscribeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadTranscription && (
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||
🔤 전사 완료 — {uploadTranscription.model} 모델,{' '}
|
||||
{uploadTranscription.segments.length}개 구간
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function SettingsPage() {
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
const [sttModel, setSttModel] = useState('')
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [savedKey, setSavedKey] = useState<string | null>(null)
|
||||
const [envConfigured, setEnvConfigured] = useState<boolean | null>(null)
|
||||
@@ -44,12 +45,15 @@ export default function SettingsPage() {
|
||||
setApiKey(stored.apiKey)
|
||||
setBaseUrl(stored.baseUrl)
|
||||
setModel(stored.model)
|
||||
setSttModel(stored.sttModel ?? '')
|
||||
setSavedKey(stored.apiKey || null)
|
||||
} else {
|
||||
// 프로바이더 설정 이전에 저장해둔 Gemini 키를 그대로 이어받는다.
|
||||
const legacyKey = getStoredApiKey()
|
||||
setApiKey(legacyKey ?? '')
|
||||
setModel(findPreset(DEFAULT_PRESET_ID).defaultModel)
|
||||
const initial = findPreset(DEFAULT_PRESET_ID)
|
||||
setModel(initial.defaultModel)
|
||||
setSttModel(initial.defaultSttModel)
|
||||
setSavedKey(legacyKey)
|
||||
}
|
||||
|
||||
@@ -66,6 +70,7 @@ export default function SettingsPage() {
|
||||
setPresetId(next.id)
|
||||
setBaseUrl(next.baseUrl)
|
||||
setModel(next.defaultModel)
|
||||
setSttModel(next.defaultSttModel)
|
||||
// 프로바이더가 바뀌면 이전 키는 무의미할 뿐 아니라, 그대로 두면
|
||||
// 다른 회사 엔드포인트로 전송될 수 있으므로 비운다.
|
||||
setApiKey('')
|
||||
@@ -78,6 +83,7 @@ export default function SettingsPage() {
|
||||
apiKey: apiKey.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
model: model.trim(),
|
||||
sttModel: sttModel.trim(),
|
||||
})
|
||||
setSavedKey(apiKey.trim() || null)
|
||||
setSavedToast(true)
|
||||
@@ -99,6 +105,7 @@ export default function SettingsPage() {
|
||||
setApiKey('')
|
||||
setBaseUrl(fallback.baseUrl)
|
||||
setModel(fallback.defaultModel)
|
||||
setSttModel(fallback.defaultSttModel)
|
||||
setSavedKey(null)
|
||||
setTestState({ status: 'idle' })
|
||||
}
|
||||
@@ -286,6 +293,29 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-2 block text-sm text-neutral-600">
|
||||
음성 전사(STT) 모델
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sttModel}
|
||||
onChange={(e) => setSttModel(e.target.value)}
|
||||
placeholder={preset.defaultSttModel || 'whisper-1'}
|
||||
className="w-full rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
녹음 오디오를 텍스트로 옮길 때 쓰는 모델입니다. 요약 모델과 별개입니다.
|
||||
</p>
|
||||
{!preset.canTranscribeWebm && (
|
||||
<p className="mt-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
⚠️ 이 프로바이더는 브라우저 녹음 형식(webm)을 받지 않습니다. 실시간
|
||||
녹음을 전사하려면 OpenAI 호환 프로바이더(OrcaRouter · OpenAI ·
|
||||
로컬 whisper)를 선택하세요. 업로드한 mp3·wav·flac 파일은 전사됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-2 block text-sm text-neutral-600">
|
||||
{preset.apiKeyLabel}
|
||||
|
||||
@@ -5,13 +5,18 @@ import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||
import { useLiveSummary } from '@/hooks/useLiveSummary'
|
||||
import { useAudioRecorder, type CompletedRecording } from '@/hooks/useAudioRecorder'
|
||||
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
||||
import { useTranscription } from '@/hooks/useTranscription'
|
||||
import { formatBytes, formatDuration } from '@/lib/recording'
|
||||
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||
|
||||
interface LiveRecorderProps {
|
||||
onTranscriptReady: (transcript: string) => void
|
||||
onRecordingReady?: (recording: CompletedRecording) => void
|
||||
/** STT 재전사 결과로 전사문을 갈아끼운다. */
|
||||
onTranscriptReplaced?: (transcript: string) => void
|
||||
title: string
|
||||
/** 참석자 등 고유명사 힌트. STT 정확도를 올린다. */
|
||||
vocabularyHint?: string
|
||||
liveSummaryEnabled: boolean
|
||||
template: TemplateId
|
||||
depth: SummaryDepth
|
||||
@@ -21,7 +26,9 @@ interface LiveRecorderProps {
|
||||
export function LiveRecorder({
|
||||
onTranscriptReady,
|
||||
onRecordingReady,
|
||||
onTranscriptReplaced,
|
||||
title,
|
||||
vocabularyHint,
|
||||
liveSummaryEnabled,
|
||||
template,
|
||||
depth,
|
||||
@@ -53,6 +60,14 @@ export function LiveRecorder({
|
||||
reset: resetRecording,
|
||||
} = useAudioRecorder()
|
||||
|
||||
const {
|
||||
isTranscribing,
|
||||
error: transcriptionError,
|
||||
result: transcription,
|
||||
transcribeRecording,
|
||||
reset: resetTranscription,
|
||||
} = useTranscription()
|
||||
|
||||
const {
|
||||
summary,
|
||||
isSummarizing,
|
||||
@@ -106,6 +121,7 @@ export function LiveRecorder({
|
||||
|
||||
async function handleStart() {
|
||||
resetRecording()
|
||||
resetTranscription()
|
||||
// 오디오 녹음을 먼저 건다. 전사가 실패하더라도 원본은 남아야 한다.
|
||||
await startRecording()
|
||||
startListening()
|
||||
@@ -180,6 +196,21 @@ export function LiveRecorder({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{recording?.recordingId && !isListening && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const outcome = await transcribeRecording(recording.recordingId!, {
|
||||
vocabularyHint,
|
||||
})
|
||||
if (outcome?.transcript) onTranscriptReplaced?.(outcome.transcript)
|
||||
}}
|
||||
disabled={isTranscribing}
|
||||
className="flex items-center gap-2 rounded-full bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isTranscribing ? '전사 중… (몇 분 걸릴 수 있습니다)' : '🔤 원본 오디오로 다시 전사'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{recording && !isListening && (
|
||||
<button
|
||||
onClick={() => downloadRecording(title)}
|
||||
@@ -194,6 +225,7 @@ export function LiveRecorder({
|
||||
onClick={() => {
|
||||
resetChunks()
|
||||
resetRecording()
|
||||
resetTranscription()
|
||||
}}
|
||||
className="rounded-full border border-neutral-300 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
@@ -246,6 +278,22 @@ export function LiveRecorder({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcriptionError && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<strong>전사 오류:</strong> {transcriptionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcription && (
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||
<strong>🔤 재전사 완료</strong> — {transcription.model} 모델,{' '}
|
||||
{transcription.segments.length > 0
|
||||
? `${transcription.segments.length}개 구간 (실제 오디오 타임스탬프)`
|
||||
: '타임스탬프 없음'}
|
||||
. 아래 텍스트가 교체되었습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recording && !isListening && (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
|
||||
<strong>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { getProviderRequestPayload } from '@/lib/api-key-storage'
|
||||
import type { TranscriptionSegment } from '@/lib/providers/types'
|
||||
|
||||
export interface TranscriptionOutcome {
|
||||
transcript: string
|
||||
segments: TranscriptionSegment[]
|
||||
model: string
|
||||
hasTimestamps: boolean
|
||||
}
|
||||
|
||||
export interface TranscriptionHook {
|
||||
isTranscribing: boolean
|
||||
error: string | null
|
||||
result: TranscriptionOutcome | null
|
||||
transcribeRecording: (
|
||||
recordingId: string,
|
||||
options?: { vocabularyHint?: string },
|
||||
) => Promise<TranscriptionOutcome | null>
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 보관된 녹음을 서버 STT로 전사한다.
|
||||
*
|
||||
* Web Speech 결과를 덮어쓰는 것이 목적이다. 실시간 텍스트는 회의 중 흐름을
|
||||
* 보기 위한 초안이고, 확정본은 원본 오디오에서 다시 뽑는다.
|
||||
*/
|
||||
export function useTranscription(): TranscriptionHook {
|
||||
const [isTranscribing, setIsTranscribing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<TranscriptionOutcome | null>(null)
|
||||
|
||||
const transcribeRecording = useCallback(
|
||||
async (recordingId: string, options: { vocabularyHint?: string } = {}) => {
|
||||
setIsTranscribing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/transcribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recordingId,
|
||||
language: 'ko',
|
||||
vocabularyHint: options.vocabularyHint,
|
||||
...getProviderRequestPayload(),
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? '전사에 실패했습니다.')
|
||||
return null
|
||||
}
|
||||
|
||||
const outcome: TranscriptionOutcome = {
|
||||
transcript: data.transcript ?? '',
|
||||
segments: Array.isArray(data.segments) ? data.segments : [],
|
||||
model: data.model ?? '',
|
||||
hasTimestamps: Boolean(data.hasTimestamps),
|
||||
}
|
||||
setResult(outcome)
|
||||
return outcome
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? `전사 요청 실패: ${err.message}` : '전사 요청 실패',
|
||||
)
|
||||
return null
|
||||
} finally {
|
||||
setIsTranscribing(false)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setResult(null)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return { isTranscribing, error, result, transcribeRecording, reset }
|
||||
}
|
||||
@@ -48,6 +48,8 @@ export interface StoredProviderConfig {
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
/** 음성 전사 모델. 비우면 프리셋 기본값을 쓴다. */
|
||||
sttModel?: string
|
||||
}
|
||||
|
||||
export interface ProviderRequestPayload {
|
||||
@@ -55,6 +57,7 @@ export interface ProviderRequestPayload {
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
sttModel: string
|
||||
}
|
||||
|
||||
export function getStoredProviderConfig(): StoredProviderConfig | null {
|
||||
@@ -69,6 +72,7 @@ export function getStoredProviderConfig(): StoredProviderConfig | null {
|
||||
apiKey: typeof parsed.apiKey === 'string' ? parsed.apiKey : '',
|
||||
baseUrl: typeof parsed.baseUrl === 'string' ? parsed.baseUrl : '',
|
||||
model: typeof parsed.model === 'string' ? parsed.model : '',
|
||||
sttModel: typeof parsed.sttModel === 'string' ? parsed.sttModel : '',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -108,6 +112,7 @@ export function getProviderRequestPayload(): ProviderRequestPayload {
|
||||
apiKey: getStoredApiKey() ?? '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
sttModel: '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,5 +122,6 @@ export function getProviderRequestPayload(): ProviderRequestPayload {
|
||||
apiKey: stored.apiKey || (preset.provider === 'gemini' ? getStoredApiKey() ?? '' : ''),
|
||||
baseUrl: stored.baseUrl,
|
||||
model: stored.model,
|
||||
sttModel: stored.sttModel ?? '',
|
||||
}
|
||||
}
|
||||
+41
-1
@@ -1,4 +1,9 @@
|
||||
import { isProviderId, type ProviderId, type ProviderSettings } from './providers'
|
||||
import {
|
||||
isProviderId,
|
||||
type ProviderId,
|
||||
type ProviderSettings,
|
||||
type TranscriptionSettings,
|
||||
} from './providers'
|
||||
|
||||
/**
|
||||
* Gemini API 키 해석 우선순위:
|
||||
@@ -88,3 +93,38 @@ function str(value: unknown): string {
|
||||
function env(name: string): string {
|
||||
return (process.env[name] ?? '').trim()
|
||||
}
|
||||
|
||||
export interface TranscriptionRequestBody extends ProviderRequestBody {
|
||||
sttModel?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 전사용 프로바이더 설정.
|
||||
*
|
||||
* 요약과 달리 대화 모델 이름이 없어도 된다. 전사는 `sttModel`(비우면 프로바이더
|
||||
* 기본값)로 별도 엔드포인트를 호출하므로, 대화 모델을 지정하지 않은 사용자도
|
||||
* 전사는 쓸 수 있어야 한다.
|
||||
*/
|
||||
export function resolveTranscriptionSettings(
|
||||
body: TranscriptionRequestBody | null | undefined,
|
||||
): TranscriptionSettings | null {
|
||||
const provider = resolveProvider(body?.provider)
|
||||
const sttModel = str(body?.sttModel) || env('LLM_STT_MODEL')
|
||||
|
||||
if (provider === 'gemini') {
|
||||
const apiKey = resolveGeminiApiKey(str(body?.apiKey) || env('LLM_API_KEY'))
|
||||
if (!apiKey) return null
|
||||
return { provider, apiKey, sttModel: sttModel || undefined }
|
||||
}
|
||||
|
||||
const baseUrl = str(body?.baseUrl) || env('LLM_BASE_URL')
|
||||
if (baseUrl.length === 0) return null
|
||||
|
||||
return {
|
||||
provider,
|
||||
apiKey: str(body?.apiKey) || env('LLM_API_KEY'),
|
||||
baseUrl,
|
||||
model: str(body?.model) || env('LLM_MODEL'),
|
||||
sttModel: sttModel || undefined,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import type {
|
||||
CompletionOptions,
|
||||
CompletionResult,
|
||||
ProviderSettings,
|
||||
TranscriptionInput,
|
||||
TranscriptionOptions,
|
||||
TranscriptionResult,
|
||||
TranscriptionSegment,
|
||||
TranscriptionSettings,
|
||||
} from './types'
|
||||
|
||||
export const DEFAULT_GEMINI_MODEL = 'gemini-3.5-flash-lite'
|
||||
@@ -74,3 +79,187 @@ function describeHttpError(status: number): string {
|
||||
}
|
||||
return `Gemini API 호출 실패: ${status}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini 오디오 입력 기본 모델.
|
||||
* flash-lite는 오디오를 받지 않으므로 요약용 기본값과 다르다.
|
||||
*/
|
||||
export const DEFAULT_GEMINI_STT_MODEL = 'gemini-3.6-flash'
|
||||
|
||||
/**
|
||||
* Gemini가 문서로 밝힌 오디오 형식.
|
||||
*
|
||||
* **webm은 여기에 없다.** 우리 브라우저 녹음이 webm/opus이므로 Gemini로는
|
||||
* 실시간 녹음을 전사할 수 없고, 업로드한 mp3/wav/flac/ogg/m4a만 된다.
|
||||
* 녹음 전사는 Whisper 호환 엔드포인트(OrcaRouter·OpenAI·로컬)를 써야 한다.
|
||||
*/
|
||||
const GEMINI_AUDIO_MIME_TYPES = [
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/mp3',
|
||||
'audio/mpeg',
|
||||
'audio/aiff',
|
||||
'audio/aac',
|
||||
'audio/ogg',
|
||||
'audio/flac',
|
||||
'audio/mp4',
|
||||
'audio/x-m4a',
|
||||
]
|
||||
|
||||
export function isGeminiSupportedAudioMimeType(mimeType: string): boolean {
|
||||
const base = mimeType.split(';')[0].trim().toLowerCase()
|
||||
return GEMINI_AUDIO_MIME_TYPES.includes(base)
|
||||
}
|
||||
|
||||
/**
|
||||
* inline_data로 보낼 수 있는 최대 오디오 크기.
|
||||
*
|
||||
* generateContent 요청 전체가 20MB를 넘으면 안 되는데 base64가 약 4/3배로
|
||||
* 부풀리므로, 원본 기준 14MB에서 끊는다. 더 큰 파일은 Files API가 필요하다.
|
||||
*/
|
||||
export const GEMINI_INLINE_AUDIO_LIMIT = 14 * 1024 * 1024
|
||||
|
||||
function buildTranscriptionPrompt(input: TranscriptionInput): string {
|
||||
const lines = [
|
||||
'이 오디오는 회의 녹음입니다. 들리는 발화를 그대로 받아쓰세요.',
|
||||
'',
|
||||
'규칙:',
|
||||
'- 요약하거나 문장을 다듬지 말고 말한 그대로 옮깁니다.',
|
||||
'- 들리지 않는 구간은 지어내지 말고 건너뜁니다.',
|
||||
'- 각 발화 앞에 `[MM:SS]` 형식으로 시작 시각을 붙입니다.',
|
||||
'- 설명이나 머리말 없이 전사문만 출력합니다.',
|
||||
]
|
||||
|
||||
if (input.vocabularyHint) {
|
||||
lines.push(
|
||||
'',
|
||||
`이 회의에 나올 수 있는 고유명사·용어: ${input.vocabularyHint}`,
|
||||
)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** `[MM:SS] 텍스트` 또는 `[HH:MM:SS] 텍스트` 줄을 구간으로 바꾼다. */
|
||||
export function parseTimestampedTranscript(text: string): TranscriptionSegment[] {
|
||||
const segments: TranscriptionSegment[] = []
|
||||
const pattern = /^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\]\s*(.+)$/
|
||||
|
||||
for (const line of text.split('\n')) {
|
||||
const match = pattern.exec(line.trim())
|
||||
if (!match) continue
|
||||
|
||||
const [, h, m, s, body] = match
|
||||
const start = (h ? Number(h) * 3600 : 0) + Number(m) * 60 + Number(s)
|
||||
const content = body.trim()
|
||||
if (content.length === 0) continue
|
||||
|
||||
// 다음 구간이 시작될 때까지가 이 구간이다. 마지막은 뒤에서 채운다.
|
||||
if (segments.length > 0) {
|
||||
segments[segments.length - 1].end = start
|
||||
}
|
||||
segments.push({ start, end: start, text: content })
|
||||
}
|
||||
|
||||
if (segments.length > 0) {
|
||||
const last = segments[segments.length - 1]
|
||||
if (last.end <= last.start) last.end = last.start + 1
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
/** Gemini에 오디오를 inline으로 실어 전사한다. */
|
||||
export async function transcribeWithGemini(
|
||||
input: TranscriptionInput,
|
||||
settings: TranscriptionSettings,
|
||||
options: TranscriptionOptions = {},
|
||||
): Promise<TranscriptionResult> {
|
||||
const { fetchFn = fetch } = options
|
||||
const apiKey = settings.apiKey.trim()
|
||||
|
||||
if (apiKey.length === 0) {
|
||||
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
||||
}
|
||||
|
||||
const baseMime = input.mimeType.split(';')[0].trim().toLowerCase()
|
||||
|
||||
if (!isGeminiSupportedAudioMimeType(input.mimeType)) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
`Gemini는 ${baseMime} 형식을 받지 않습니다. ` +
|
||||
'브라우저 녹음(webm)을 전사하려면 설정에서 OpenAI 호환 프로바이더' +
|
||||
'(OrcaRouter · OpenAI · 로컬 whisper)를 선택해주세요.',
|
||||
}
|
||||
}
|
||||
|
||||
if (input.bytes.byteLength > GEMINI_INLINE_AUDIO_LIMIT) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
`오디오가 너무 큽니다 (${Math.round(input.bytes.byteLength / 1024 / 1024)}MB). ` +
|
||||
`Gemini 직접 호출은 ${Math.round(GEMINI_INLINE_AUDIO_LIMIT / 1024 / 1024)}MB까지만 가능합니다. ` +
|
||||
'OpenAI 호환 프로바이더를 쓰거나 오디오를 나눠주세요.',
|
||||
}
|
||||
}
|
||||
|
||||
const model = settings.sttModel?.trim() || DEFAULT_GEMINI_STT_MODEL
|
||||
const url = `${API_ROOT}/${encodeURIComponent(model)}:generateContent`
|
||||
|
||||
try {
|
||||
const response = await fetchFn(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
parts: [
|
||||
{ text: buildTranscriptionPrompt(input) },
|
||||
{
|
||||
inline_data: {
|
||||
mime_type: baseMime,
|
||||
data: Buffer.from(input.bytes).toString('base64'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Gemini API 요청 한도 초과. 잠시 후 다시 시도해주세요.',
|
||||
rateLimited: true,
|
||||
}
|
||||
}
|
||||
return { success: false, error: describeHttpError(response.status) }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const text: string = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
|
||||
|
||||
if (text.trim().length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: '전사 결과가 비어 있습니다. 오디오에 음성이 들어 있는지 확인해주세요.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
text: text.trim(),
|
||||
segments: parseTimestampedTranscript(text),
|
||||
model,
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return { success: false, error: `전사 호출 중 오류: ${message}` }
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,39 @@
|
||||
import { completeWithGemini, resolveGeminiModel } from './gemini'
|
||||
import { completeWithOpenAICompatible } from './openai-compatible'
|
||||
import {
|
||||
DEFAULT_GEMINI_STT_MODEL,
|
||||
completeWithGemini,
|
||||
resolveGeminiModel,
|
||||
transcribeWithGemini,
|
||||
} from './gemini'
|
||||
import {
|
||||
DEFAULT_OPENAI_STT_MODEL,
|
||||
completeWithOpenAICompatible,
|
||||
transcribeWithOpenAICompatible,
|
||||
} from './openai-compatible'
|
||||
import type {
|
||||
CompletionOptions,
|
||||
CompletionResult,
|
||||
ProviderSettings,
|
||||
TranscriptionInput,
|
||||
TranscriptionOptions,
|
||||
TranscriptionResult,
|
||||
TranscriptionSettings,
|
||||
} from './types'
|
||||
|
||||
export * from './types'
|
||||
export * from './presets'
|
||||
export { DEFAULT_GEMINI_MODEL, resolveGeminiModel } from './gemini'
|
||||
export { normalizeBaseUrl } from './openai-compatible'
|
||||
export {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_STT_MODEL,
|
||||
GEMINI_INLINE_AUDIO_LIMIT,
|
||||
isGeminiSupportedAudioMimeType,
|
||||
parseTimestampedTranscript,
|
||||
resolveGeminiModel,
|
||||
} from './gemini'
|
||||
export {
|
||||
DEFAULT_OPENAI_STT_MODEL,
|
||||
isWhisperSupportedMimeType,
|
||||
normalizeBaseUrl,
|
||||
} from './openai-compatible'
|
||||
|
||||
/**
|
||||
* 설정된 프로바이더로 프롬프트 1회 호출.
|
||||
@@ -41,3 +65,31 @@ export function toProviderSettings(
|
||||
): ProviderSettings {
|
||||
return settings ?? { provider: 'gemini', apiKey: apiKey ?? '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정된 프로바이더로 오디오 1건 전사.
|
||||
*
|
||||
* 요약과 마찬가지로 실패를 예외로 던지지 않는다. 전사는 회의가 끝난 뒤
|
||||
* 한 번뿐인 기회이므로, 호출부가 오류 문구를 그대로 사용자에게 보여주고
|
||||
* 원본 오디오를 내려받도록 안내할 수 있어야 한다.
|
||||
*/
|
||||
export async function transcribe(
|
||||
input: TranscriptionInput,
|
||||
settings: TranscriptionSettings,
|
||||
options: TranscriptionOptions = {},
|
||||
): Promise<TranscriptionResult> {
|
||||
if (settings.provider === 'openai-compatible') {
|
||||
return transcribeWithOpenAICompatible(input, settings, options)
|
||||
}
|
||||
return transcribeWithGemini(input, settings, options)
|
||||
}
|
||||
|
||||
/** 전사에 실제로 쓰일 모델 이름. */
|
||||
export function resolveSttModel(settings: TranscriptionSettings): string {
|
||||
const explicit = settings.sttModel?.trim()
|
||||
if (explicit) return explicit
|
||||
|
||||
return settings.provider === 'openai-compatible'
|
||||
? DEFAULT_OPENAI_STT_MODEL
|
||||
: DEFAULT_GEMINI_STT_MODEL
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import type {
|
||||
CompletionOptions,
|
||||
CompletionResult,
|
||||
ProviderSettings,
|
||||
TranscriptionInput,
|
||||
TranscriptionOptions,
|
||||
TranscriptionResult,
|
||||
TranscriptionSegment,
|
||||
TranscriptionSettings,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
@@ -110,3 +115,142 @@ function describeHttpError(status: number): string {
|
||||
}
|
||||
return `API 호출 실패: ${status}`
|
||||
}
|
||||
|
||||
/** Whisper 계열 기본 모델. OrcaRouter·OpenAI·로컬 whisper.cpp 모두 이 이름을 쓴다. */
|
||||
export const DEFAULT_OPENAI_STT_MODEL = 'whisper-1'
|
||||
|
||||
/**
|
||||
* Whisper가 받아주는 컨테이너.
|
||||
* 우리 녹음(webm/opus)이 여기 포함되므로 별도 변환 없이 그대로 보낸다.
|
||||
*/
|
||||
const WHISPER_MIME_TYPES = [
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/mpeg',
|
||||
'audio/mpga',
|
||||
'audio/m4a',
|
||||
'audio/x-m4a',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/ogg',
|
||||
'audio/flac',
|
||||
]
|
||||
|
||||
export function isWhisperSupportedMimeType(mimeType: string): boolean {
|
||||
const base = mimeType.split(';')[0].trim().toLowerCase()
|
||||
return WHISPER_MIME_TYPES.includes(base)
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI `/audio/transcriptions` 호환 엔드포인트로 전사.
|
||||
*
|
||||
* `verbose_json`을 먼저 요청한다. 구간별 실제 타임스탬프가 같이 오기 때문이다 —
|
||||
* Web Speech가 주지 못하던 진짜 오디오 타임라인이며, 화자 분리를 얹으려면
|
||||
* 반드시 필요하다. 이 형식을 지원하지 않는 최신 모델(gpt-4o-transcribe 등)은
|
||||
* 400을 돌려주므로 그때는 `json`으로 한 번 더 시도한다.
|
||||
*/
|
||||
export async function transcribeWithOpenAICompatible(
|
||||
input: TranscriptionInput,
|
||||
settings: TranscriptionSettings,
|
||||
options: TranscriptionOptions = {},
|
||||
): Promise<TranscriptionResult> {
|
||||
const { fetchFn = fetch } = options
|
||||
|
||||
const baseUrl = normalizeBaseUrl(settings.baseUrl ?? '')
|
||||
if (!baseUrl) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API 주소(base URL)가 올바르지 않습니다. http:// 또는 https:// 로 시작해야 합니다.',
|
||||
}
|
||||
}
|
||||
|
||||
if (!isWhisperSupportedMimeType(input.mimeType)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `이 엔드포인트가 지원하지 않는 오디오 형식입니다 (${input.mimeType}).`,
|
||||
}
|
||||
}
|
||||
|
||||
const model = settings.sttModel?.trim() || DEFAULT_OPENAI_STT_MODEL
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
const apiKey = settings.apiKey.trim()
|
||||
if (apiKey.length > 0) {
|
||||
headers.Authorization = `Bearer ${apiKey}`
|
||||
}
|
||||
// Content-Type은 지정하지 않는다. FormData가 boundary까지 붙여 설정한다.
|
||||
|
||||
async function send(responseFormat: 'verbose_json' | 'json') {
|
||||
const form = new FormData()
|
||||
form.append(
|
||||
'file',
|
||||
new Blob([input.bytes as BlobPart], { type: input.mimeType }),
|
||||
input.fileName,
|
||||
)
|
||||
form.append('model', model)
|
||||
form.append('response_format', responseFormat)
|
||||
if (input.language) form.append('language', input.language)
|
||||
if (input.vocabularyHint) form.append('prompt', input.vocabularyHint)
|
||||
|
||||
return fetchFn(`${baseUrl}/audio/transcriptions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
let response = await send('verbose_json')
|
||||
|
||||
// verbose_json 미지원 모델 → 타임스탬프를 포기하고 텍스트만 받는다.
|
||||
if (response.status === 400) {
|
||||
response = await send('json')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
success: false,
|
||||
error: '전사 API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
|
||||
rateLimited: true,
|
||||
}
|
||||
}
|
||||
return { success: false, error: describeHttpError(response.status) }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const text: string = typeof data?.text === 'string' ? data.text : ''
|
||||
|
||||
if (text.trim().length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: '전사 결과가 비어 있습니다. 오디오에 음성이 들어 있는지 확인해주세요.',
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, text, segments: parseWhisperSegments(data), model }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return { success: false, error: `전사 호출 중 오류: ${message}` }
|
||||
}
|
||||
}
|
||||
|
||||
function parseWhisperSegments(data: unknown): TranscriptionSegment[] {
|
||||
const raw = (data as { segments?: unknown })?.segments
|
||||
if (!Array.isArray(raw)) return []
|
||||
|
||||
const segments: TranscriptionSegment[] = []
|
||||
|
||||
for (const item of raw) {
|
||||
const start = Number((item as { start?: unknown })?.start)
|
||||
const end = Number((item as { end?: unknown })?.end)
|
||||
const text = String((item as { text?: unknown })?.text ?? '').trim()
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) continue
|
||||
if (text.length === 0) continue
|
||||
|
||||
segments.push({ start: Math.max(0, start), end: Math.max(start, end), text })
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DEFAULT_GEMINI_MODEL } from './gemini'
|
||||
import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_STT_MODEL } from './gemini'
|
||||
import { DEFAULT_OPENAI_STT_MODEL } from './openai-compatible'
|
||||
import type { ProviderId } from './types'
|
||||
|
||||
export interface ProviderPreset {
|
||||
@@ -8,6 +9,10 @@ export interface ProviderPreset {
|
||||
/** openai-compatible 프리셋의 기본 base URL. 사용자가 수정할 수 있다. */
|
||||
baseUrl: string
|
||||
defaultModel: string
|
||||
/** 음성 전사(STT)에 쓸 기본 모델. 요약용 모델과 다르다. */
|
||||
defaultSttModel: string
|
||||
/** 이 프로바이더로 브라우저 녹음(webm)을 전사할 수 있는지. */
|
||||
canTranscribeWebm: boolean
|
||||
description: string
|
||||
apiKeyLabel: string
|
||||
apiKeyPlaceholder: string
|
||||
@@ -25,6 +30,9 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
provider: 'gemini',
|
||||
baseUrl: '',
|
||||
defaultModel: DEFAULT_GEMINI_MODEL,
|
||||
defaultSttModel: DEFAULT_GEMINI_STT_MODEL,
|
||||
// Gemini는 webm 오디오를 받지 않는다 — 업로드한 mp3/wav/flac만 전사 가능.
|
||||
canTranscribeWebm: false,
|
||||
description: 'Google에 직접 호출합니다. 무료 티어가 있어 가장 간단합니다.',
|
||||
apiKeyLabel: 'Gemini API 키',
|
||||
apiKeyPlaceholder: 'AIzaSy...',
|
||||
@@ -39,7 +47,9 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
description: 'OpenAI Chat Completions API를 사용합니다.',
|
||||
defaultSttModel: DEFAULT_OPENAI_STT_MODEL,
|
||||
canTranscribeWebm: true,
|
||||
description: 'OpenAI Chat Completions + Whisper 전사를 사용합니다.',
|
||||
apiKeyLabel: 'OpenAI API 키',
|
||||
apiKeyPlaceholder: 'sk-...',
|
||||
docsUrl: 'https://platform.openai.com/api-keys',
|
||||
@@ -52,6 +62,8 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||
defaultModel: 'google/gemini-3.5-flash-lite',
|
||||
defaultSttModel: 'openai/whisper-1',
|
||||
canTranscribeWebm: true,
|
||||
description:
|
||||
'하나의 키로 여러 제공사 모델을 사용합니다. 별도 가입과 크레딧 충전(또는 BYOK 등록)이 필요합니다.',
|
||||
apiKeyLabel: 'OrcaRouter API 키',
|
||||
@@ -67,6 +79,8 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: 'http://localhost:11434/v1',
|
||||
defaultModel: 'llama3.1',
|
||||
defaultSttModel: 'whisper-1',
|
||||
canTranscribeWebm: true,
|
||||
description:
|
||||
'Ollama · LM Studio · vLLM 등 내 PC에서 도는 모델. 회의 내용이 외부로 나가지 않습니다.',
|
||||
apiKeyLabel: 'API 키 (보통 불필요)',
|
||||
@@ -80,6 +94,8 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: '',
|
||||
defaultModel: '',
|
||||
defaultSttModel: DEFAULT_OPENAI_STT_MODEL,
|
||||
canTranscribeWebm: true,
|
||||
description: 'OpenAI 호환 엔드포인트라면 무엇이든 연결할 수 있습니다.',
|
||||
apiKeyLabel: 'API 키',
|
||||
apiKeyPlaceholder: 'sk-...',
|
||||
|
||||
@@ -30,3 +30,53 @@ export type CompletionResult =
|
||||
export interface CompletionOptions {
|
||||
fetchFn?: typeof fetch
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 음성 전사 (STT)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 전사할 오디오.
|
||||
*
|
||||
* 파일 전체를 메모리에 올린다. 회의 하나가 상한(수십 MB) 안에 들어오도록
|
||||
* 녹음 비트레이트를 낮춰 두었으므로 스트리밍까지 갈 필요는 없다.
|
||||
*/
|
||||
export interface TranscriptionInput {
|
||||
bytes: Uint8Array
|
||||
mimeType: string
|
||||
fileName: string
|
||||
/** BCP-47 앞부분. 예: 'ko'. 지정하면 인식 정확도가 눈에 띄게 오른다. */
|
||||
language?: string
|
||||
/**
|
||||
* 어휘 힌트. 참석자 이름·제품명·사내 용어를 넣으면 고유명사 오인식이 준다.
|
||||
* Whisper는 이 문자열을 직전 문맥처럼 취급한다(약 224토큰까지).
|
||||
*/
|
||||
vocabularyHint?: string
|
||||
}
|
||||
|
||||
/** 전사 구간. Whisper `verbose_json`이 주는 실제 오디오 타임라인 기준. */
|
||||
export interface TranscriptionSegment {
|
||||
/** 초 단위, 오디오 시작 기준. */
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export type TranscriptionResult =
|
||||
| {
|
||||
success: true
|
||||
text: string
|
||||
/** 프로바이더가 타임스탬프를 주지 않으면 비어 있다. */
|
||||
segments: TranscriptionSegment[]
|
||||
model: string
|
||||
}
|
||||
| { success: false; error: string; rateLimited?: boolean }
|
||||
|
||||
export interface TranscriptionOptions {
|
||||
fetchFn?: typeof fetch
|
||||
}
|
||||
|
||||
/** 전사에 쓸 모델은 대화용 모델과 다르므로 따로 받는다. */
|
||||
export interface TranscriptionSettings extends ProviderSettings {
|
||||
sttModel?: string
|
||||
}
|
||||
+11
-2
@@ -40,8 +40,17 @@ export const RECORDING_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
|
||||
*/
|
||||
export const CHUNK_INTERVAL_MS = 15_000
|
||||
|
||||
/** 음성 전용이므로 128kbps면 STT에 충분하고도 남는다. */
|
||||
export const AUDIO_BITS_PER_SECOND = 128_000
|
||||
/**
|
||||
* 녹음 비트레이트.
|
||||
*
|
||||
* 이 오디오는 감상용이 아니라 STT 입력이다. Opus는 음성 대역에서 32kbps만
|
||||
* 되어도 인식 정확도에 영향이 없고, 대신 파일이 작아야 전사 API에 통째로
|
||||
* 넣을 수 있다. 128kbps로 두면 46분 회의가 44MB가 되어 Whisper 상한(25MB)도,
|
||||
* Gemini inline 상한도 넘긴다.
|
||||
*
|
||||
* 32kbps 기준 대략: 46분 → 11MB, 1시간 → 14MB, 1시간 40분 → 24MB
|
||||
*/
|
||||
export const AUDIO_BITS_PER_SECOND = 32_000
|
||||
|
||||
/** 회의 하나의 상한. 128kbps 기준 약 8.6시간. */
|
||||
export const MAX_RECORDING_BYTES = 500 * 1024 * 1024
|
||||
|
||||
@@ -50,3 +50,32 @@ export function mergeAdjacentChunks(
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 STT가 준 구간을 전사문으로 옮긴다.
|
||||
*
|
||||
* 여기 붙는 타임스탬프는 실제 오디오 타임라인이다. Web Speech 경로가 쓰던
|
||||
* `결과 이벤트 시각 − 2초` 추정값과 달리 화자 분리·구간 재생에 그대로 쓸 수 있다.
|
||||
*/
|
||||
export function formatTranscriptionSegments(
|
||||
segments: readonly { start: number; text: string }[],
|
||||
): string {
|
||||
return segments
|
||||
.filter((segment) => segment.text.trim().length > 0)
|
||||
.map((segment) => `${formatTimestamp(segment.start)} ${segment.text.trim()}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/** STT 구간을 기존 청크 구조로 변환한다 (화자 배정·병합 로직 재사용용). */
|
||||
export function segmentsToChunks(
|
||||
segments: readonly { start: number; end: number; text: string }[],
|
||||
): TranscriptChunk[] {
|
||||
return segments
|
||||
.filter((segment) => segment.text.trim().length > 0)
|
||||
.map((segment) => ({
|
||||
text: segment.text.trim(),
|
||||
startTime: Math.max(0, segment.start),
|
||||
endTime: Math.max(segment.start, segment.end),
|
||||
isFinal: true,
|
||||
}))
|
||||
}
|
||||
Reference in new issue
Block a user