Phase 15.5 구현: 화자 구분 (Speaker Diarization)
- Sidecar: pyannote /diarize 엔드포인트 + requirements.txt 업데이트 - LLM 기반 화자 추정 (Phase 1 — 오디오 보존 없이 전사 텍스트 분석) - CaptionSegment에 speaker 필드 추가 - EditableSegment: 화자별 색상 바 + 화자 Chip 표시 - TranscriptTab: 화자 구분 버튼 + 진행률 - 설정: HuggingFace 토큰 입력 + Diarization 토글 - IPC: DIARIZE + DIARIZATION_PROGRESS 채널 - 에러코드: 895-897 - 12개 locale i18n
This commit is contained in:
parent
da25791c75
commit
fc7628327a
25 changed files with 679 additions and 18 deletions
228
docs/phases/phase-15.5-speaker-diarization.md
Normal file
228
docs/phases/phase-15.5-speaker-diarization.md
Normal file
|
|
@ -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<DiarizationResult> {
|
||||
// 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인+)에서 정확도 하락
|
||||
117
sidecar/main.py
117
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하게 종료한다."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -933,6 +933,52 @@ ${transcript}`
|
|||
this._chatHistory = []
|
||||
}
|
||||
|
||||
// ── Phase 15.5: 화자 구분 ──
|
||||
|
||||
async diarizeSession(sessionId: string, numSpeakers?: number): Promise<void> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string>(IPC_CHANNELS.MEETING_MODE.POLISH_TRANSCRIPT, params),
|
||||
// Phase 15.5
|
||||
diarize: (params: DiarizeSessionParams) =>
|
||||
invoke<void>(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) ───────────────────────────
|
||||
|
|
|
|||
|
|
@ -642,6 +642,46 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
<MenuItem value="zh">{t('settings.sttLang.zh')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.diarization')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('settings.hfToken')}
|
||||
type="password"
|
||||
value={(config as Record<string, unknown>)['hfToken'] as string ?? ''}
|
||||
onChange={(e) => updateConfig('hfToken' as keyof AppConfig, e.target.value as never)}
|
||||
fullWidth
|
||||
size="small"
|
||||
helperText={t('settings.hfTokenHint')}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={(config as Record<string, unknown>)['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={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontSize: '12px' }}>{t('settings.diarization')}</Typography>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
||||
{t('settings.diarizationHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 0.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
borderLeft: speakerColor ? `4px solid ${speakerColor}` : undefined,
|
||||
pl: speakerColor ? 0.75 : 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
|
|
@ -125,7 +154,16 @@ export function EditableSegment({
|
|||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 0.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
borderLeft: speakerColor ? `4px solid ${speakerColor}` : undefined,
|
||||
pl: speakerColor ? 0.75 : 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
|
|
@ -139,6 +177,21 @@ export function EditableSegment({
|
|||
>
|
||||
{formatTimestamp(timestamp)}
|
||||
</Box>
|
||||
{speaker && (
|
||||
<Chip
|
||||
label={speaker}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
flexShrink: 0,
|
||||
bgcolor: `${speakerColor}22`,
|
||||
color: speakerColor,
|
||||
border: `1px solid ${speakerColor}55`,
|
||||
fontFamily: d3roFontMono,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{edited ? (
|
||||
<Tooltip title={t('meeting.modified')} placement="top">
|
||||
{textNode}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
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({
|
|||
<PhysicalButton
|
||||
size="small"
|
||||
onClick={handlePolish}
|
||||
disabled={polishing}
|
||||
disabled={polishing || diarizing}
|
||||
sx={{ height: 30, fontSize: d3roTypo.engrave.size }}
|
||||
>
|
||||
<AutoFixHighIcon sx={{ fontSize: 13, mr: 0.5 }} />
|
||||
{polishing ? t('meeting.polishing') : t('meeting.polish')}
|
||||
</PhysicalButton>
|
||||
{onDiarize && (
|
||||
<PhysicalButton
|
||||
size="small"
|
||||
onClick={onDiarize}
|
||||
disabled={polishing || diarizing}
|
||||
sx={{ height: 30, fontSize: d3roTypo.engrave.size }}
|
||||
>
|
||||
<RecordVoiceOverIcon sx={{ fontSize: 13, mr: 0.5 }} />
|
||||
{diarizing ? t('meeting.diarizing') : t('meeting.diarize')}
|
||||
</PhysicalButton>
|
||||
)}
|
||||
<PhysicalButton
|
||||
size="small"
|
||||
onClick={handleDownloadTxt}
|
||||
|
|
@ -173,6 +190,7 @@ export function TranscriptTab({
|
|||
text={item.text}
|
||||
edited={item.edited}
|
||||
onEdit={onEditSegment}
|
||||
speaker={item.speaker}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "録音終了後に自動的に話者を識別します"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "녹음 종료 후 화자를 자동으로 구분합니다"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Автоматически определять говорящих после завершения записи"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "ระบุผู้พูดโดยอัตโนมัติหลังจากการบันทึกสิ้นสุด"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "錄音結束後自動識別說話人"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "录音结束后自动识别说话人"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,9 @@ export enum ErrorCode {
|
|||
MeetingDocxExportFailed = 892,
|
||||
MeetingPolishFailed = 893,
|
||||
MeetingChatFailed = 894,
|
||||
DiarizationFailed = 895,
|
||||
DiarizationModelNotLoaded = 896,
|
||||
DiarizationTokenRequired = 897,
|
||||
|
||||
// === Config (800-849) ===
|
||||
ConfigReadFailed = 800,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue