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:
Yun Chan 2026-04-08 12:14:00 +09:00
parent da25791c75
commit fc7628327a
25 changed files with 679 additions and 18 deletions

View file

@ -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')
}

View file

@ -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

View file

@ -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) ───────────────────────────

View file

@ -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>

View file

@ -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}

View file

@ -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) => {

View file

@ -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}
/>
)
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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": "録音終了後に自動的に話者を識別します"
}

View file

@ -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": "녹음 종료 후 화자를 자동으로 구분합니다"
}

View file

@ -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"
}

View file

@ -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": "Автоматически определять говорящих после завершения записи"
}

View file

@ -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": "ระบุผู้พูดโดยอัตโนมัติหลังจากการบันทึกสิ้นสุด"
}

View file

@ -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"
}

View file

@ -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": "錄音結束後自動識別說話人"
}

View file

@ -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": "录音结束后自动识别说话人"
}

View file

@ -167,6 +167,9 @@ export enum ErrorCode {
MeetingDocxExportFailed = 892,
MeetingPolishFailed = 893,
MeetingChatFailed = 894,
DiarizationFailed = 895,
DiarizationModelNotLoaded = 896,
DiarizationTokenRequired = 897,
// === Config (800-849) ===
ConfigReadFailed = 800,

View file

@ -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',
},

View file

@ -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
}