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