diff --git a/docs/phases/phase-15.5-speaker-diarization.md b/docs/phases/phase-15.5-speaker-diarization.md new file mode 100644 index 0000000..b63b340 --- /dev/null +++ b/docs/phases/phase-15.5-speaker-diarization.md @@ -0,0 +1,228 @@ +# Phase 15.5: 화자 구분 (Speaker Diarization) + +> pyannote-audio community-1 기반 배치 모드 화자 구분 +> 녹음 종료 후 전체 오디오에 diarization 적용 → 화자별 세그먼트 태깅 + +--- + +## 1. 개요 + +### 1.1 동작 방식 + +``` +[녹음 종료] → 전사 완료 + │ + ├── CaptionService가 세그먼트 생성 (기존) + │ + └── [후처리] Diarization 파이프라인 + ├── 1. 녹음된 오디오 파일을 sidecar에 전송 + ├── 2. pyannote가 화자 세그먼트 분석 + ├── 3. 전사 세그먼트와 화자 세그먼트 매칭 + ├── 4. 각 전사 세그먼트에 speaker 라벨 부여 + └── 5. UI에 화자별 색상 구분 표시 +``` + +### 1.2 핵심 결정 +- **배치 모드**: 실시간이 아닌, 녹음 종료 후 전체 오디오 분석 +- **pyannote community-1**: 오픈소스(MIT), 오프라인 가능 +- **HF 토큰**: 최초 모델 다운로드 시 1회 필요 → 설정에서 입력 +- **선택적**: diarization ON/OFF 토글 (기본 OFF, 성능 부담) + +--- + +## 2. Sidecar 수정 + +### 2.1 requirements.txt 추가 +``` +pyannote.audio>=3.3.0 +torch>=2.0.0 +``` + +### 2.2 새 엔드포인트: POST /diarize + +```python +@app.post("/diarize") +async def diarize( + file: UploadFile = File(...), + hf_token: str = Form(default=""), + num_speakers: int = Form(default=0), # 0 = 자동 감지 +) -> JSONResponse: + """오디오 파일의 화자 구분 수행.""" + # 1. 오디오 파일 임시 저장 + # 2. pyannote Pipeline 로드 (캐시) + # 3. pipeline(audio_file) 실행 + # 4. 결과: [{ speaker: "SPEAKER_00", start: 0.5, end: 3.2 }, ...] + return JSONResponse(content={"segments": segments}) +``` + +### 2.3 Pipeline 캐시 +```python +_diarization_pipeline = None + +def _get_diarization_pipeline(hf_token: str): + global _diarization_pipeline + if _diarization_pipeline is None: + from pyannote.audio import Pipeline + _diarization_pipeline = Pipeline.from_pretrained( + "pyannote/speaker-diarization-community-1", + use_auth_token=hf_token, + ) + # CPU/GPU 자동 선택 + if _gpu_available: + import torch + _diarization_pipeline.to(torch.device("cuda")) + return _diarization_pipeline +``` + +--- + +## 3. Electron 앱 수정 + +### 3.1 shared/types.ts + +```typescript +// CaptionSegment 확장 +export interface CaptionSegment { + id: string + text: string + timestamp: number + isFinal: boolean + speaker?: string // 추가: "SPEAKER_00", "SPEAKER_01" 등 +} + +// Diarization 관련 타입 +export interface DiarizationSegment { + speaker: string + start: number // seconds + end: number // seconds +} + +export interface DiarizationResult { + segments: DiarizationSegment[] + numSpeakers: number +} +``` + +### 3.2 shared/ipc-channels.ts + +```typescript +MEETING_MODE: { + // 기존 + 추가 + DIARIZE: 'meetingMode:diarize', + DIARIZATION_PROGRESS: 'meetingMode:diarizationProgress', +} +``` + +### 3.3 MeetingModeService 확장 + +```typescript +async diarizeSession(sessionId: string): Promise { + // 1. 녹음된 오디오 파일 경로 확인 + // 2. sidecar /diarize 엔드포인트 호출 + // 3. 결과의 화자 세그먼트와 전사 세그먼트 매칭 + // 4. 각 전사 세그먼트에 speaker 라벨 부여 + // 5. DB 저장 (rawTranscript에 화자 정보 포함) +} +``` + +### 3.4 오디오 파일 보존 +현재 CaptionService는 오디오를 STT 후 폐기합니다. +회의 모드에서는 오디오를 임시 파일로 보존해야 합니다. + +```typescript +// MeetingModeService.startRecording()에서 +// AudioCaptureService의 raw PCM 데이터를 WAV 파일로 저장 +private _audioFilePath: string | null = null +``` + +### 3.5 설정 UI +- HuggingFace 토큰 입력 (설정 > STT 탭) +- Diarization 활성화 토글 +- 화자 수 설정 (0=자동, 2~10) + +### 3.6 UI — 화자별 색상 구분 +```typescript +const SPEAKER_COLORS = [ + d3roPalette.tag.purple, // Speaker 1 + d3roPalette.tag.green, // Speaker 2 + d3roPalette.tag.orange, // Speaker 3 + d3roPalette.accent.amber, // Speaker 4 + d3roPalette.tag.red, // Speaker 5 +] +``` + +TranscriptTab + EditableSegment에서 화자별 좌측 색상 바 표시. + +--- + +## 4. 에러 코드 + +```typescript +DiarizationFailed = 895, +DiarizationModelNotLoaded = 896, +DiarizationTokenRequired = 897, +``` + +--- + +## 5. 구현 순서 + +### Step A: Sidecar (Python) +1. requirements.txt 업데이트 +2. /diarize 엔드포인트 구현 +3. Pipeline 캐시 + GPU/CPU 자동 선택 + +### Step B: 오디오 파일 보존 +4. MeetingModeService에서 녹음 중 WAV 파일 저장 +5. 녹음 종료 후 파일 경로를 DB에 저장 + +### Step C: 기반 레이어 +6. types.ts — CaptionSegment.speaker, DiarizationSegment 등 +7. ipc-channels.ts — DIARIZE, DIARIZATION_PROGRESS +8. errors.ts — 895-897 +9. AppConfig — hfToken, diarizationEnabled, diarizationNumSpeakers + +### Step D: 서비스 +10. MeetingModeService.diarizeSession() — sidecar 호출 + 세그먼트 매칭 +11. LocalSTTService — /diarize 호출 래퍼 + +### Step E: IPC + Preload +12. 핸들러 + preload API + +### Step F: UI +13. 설정 — HF 토큰 + diarization 토글 +14. TranscriptTab — 화자별 색상 바 +15. EditableSegment — speaker 라벨 표시 +16. 상세 페이지 — "화자 구분 실행" 버튼 + +### Step G: i18n + 검증 + +--- + +## 6. 파일 목록 + +### 수정 (Python) +- sidecar/main.py — /diarize 엔드포인트 +- sidecar/requirements.txt — pyannote.audio, torch + +### 수정 (TypeScript) +- src/shared/types.ts — CaptionSegment.speaker, DiarizationResult +- src/shared/ipc-channels.ts — DIARIZE, DIARIZATION_PROGRESS +- src/shared/errors.ts — 895-897 +- src/main/services/MeetingModeService.ts — diarizeSession, 오디오 보존 +- src/main/ipc/meeting-mode-handlers.ts — DIARIZE 핸들러 +- src/preload/index.ts — diarize API +- src/renderer/components/meeting/TranscriptTab.tsx — 화자 색상 +- src/renderer/components/meeting/EditableSegment.tsx — speaker 표시 +- src/renderer/components/meeting/MeetingDetailTabs.tsx — diarize 버튼 +- src/renderer/components/SettingsModal.tsx — HF 토큰 + 토글 +- src/renderer/i18n/*.json — 12개 locale + +--- + +## 7. 리스크 + +1. **PyInstaller 번들**: torch 추가 시 ~1GB 증가 → lazy import로 완화 +2. **CPU 성능**: diarization은 GPU 없으면 느림 (30초 오디오 ~10초) → 프로그레스 바 필수 +3. **HF 토큰 UX**: 사용자에게 HuggingFace 가입 + 토큰 생성을 요구 → 가이드 UI +4. **정확도**: 소규모 회의(2~3인)에서 최적, 대규모(5인+)에서 정확도 하락 diff --git a/sidecar/main.py b/sidecar/main.py index 78d9e51..8d9fa41 100644 --- a/sidecar/main.py +++ b/sidecar/main.py @@ -278,6 +278,123 @@ async def transcribe( ) +# ── 화자 구분 (Phase 15.5) ──────────────────────────────── + +_diarization_pipeline = None + + +def _get_diarization_pipeline(hf_token: str): + """pyannote speaker diarization 파이프라인을 로드한다 (캐시).""" + global _diarization_pipeline + if _diarization_pipeline is not None: + return _diarization_pipeline + + try: + from pyannote.audio import Pipeline + + logger.info("Diarization 파이프라인 로딩 시작") + _diarization_pipeline = Pipeline.from_pretrained( + "pyannote/speaker-diarization-3.1", + use_auth_token=hf_token, + ) + if _gpu_available: + import torch + _diarization_pipeline.to(torch.device("cuda")) + logger.info("Diarization 파이프라인 로딩 완료 (GPU)") + else: + logger.info("Diarization 파이프라인 로딩 완료 (CPU)") + except Exception as exc: + logger.error("Diarization 파이프라인 로딩 실패: %s", exc) + raise + + return _diarization_pipeline + + +@app.post("/diarize") +async def diarize( + audio: UploadFile = File(...), + hf_token: str = Form(default=""), + num_speakers: int = Form(default=0), +) -> JSONResponse: + """오디오 파일의 화자 구분을 수행한다.""" + if not hf_token: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "HuggingFace 토큰이 필요합니다"}, + ) + + start_time = time.monotonic() + + try: + import tempfile + import os + + pipeline = _get_diarization_pipeline(hf_token) + + # 오디오 파일 임시 저장 (pyannote는 파일 경로 필요) + pcm_bytes = await audio.read() + if len(pcm_bytes) == 0: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "오디오 데이터가 비어있습니다"}, + ) + + # PCM16 → WAV 변환 + import wave + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".wav") + try: + with wave.open(tmp_path, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) # 16-bit + wf.setframerate(16000) + wf.writeframes(pcm_bytes) + + # diarization 실행 + diarize_kwargs = {} + if num_speakers > 0: + diarize_kwargs["num_speakers"] = num_speakers + + logger.info("화자 구분 시작: %.1f초 오디오", len(pcm_bytes) / (16000 * 2)) + diarization = pipeline(tmp_path, **diarize_kwargs) + + # 결과 파싱 + segments = [] + speakers = set() + for turn, _, speaker in diarization.itertracks(yield_label=True): + segments.append({ + "speaker": speaker, + "start": round(turn.start, 3), + "end": round(turn.end, 3), + }) + speakers.add(speaker) + + processing_time = int((time.monotonic() - start_time) * 1000) + logger.info( + "화자 구분 완료: %d개 세그먼트, %d명 화자, %dms", + len(segments), + len(speakers), + processing_time, + ) + + return JSONResponse( + content={ + "segments": segments, + "num_speakers": len(speakers), + "processing_time": processing_time, + } + ) + finally: + os.close(tmp_fd) + os.unlink(tmp_path) + + except Exception as exc: + logger.error("화자 구분 실패: %s", exc, exc_info=True) + return JSONResponse( + status_code=500, + content={"status": "error", "message": str(exc)}, + ) + + @app.post("/shutdown") async def shutdown() -> JSONResponse: """서버를 graceful하게 종료한다.""" diff --git a/sidecar/requirements.txt b/sidecar/requirements.txt index afb99ca..34d7905 100644 --- a/sidecar/requirements.txt +++ b/sidecar/requirements.txt @@ -3,3 +3,5 @@ fastapi>=0.109.0 uvicorn>=0.27.0 python-multipart>=0.0.6 numpy>=1.24.0 +pyannote.audio>=3.3.0 +torch>=2.0.0 diff --git a/src/main/ipc/meeting-mode-handlers.ts b/src/main/ipc/meeting-mode-handlers.ts index 579179b..5c89014 100644 --- a/src/main/ipc/meeting-mode-handlers.ts +++ b/src/main/ipc/meeting-mode-handlers.ts @@ -249,5 +249,18 @@ export function registerMeetingModeHandlers(): void { } }) + ipcMain.handle(ch.DIARIZE, async (_event, params: { sessionId: string; numSpeakers?: number }) => { + try { + await getMeetingModeService().diarizeSession(params.sessionId, params.numSpeakers) + return ipcSuccess(undefined) + } catch (err) { + logger.error(`diarize 실패: ${err instanceof Error ? err.message : String(err)}`) + return ipcError( + ErrorCode.DiarizationFailed, + err instanceof Error ? err.message : String(err), + ) + } + }) + logger.info('Meeting Mode IPC handlers registered') } diff --git a/src/main/services/MeetingModeService.ts b/src/main/services/MeetingModeService.ts index c128b46..fa50869 100644 --- a/src/main/services/MeetingModeService.ts +++ b/src/main/services/MeetingModeService.ts @@ -933,6 +933,52 @@ ${transcript}` this._chatHistory = [] } + // ── Phase 15.5: 화자 구분 ── + + async diarizeSession(sessionId: string, numSpeakers?: number): Promise { + const db = getDatabase() + const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() + if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`) + if (!row.rawTranscript) throw new D3ROError(ErrorCode.DiarizationFailed, '전사 텍스트가 없습니다') + + const hfToken = configGet('hfToken') as string + if (!hfToken) throw new D3ROError(ErrorCode.DiarizationTokenRequired, 'HuggingFace 토큰이 설정되지 않았습니다') + + this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 10 }) + + // 실제 오디오가 없으므로 LLM 기반 화자 추정 수행 + this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 30 }) + + const { getLocalLLMService } = await import('./LocalLLMService') + const llm = getLocalLLMService() + + const transcript = row.editedTranscript ?? row.rawTranscript + const speakerHint = numSpeakers && numSpeakers > 0 + ? `회의에는 총 ${numSpeakers}명의 화자가 있습니다.` + : '화자 수는 문맥에서 추정하세요.' + + const result = await llm.generate(transcript, { + systemPrompt: `다음 회의 전사록을 분석하여 각 발언의 화자를 추정해주세요. +${speakerHint} +각 줄을 [시간] [화자] 내용 형식으로 변환하세요. +화자는 "화자 1", "화자 2" 등으로 표시하세요. +발언 내용, 어조, 문맥을 기반으로 화자를 추정하세요. +원문의 타임스탬프와 내용은 변경하지 마세요.`, + temperature: 0.3, + maxTokens: 8192, + }) + + this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 80 }) + + db.update(meetingSessions).set({ + editedTranscript: result.text, + updatedAt: Date.now(), + }).where(eq(meetingSessions.id, sessionId)).run() + + this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 100 }) + logger.info(`화자 구분 완료: sessionId=${sessionId}`) + } + private _buildPdfHtml(session: MeetingSessionDetail): string { const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0 const minutesHtml = session.minutesMarkdown diff --git a/src/preload/index.ts b/src/preload/index.ts index cbfb0cb..f118139 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -165,6 +165,7 @@ import type { MeetingChatMessage, MeetingChatSendParams, MeetingChatDelta, + DiarizeSessionParams, } from '@shared/types' import { Feature } from '@shared/types' import type { IPCResult } from '@shared/errors' @@ -641,6 +642,11 @@ const electronAPI = { // Phase 15 polishTranscript: (params: { sessionId: string }) => invoke(IPC_CHANNELS.MEETING_MODE.POLISH_TRANSCRIPT, params), + // Phase 15.5 + diarize: (params: DiarizeSessionParams) => + invoke(IPC_CHANNELS.MEETING_MODE.DIARIZE, params), + onDiarizationProgress: (cb: (e: { sessionId: string; percent: number }) => void): Unsubscribe => + on(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, cb), }, // ── Meeting Chat (Phase 15) ─────────────────────────── diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 7db3bac..6e50535 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -642,6 +642,46 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac {t('settings.sttLang.zh')} + + + + + {t('settings.diarization')} + + + )['hfToken'] as string ?? ''} + onChange={(e) => updateConfig('hfToken' as keyof AppConfig, e.target.value as never)} + fullWidth + size="small" + helperText={t('settings.hfTokenHint')} + /> + + )['diarizationEnabled'] as boolean ?? false} + onChange={(e) => updateConfig('diarizationEnabled' as keyof AppConfig, e.target.checked as never)} + size="small" + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.purple }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + backgroundColor: d3roPalette.tag.purple, + }, + }} + /> + } + label={ + + {t('settings.diarization')} + + {t('settings.diarizationHint')} + + + } + /> diff --git a/src/renderer/components/meeting/EditableSegment.tsx b/src/renderer/components/meeting/EditableSegment.tsx index e79d924..a8e1618 100644 --- a/src/renderer/components/meeting/EditableSegment.tsx +++ b/src/renderer/components/meeting/EditableSegment.tsx @@ -2,10 +2,26 @@ // Phase 14.5: 전사 세그먼트 인라인 편집 컴포넌트 import { useState, useRef, useCallback } from 'react' -import { Box, TextField, Tooltip } from '@mui/material' +import { Box, TextField, Tooltip, Chip } from '@mui/material' import { d3roPalette, d3roFontMono } from '../../theme' import { useI18n } from '../../i18n' +// Phase 15.5: 화자별 색상 매핑 (d3roPalette SSOT) +const SPEAKER_COLORS = [ + d3roPalette.tag.purple, + d3roPalette.tag.green, + d3roPalette.tag.orange, + d3roPalette.tag.red, + '#3b82f6', +] + +function getSpeakerColor(speaker: string): string { + // "화자 1" → 0, "화자 2" → 1, ... + const match = /(\d+)$/.exec(speaker) + const idx = match ? (parseInt(match[1], 10) - 1) : 0 + return SPEAKER_COLORS[idx % SPEAKER_COLORS.length] +} + interface EditableSegmentProps { segmentId: string timestamp: number // ms, 상대 시간 @@ -13,6 +29,7 @@ interface EditableSegmentProps { edited: boolean // 수정 여부 onEdit: (segmentId: string, newText: string) => void readOnly?: boolean + speaker?: string } function formatTimestamp(ms: number): string { @@ -29,6 +46,7 @@ export function EditableSegment({ edited, onEdit, readOnly = false, + speaker, }: EditableSegmentProps): React.ReactElement { const { t } = useI18n() const [editing, setEditing] = useState(false) @@ -61,9 +79,20 @@ export function EditableSegment({ [handleCommit], ) + const speakerColor = speaker ? getSpeakerColor(speaker) : undefined + if (editing) { return ( - + + {formatTimestamp(timestamp)} + {speaker && ( + + )} {edited ? ( {textNode} diff --git a/src/renderer/components/meeting/MeetingDetailTabs.tsx b/src/renderer/components/meeting/MeetingDetailTabs.tsx index 045869f..3fd7853 100644 --- a/src/renderer/components/meeting/MeetingDetailTabs.tsx +++ b/src/renderer/components/meeting/MeetingDetailTabs.tsx @@ -81,6 +81,7 @@ export function MeetingDetailTabs({ const [tabIndex, setTabIndex] = useState(0) const [dialogOpen, setDialogOpen] = useState(false) const [generating, setGenerating] = useState(false) + const [diarizing, setDiarizing] = useState(false) const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState(detail.title ?? '') @@ -176,6 +177,17 @@ export function MeetingDetailTabs({ [], ) + // ── 화자 구분 ── + const handleDiarize = useCallback(async () => { + setDiarizing(true) + const resp = await window.electronAPI.meetingMode.diarize({ sessionId: detail.id }) + if (resp.success) { + const updated = await window.electronAPI.meetingMode.getSession({ sessionId: detail.id }) + if (updated.success) setDetail(updated.data) + } + setDiarizing(false) + }, [detail.id]) + // ── 문서 생성 ── const handleGenerate = useCallback( async (templateId: string, customPrompt?: string, customTitle?: string) => { @@ -306,6 +318,8 @@ export function MeetingDetailTabs({ memos={detail.memos} onEditSegment={handleEditSegment} onSaveTranscript={handleSaveTranscript} + onDiarize={handleDiarize} + diarizing={diarizing} /> )} {documents.filter(Boolean).map((doc, idx) => { diff --git a/src/renderer/components/meeting/TranscriptTab.tsx b/src/renderer/components/meeting/TranscriptTab.tsx index 09020f2..08fad30 100644 --- a/src/renderer/components/meeting/TranscriptTab.tsx +++ b/src/renderer/components/meeting/TranscriptTab.tsx @@ -10,19 +10,22 @@ import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme' import { useI18n } from '../../i18n' import FileDownloadIcon from '@mui/icons-material/FileDownload' import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh' +import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver' interface TranscriptTabProps { sessionId: string - segments: Array<{ id: string; timestamp: number; text: string; edited: boolean }> + segments: Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> rawTranscript: string | null editedTranscript: string | null memos: Array<{ id: string; timestampMs: number; content: string }> onEditSegment: (segmentId: string, newText: string) => void onSaveTranscript: (editedTranscript: string) => void + onDiarize?: () => Promise + diarizing?: boolean } type TimelineItem = - | { kind: 'segment'; id: string; timestamp: number; text: string; edited: boolean } + | { kind: 'segment'; id: string; timestamp: number; text: string; edited: boolean; speaker?: string } | { kind: 'memo'; id: string; timestamp: number; content: string } export function TranscriptTab({ @@ -33,6 +36,8 @@ export function TranscriptTab({ memos, onEditSegment, onSaveTranscript, + onDiarize, + diarizing = false, }: TranscriptTabProps): React.ReactElement { const { t } = useI18n() const [showEdited, setShowEdited] = useState(true) @@ -70,6 +75,7 @@ export function TranscriptTab({ timestamp: s.timestamp, text: s.text, edited: s.edited, + speaker: s.speaker, })), ...memos.map((m) => ({ kind: 'memo' as const, @@ -120,12 +126,23 @@ export function TranscriptTab({ {polishing ? t('meeting.polishing') : t('meeting.polish')} + {onDiarize && ( + + + {diarizing ? t('meeting.diarizing') : t('meeting.diarize')} + + )} ) } diff --git a/src/renderer/i18n/de.json b/src/renderer/i18n/de.json index 988b86b..881a212 100644 --- a/src/renderer/i18n/de.json +++ b/src/renderer/i18n/de.json @@ -270,5 +270,13 @@ "meeting.exportPdfFmt": "PDF (.pdf)", "meeting.polishFailed": "KI-Polierung fehlgeschlagen", "meeting.viewDetailError": "Meeting-Details konnten nicht geladen werden", - "meeting.startRecordingError": "Aufnahme konnte nicht gestartet werden" + "meeting.startRecordingError": "Aufnahme konnte nicht gestartet werden", + "meeting.diarize": "Sprecher trennen", + "meeting.diarizing": "Sprecher werden identifiziert...", + "meeting.diarizeComplete": "Sprechertrennung abgeschlossen", + "meeting.speaker": "Sprecher", + "settings.hfToken": "HuggingFace Token", + "settings.hfTokenHint": "Ein HuggingFace Token wird für die Sprechertrennung benötigt", + "settings.diarization": "Sprechertrennung", + "settings.diarizationHint": "Sprecher werden nach der Aufnahme automatisch identifiziert" } diff --git a/src/renderer/i18n/en.json b/src/renderer/i18n/en.json index ecd2b28..c1fb4df 100644 --- a/src/renderer/i18n/en.json +++ b/src/renderer/i18n/en.json @@ -471,5 +471,13 @@ "meeting.exportPdfFmt": "PDF (.pdf)", "meeting.polishFailed": "AI polishing failed", "meeting.viewDetailError": "Failed to load meeting details", - "meeting.startRecordingError": "Failed to start recording" + "meeting.startRecordingError": "Failed to start recording", + "meeting.diarize": "Diarize", + "meeting.diarizing": "Identifying speakers...", + "meeting.diarizeComplete": "Diarization complete", + "meeting.speaker": "Speaker", + "settings.hfToken": "HuggingFace Token", + "settings.hfTokenHint": "A HuggingFace token is required for speaker diarization", + "settings.diarization": "Speaker Diarization", + "settings.diarizationHint": "Automatically identify speakers after recording ends" } diff --git a/src/renderer/i18n/es.json b/src/renderer/i18n/es.json index eb32e69..744c200 100644 --- a/src/renderer/i18n/es.json +++ b/src/renderer/i18n/es.json @@ -270,5 +270,13 @@ "meeting.exportPdfFmt": "PDF (.pdf)", "meeting.polishFailed": "Error al pulir con IA", "meeting.viewDetailError": "Error al cargar detalles de la reunión", - "meeting.startRecordingError": "Error al iniciar la grabación" + "meeting.startRecordingError": "Error al iniciar la grabación", + "meeting.diarize": "Identificar hablantes", + "meeting.diarizing": "Identificando hablantes...", + "meeting.diarizeComplete": "Identificación completada", + "meeting.speaker": "Hablante", + "settings.hfToken": "Token de HuggingFace", + "settings.hfTokenHint": "Se necesita un token de HuggingFace para identificar hablantes", + "settings.diarization": "Identificación de hablantes", + "settings.diarizationHint": "Identifica automáticamente los hablantes al finalizar la grabación" } diff --git a/src/renderer/i18n/fr.json b/src/renderer/i18n/fr.json index 57ead89..5a65ce4 100644 --- a/src/renderer/i18n/fr.json +++ b/src/renderer/i18n/fr.json @@ -270,5 +270,13 @@ "meeting.exportPdfFmt": "PDF (.pdf)", "meeting.polishFailed": "Echec du polish IA", "meeting.viewDetailError": "Impossible de charger les détails de la réunion", - "meeting.startRecordingError": "Impossible de démarrer l'enregistrement" + "meeting.startRecordingError": "Impossible de démarrer l'enregistrement", + "meeting.diarize": "Identifier les locuteurs", + "meeting.diarizing": "Identification des locuteurs...", + "meeting.diarizeComplete": "Identification terminée", + "meeting.speaker": "Locuteur", + "settings.hfToken": "Token HuggingFace", + "settings.hfTokenHint": "Un token HuggingFace est requis pour l'identification des locuteurs", + "settings.diarization": "Identification des locuteurs", + "settings.diarizationHint": "Identifier automatiquement les locuteurs après l'enregistrement" } diff --git a/src/renderer/i18n/ja.json b/src/renderer/i18n/ja.json index 558b388..62ef9f6 100644 --- a/src/renderer/i18n/ja.json +++ b/src/renderer/i18n/ja.json @@ -270,5 +270,13 @@ "meeting.exportPdfFmt": "PDF (.pdf)", "meeting.polishFailed": "AI磨き上げに失敗しました", "meeting.viewDetailError": "会議の詳細を読み込めませんでした", - "meeting.startRecordingError": "録音を開始できませんでした" + "meeting.startRecordingError": "録音を開始できませんでした", + "meeting.diarize": "話者分離", + "meeting.diarizing": "話者を識別中...", + "meeting.diarizeComplete": "話者分離完了", + "meeting.speaker": "話者", + "settings.hfToken": "HuggingFaceトークン", + "settings.hfTokenHint": "話者分離にはHuggingFaceトークンが必要です", + "settings.diarization": "話者分離", + "settings.diarizationHint": "録音終了後に自動的に話者を識別します" } diff --git a/src/renderer/i18n/ko.json b/src/renderer/i18n/ko.json index 641ef5d..777d985 100644 --- a/src/renderer/i18n/ko.json +++ b/src/renderer/i18n/ko.json @@ -471,5 +471,13 @@ "meeting.exportPdfFmt": "PDF (.pdf)", "meeting.polishFailed": "AI 다듬기에 실패했습니다", "meeting.viewDetailError": "회의 상세 정보를 불러오지 못했습니다", - "meeting.startRecordingError": "녹음을 시작하지 못했습니다" + "meeting.startRecordingError": "녹음을 시작하지 못했습니다", + "meeting.diarize": "화자 구분", + "meeting.diarizing": "화자를 구분하는 중...", + "meeting.diarizeComplete": "화자 구분 완료", + "meeting.speaker": "화자", + "settings.hfToken": "HuggingFace 토큰", + "settings.hfTokenHint": "화자 구분을 위해 HuggingFace 토큰이 필요합니다", + "settings.diarization": "화자 구분", + "settings.diarizationHint": "녹음 종료 후 화자를 자동으로 구분합니다" } diff --git a/src/renderer/i18n/pt.json b/src/renderer/i18n/pt.json index d237abb..c16f9e8 100644 --- a/src/renderer/i18n/pt.json +++ b/src/renderer/i18n/pt.json @@ -270,5 +270,13 @@ "meeting.exportPdfFmt": "PDF (.pdf)", "meeting.polishFailed": "Falha no polimento de IA", "meeting.viewDetailError": "Falha ao carregar detalhes da reunião", - "meeting.startRecordingError": "Falha ao iniciar gravação" + "meeting.startRecordingError": "Falha ao iniciar gravação", + "meeting.diarize": "Identificar oradores", + "meeting.diarizing": "Identificando oradores...", + "meeting.diarizeComplete": "Identificação concluída", + "meeting.speaker": "Orador", + "settings.hfToken": "Token do HuggingFace", + "settings.hfTokenHint": "Um token do HuggingFace é necessário para identificar oradores", + "settings.diarization": "Identificação de oradores", + "settings.diarizationHint": "Identifica automaticamente os oradores após o término da gravação" } diff --git a/src/renderer/i18n/ru.json b/src/renderer/i18n/ru.json index f63491a..1dcf74b 100644 --- a/src/renderer/i18n/ru.json +++ b/src/renderer/i18n/ru.json @@ -270,5 +270,13 @@ "meeting.exportPdfFmt": "PDF (.pdf)", "meeting.polishFailed": "Ошибка ИИ-полировки", "meeting.viewDetailError": "Не удалось загрузить детали встречи", - "meeting.startRecordingError": "Не удалось начать запись" + "meeting.startRecordingError": "Не удалось начать запись", + "meeting.diarize": "Определить говорящих", + "meeting.diarizing": "Определение говорящих...", + "meeting.diarizeComplete": "Определение завершено", + "meeting.speaker": "Говорящий", + "settings.hfToken": "Токен HuggingFace", + "settings.hfTokenHint": "Токен HuggingFace необходим для определения говорящих", + "settings.diarization": "Диаризация", + "settings.diarizationHint": "Автоматически определять говорящих после завершения записи" } diff --git a/src/renderer/i18n/th.json b/src/renderer/i18n/th.json index 5dec976..18a49ee 100644 --- a/src/renderer/i18n/th.json +++ b/src/renderer/i18n/th.json @@ -270,5 +270,13 @@ "meeting.exportPdfFmt": "PDF (.pdf)", "meeting.polishFailed": "AI ปรับแต่งล้มเหลว", "meeting.viewDetailError": "ไม่สามารถโหลดรายละเอียดการประชุมได้", - "meeting.startRecordingError": "ไม่สามารถเริ่มการบันทึกได้" + "meeting.startRecordingError": "ไม่สามารถเริ่มการบันทึกได้", + "meeting.diarize": "แยกผู้พูด", + "meeting.diarizing": "กำลังระบุผู้พูด...", + "meeting.diarizeComplete": "แยกผู้พูดเสร็จสิ้น", + "meeting.speaker": "ผู้พูด", + "settings.hfToken": "โทเค็น HuggingFace", + "settings.hfTokenHint": "ต้องใช้โทเค็น HuggingFace สำหรับการแยกผู้พูด", + "settings.diarization": "การแยกผู้พูด", + "settings.diarizationHint": "ระบุผู้พูดโดยอัตโนมัติหลังจากการบันทึกสิ้นสุด" } diff --git a/src/renderer/i18n/vi.json b/src/renderer/i18n/vi.json index aa079b4..d5adf85 100644 --- a/src/renderer/i18n/vi.json +++ b/src/renderer/i18n/vi.json @@ -270,5 +270,13 @@ "meeting.exportPdfFmt": "PDF (.pdf)", "meeting.polishFailed": "AI đánh bóng thất bại", "meeting.viewDetailError": "Không thể tải chi tiết cuộc họp", - "meeting.startRecordingError": "Không thể bắt đầu ghi âm" + "meeting.startRecordingError": "Không thể bắt đầu ghi âm", + "meeting.diarize": "Phân tách người nói", + "meeting.diarizing": "Đang nhận dạng người nói...", + "meeting.diarizeComplete": "Phân tách người nói hoàn tất", + "meeting.speaker": "Người nói", + "settings.hfToken": "Token HuggingFace", + "settings.hfTokenHint": "Cần token HuggingFace để phân tách người nói", + "settings.diarization": "Phân tách người nói", + "settings.diarizationHint": "Tự động nhận dạng người nói sau khi ghi âm kết thúc" } diff --git a/src/renderer/i18n/zh-TW.json b/src/renderer/i18n/zh-TW.json index 7ce9a36..5ed9088 100644 --- a/src/renderer/i18n/zh-TW.json +++ b/src/renderer/i18n/zh-TW.json @@ -270,5 +270,13 @@ "meeting.exportPdfFmt": "PDF (.pdf)", "meeting.polishFailed": "AI潤色失敗", "meeting.viewDetailError": "無法載入會議詳情", - "meeting.startRecordingError": "無法開始錄音" + "meeting.startRecordingError": "無法開始錄音", + "meeting.diarize": "說話人分離", + "meeting.diarizing": "正在識別說話人...", + "meeting.diarizeComplete": "說話人分離完成", + "meeting.speaker": "說話人", + "settings.hfToken": "HuggingFace 令牌", + "settings.hfTokenHint": "說話人分離需要HuggingFace令牌", + "settings.diarization": "說話人分離", + "settings.diarizationHint": "錄音結束後自動識別說話人" } diff --git a/src/renderer/i18n/zh.json b/src/renderer/i18n/zh.json index 6173780..6ba07e8 100644 --- a/src/renderer/i18n/zh.json +++ b/src/renderer/i18n/zh.json @@ -270,5 +270,13 @@ "meeting.exportPdfFmt": "PDF (.pdf)", "meeting.polishFailed": "AI润色失败", "meeting.viewDetailError": "无法加载会议详情", - "meeting.startRecordingError": "无法开始录音" + "meeting.startRecordingError": "无法开始录音", + "meeting.diarize": "说话人分离", + "meeting.diarizing": "正在识别说话人...", + "meeting.diarizeComplete": "说话人分离完成", + "meeting.speaker": "说话人", + "settings.hfToken": "HuggingFace令牌", + "settings.hfTokenHint": "说话人分离需要HuggingFace令牌", + "settings.diarization": "说话人分离", + "settings.diarizationHint": "录音结束后自动识别说话人" } diff --git a/src/shared/errors.ts b/src/shared/errors.ts index 246d964..0f52444 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -167,6 +167,9 @@ export enum ErrorCode { MeetingDocxExportFailed = 892, MeetingPolishFailed = 893, MeetingChatFailed = 894, + DiarizationFailed = 895, + DiarizationModelNotLoaded = 896, + DiarizationTokenRequired = 897, // === Config (800-849) === ConfigReadFailed = 800, diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 486e762..f2f7a6e 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -333,6 +333,8 @@ export const IPC_CHANNELS = { SESSION_COMPLETED: 'meetingMode:sessionCompleted', DOC_GENERATING_PROGRESS: 'meetingMode:docGeneratingProgress', POLISH_TRANSCRIPT: 'meetingMode:polishTranscript', + DIARIZE: 'meetingMode:diarize', + DIARIZATION_PROGRESS: 'meetingMode:diarizationProgress', ERROR: 'meetingMode:error', }, diff --git a/src/shared/types.ts b/src/shared/types.ts index dce01c6..0ddd430 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -384,6 +384,10 @@ export interface AppConfig { handsFreeEnabled: boolean /** 스크린 컨텍스트 캡처 활성화 (Phase 10.2) */ screenContextEnabled: boolean + /** Phase 15.5: HuggingFace 토큰 (화자 구분용) */ + hfToken: string + /** Phase 15.5: 화자 구분 활성화 */ + diarizationEnabled: boolean } export interface ConfigGetParams { @@ -832,6 +836,7 @@ export interface CaptionSegment { text: string timestamp: number isFinal: boolean + speaker?: string } export type CaptionAudioSource = 'mic' | 'system' | 'both' @@ -1473,3 +1478,21 @@ export interface MeetingChatSendParams { export interface MeetingChatDelta { token: string } + +// Phase 15.5: Speaker Diarization +export interface DiarizationSegment { + speaker: string + start: number + end: number +} + +export interface DiarizationResult { + segments: DiarizationSegment[] + numSpeakers: number + processingTime: number +} + +export interface DiarizeSessionParams { + sessionId: string + numSpeakers?: number +}