// src/main/services/MeetingModeService.ts // Phase 14 / 14.5: Meeting Mode — 실시간 녹음 + 타임스탬프 메모 + 문서 생성/내보내기 // 싱글톤 + EventEmitter 패턴. CaptionService 연동. import { EventEmitter } from 'events' import { BrowserWindow, Notification, dialog } from 'electron' import fs from 'fs' import { nanoid } from 'nanoid' import { eq, desc, sql } from 'drizzle-orm' import { getLogger } from './LoggerService' import { configGet, configSet } from './ConfigService' import { getMainWindow } from '../windows/WindowManager' import { getDatabase } from '../db' import { meetingSessions, meetingMemos, meetingDocuments } from '../db/schema' import { IPC_CHANNELS } from '@shared/ipc-channels' import { D3ROError, ErrorCode } from '@shared/errors' import { parseMinutes, buildExportMarkdown, markdownToSimpleHtml, formatTime, formatDateFile, formatDateRange, } from '../utils/meeting-markdown' import { markdownToDocx } from '../utils/markdown-to-docx' import type { MeetingModeState, MeetingMemo, MeetingSessionSummary, MeetingSessionDetail, MeetingMinutes, MeetingSessionPage, MeetingStartResult, MeetingModeStateInfo, MeetingProcessingStep, MeetingProcessingProgress, MeetingDocument, MeetingGenerateDocParams, MeetingExportFormat, MeetingDocGeneratingProgress, CaptionSegment, } from '@shared/types' const logger = getLogger('MeetingModeService') interface MeetingModeServiceEvents { 'state-changed': (state: MeetingModeState) => void 'segment': (segment: CaptionSegment) => void 'memo-added': (memo: MeetingMemo) => void 'processing-progress': (progress: MeetingProcessingProgress) => void 'session-completed': (detail: MeetingSessionDetail) => void 'error': (error: D3ROError) => void } class MeetingModeService extends EventEmitter { private _state: MeetingModeState = 'idle' private _sessionId: string | null = null private _sessionStartedAt: number | null = null private _segments: CaptionSegment[] = [] private _memos: MeetingMemo[] = [] private _segmentHandler: ((segment: CaptionSegment) => void) | null = null private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null private _audioLevelTimer: ReturnType | null = null private _lastRms = 0 /** CaptionService session-saved 방지 플래그 */ private _meetingModeActive = false /** Phase 15.5-2: 녹음 중 오디오 WAV 파일 보존 */ private _audioBuffersForFile: Buffer[] = [] private _audioFilePath: string | null = null // Phase 15: Meeting AI Chat private _chatHistory: Array<{ role: 'user' | 'assistant'; content: string }> = [] private _chatAbortController: AbortController | null = null // ── 공개 접근자 ── getState(): MeetingModeState { return this._state } getStateInfo(): MeetingModeStateInfo { return { state: this._state, sessionId: this._sessionId, elapsedMs: this._sessionStartedAt ? Date.now() - this._sessionStartedAt : 0, memoCount: this._memos.length, segmentCount: this._segments.length, } } isMeetingModeActive(): boolean { return this._meetingModeActive } // ── 녹음 시작 ── async startRecording(): Promise { if (this._state !== 'idle') { throw new D3ROError( ErrorCode.MeetingAlreadyRecording, `회의 모드가 이미 활성 상태입니다: ${this._state}`, ) } // CaptionService 상태 확인 const { getCaptionService } = await import('./CaptionService') const captionService = getCaptionService() const captionState = captionService.getState() if (captionState !== 'inactive') { throw new D3ROError( ErrorCode.MeetingAlreadyRecording, '자막 모드가 활성 상태입니다. 먼저 자막을 종료해주세요.', ) } this._setState('recording') this._meetingModeActive = true const sessionId = nanoid() const now = Date.now() this._sessionId = sessionId this._sessionStartedAt = now this._segments = [] this._memos = [] // DB에 세션 생성 const db = getDatabase() db.insert(meetingSessions).values({ id: sessionId, status: 'recording', startedAt: now, createdAt: now, updatedAt: now, }).run() // CaptionService 시작 (오버레이 숨김 상태로 — 회의 모드 UI에서 자체 표시) this._segmentHandler = (segment: CaptionSegment) => { this._segments.push(segment) this.emit('segment', segment) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SEGMENT, segment) } captionService.on('segment', this._segmentHandler) // 회의 모드는 마이크 캡처 강제 — ConfigService 값도 임시 변경 // (CaptionService.start()가 ConfigService에서 다시 읽기 때문) type AppConfigKey = keyof import('@shared/types').AppConfig const prevAudioSource = configGet('captionAudioSource' as AppConfigKey) as unknown as string configSet('captionAudioSource' as AppConfigKey, 'mic' as never) try { await captionService.start() // 회의 모드 UI가 자체 자막 패널에 표시하므로 오버레이 숨김 const { hideCaptionOverlay } = await import('../windows/WindowManager') hideCaptionOverlay() // ConfigService 원래 값 복원 if (prevAudioSource) { configSet('captionAudioSource' as AppConfigKey, prevAudioSource as never) } } catch (err) { // ConfigService 원래 값 복원 if (prevAudioSource) { configSet('captionAudioSource' as AppConfigKey, prevAudioSource as never) } // 시작 실패 시 복원 captionService.off('segment', this._segmentHandler) this._segmentHandler = null this._meetingModeActive = false this._sessionId = null this._sessionStartedAt = null db.update(meetingSessions).set({ status: 'error', errorMessage: err instanceof Error ? err.message : String(err), updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() this._setState('idle') throw err } // 오디오 레벨 모니터링 시작 const { getAudioCaptureService, calculateRMS } = await import('./AudioCaptureService') const audioCaptureService = getAudioCaptureService() this._audioBuffersForFile = [] this._audioDataHandler = (payload: { buffer: Buffer; timestamp: number }) => { this._lastRms = calculateRMS(payload.buffer) // Phase 15.5-2: 오디오 버퍼 축적 (WAV 저장용) this._audioBuffersForFile.push(Buffer.from(payload.buffer)) } audioCaptureService.on('audio-data', this._audioDataHandler) this._audioLevelTimer = setInterval(() => { this._sendToRenderer('meetingMode:audioLevel', { level: this._lastRms }) }, 100) logger.info(`회의 녹음 시작: sessionId=${sessionId}`) this._sendStateToRenderer() return { sessionId } } // ── 메모 추가 ── async addMemo(content: string): Promise { if (this._state !== 'recording' || !this._sessionId || !this._sessionStartedAt) { throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다') } const memo: MeetingMemo = { id: nanoid(), sessionId: this._sessionId, content, timestampMs: Date.now() - this._sessionStartedAt, createdAt: Date.now(), } this._memos.push(memo) // DB 저장 const db = getDatabase() db.insert(meetingMemos).values({ id: memo.id, sessionId: memo.sessionId, content: memo.content, timestampMs: memo.timestampMs, createdAt: memo.createdAt, }).run() this.emit('memo-added', memo) logger.debug(`메모 추가: [${formatTime(memo.timestampMs)}] ${content}`) return memo } // ── 녹음 종료 ── async stopRecording(): Promise { if (this._state !== 'recording') { throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다') } const sessionId = this._sessionId! const now = Date.now() // 오디오 레벨 모니터링 정리 if (this._audioLevelTimer) { clearInterval(this._audioLevelTimer) this._audioLevelTimer = null } if (this._audioDataHandler) { const { getAudioCaptureService } = await import('./AudioCaptureService') getAudioCaptureService().off('audio-data', this._audioDataHandler) this._audioDataHandler = null } // CaptionService 종료 const { getCaptionService } = await import('./CaptionService') const captionService = getCaptionService() if (this._segmentHandler) { captionService.off('segment', this._segmentHandler) this._segmentHandler = null } await captionService.stop() // 세션 종료 시간 기록 const db = getDatabase() db.update(meetingSessions).set({ endedAt: now, durationMs: this._sessionStartedAt ? now - this._sessionStartedAt : 0, updatedAt: now, }).where(eq(meetingSessions.id, sessionId)).run() logger.info(`회의 녹음 종료: sessionId=${sessionId}, segments=${this._segments.length}`) // 후처리 시작 (비동기) this._runPostProcessing().catch((err: unknown) => { logger.error(`후처리 실패: ${err instanceof Error ? err.message : String(err)}`) }) } // ── 후처리 파이프라인 ── private async _runPostProcessing(): Promise { this._setState('processing') this._sendStateToRenderer() const sessionId = this._sessionId! const db = getDatabase() try { db.update(meetingSessions).set({ status: 'processing', updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() // Step 1: 전사 텍스트 합산 this._sendProgress(sessionId, 'merging', 40) const rawTranscript = this._segments .map((s) => `[${formatTime(s.timestamp)}] ${s.text}`) .join('\n') // Step 2: DB 저장 (rawTranscript + completed) this._sendProgress(sessionId, 'saving', 80) const now = Date.now() db.update(meetingSessions).set({ status: 'completed', rawTranscript, sttModel: configGet('sttModelId') as string | undefined, updatedAt: now, }).where(eq(meetingSessions.id, sessionId)).run() // Step 2.5: 오디오 WAV 파일 저장 (화자 구분용) if (this._audioBuffersForFile.length > 0) { try { const { app } = await import('electron') const path = await import('path') const audioDir = path.join(app.getPath('userData'), 'meeting-audio') if (!fs.existsSync(audioDir)) fs.mkdirSync(audioDir, { recursive: true }) const wavPath = path.join(audioDir, `${sessionId}.wav`) this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile)) this._audioFilePath = wavPath logger.info(`회의 오디오 저장: ${wavPath}`) } catch (err) { logger.warn(`오디오 저장 실패: ${err instanceof Error ? err.message : String(err)}`) } } this._audioBuffersForFile = [] // Step 3: 알림 this._sendProgress(sessionId, 'notifying', 95) new Notification({ title: '녹음 완료', body: '전사가 완료되었습니다. 문서 생성 탭에서 회의록을 만들어보세요.', }).show() // 완료 이벤트 this._sendProgress(sessionId, 'notifying', 100) const detail = this._loadSessionDetail(sessionId) if (detail) { this.emit('session-completed', detail) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, detail) } this._resetSession() this._setState('idle') this._sendStateToRenderer() logger.info(`회의 전사 저장 완료: sessionId=${sessionId}`) } catch (err) { db.update(meetingSessions).set({ status: 'error', errorMessage: err instanceof Error ? err.message : String(err), updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() this._resetSession() this._setState('idle') this._sendStateToRenderer() const d3roErr = new D3ROError( ErrorCode.MeetingProcessingFailed, `후처리 실패: ${err instanceof Error ? err.message : String(err)}`, ) this.emit('error', d3roErr) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.ERROR, { code: d3roErr.code, message: d3roErr.message, }) logger.error(`후처리 실패: ${d3roErr.message}`) } } // ── 세션 조회 ── getSessions(page: number, pageSize: number): MeetingSessionPage { const db = getDatabase() const offset = (page - 1) * pageSize const countResult = db.select({ count: sql`count(*)` }) .from(meetingSessions) .get() const total = countResult?.count ?? 0 const rows = db.select() .from(meetingSessions) .orderBy(desc(meetingSessions.createdAt)) .limit(pageSize) .offset(offset) .all() const sessions: MeetingSessionSummary[] = rows.map((row) => { const memoCount = db.select({ count: sql`count(*)` }) .from(meetingMemos) .where(eq(meetingMemos.sessionId, row.id)) .get()?.count ?? 0 return { id: row.id, title: row.title, status: row.status as MeetingSessionSummary['status'], startedAt: row.startedAt, endedAt: row.endedAt, durationMs: row.durationMs, memoCount, } }) return { sessions, total, page, pageSize, totalPages: Math.ceil(total / pageSize), } } getSession(sessionId: string): MeetingSessionDetail { const detail = this._loadSessionDetail(sessionId) if (!detail) { throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`) } return detail } deleteSession(sessionId: string): void { const db = getDatabase() const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() if (!row) { throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`) } db.delete(meetingMemos).where(eq(meetingMemos.sessionId, sessionId)).run() db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run() logger.info(`회의 세션 삭제: ${sessionId}`) } updateTitle(sessionId: string, title: string): void { const db = getDatabase() const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() if (!row) { throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`) } db.update(meetingSessions).set({ title, updatedAt: Date.now() }) .where(eq(meetingSessions.id, sessionId)).run() } // ── 내보내기 ── async exportPdf(sessionId: string): Promise { const session = this._loadSessionDetail(sessionId) if (!session) { throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`) } const html = this._buildPdfHtml(session) const { filePath } = await dialog.showSaveDialog({ title: 'PDF로 내보내기', defaultPath: `회의록_${formatDateFile(session.startedAt)}.pdf`, filters: [{ name: 'PDF', extensions: ['pdf'] }], }) if (!filePath) return '' const win = new BrowserWindow({ show: false, width: 800, height: 600 }) try { await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) const pdfBuffer = await win.webContents.printToPDF({ printBackground: true, pageSize: 'A4', margins: { top: 20, bottom: 20, left: 20, right: 20 }, }) fs.writeFileSync(filePath, pdfBuffer) logger.info(`PDF 내보내기 완료: ${filePath}`) return filePath } finally { win.close() } } async exportMarkdown(sessionId: string): Promise { const session = this._loadSessionDetail(sessionId) if (!session) { throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`) } const { filePath } = await dialog.showSaveDialog({ title: '마크다운으로 내보내기', defaultPath: `회의록_${formatDateFile(session.startedAt)}.md`, filters: [{ name: 'Markdown', extensions: ['md'] }], }) if (!filePath) return '' const md = buildExportMarkdown(session) fs.writeFileSync(filePath, md, 'utf-8') logger.info(`마크다운 내보내기 완료: ${filePath}`) return filePath } // ── 내부 헬퍼 ── private _setState(state: MeetingModeState): void { this._state = state this.emit('state-changed', state) } private _resetSession(): void { this._sessionId = null this._sessionStartedAt = null this._segments = [] this._memos = [] this._meetingModeActive = false this._audioBuffersForFile = [] this._audioFilePath = null } /** PCM16 버퍼를 WAV 파일로 저장 (16kHz mono 16-bit) */ private _saveWav(filePath: string, pcmBuffer: Buffer): void { const sampleRate = 16000 const numChannels = 1 const bitsPerSample = 16 const byteRate = sampleRate * numChannels * (bitsPerSample / 8) const blockAlign = numChannels * (bitsPerSample / 8) const dataSize = pcmBuffer.length const header = Buffer.alloc(44) header.write('RIFF', 0) header.writeUInt32LE(36 + dataSize, 4) header.write('WAVE', 8) header.write('fmt ', 12) header.writeUInt32LE(16, 16) // fmt chunk size header.writeUInt16LE(1, 20) // PCM header.writeUInt16LE(numChannels, 22) header.writeUInt32LE(sampleRate, 24) header.writeUInt32LE(byteRate, 28) header.writeUInt16LE(blockAlign, 32) header.writeUInt16LE(bitsPerSample, 34) header.write('data', 36) header.writeUInt32LE(dataSize, 40) fs.writeFileSync(filePath, Buffer.concat([header, pcmBuffer])) } private _sendToRenderer(channel: string, data: unknown): void { const win = getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send(channel, data) } } private _sendStateToRenderer(): void { this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.STATE_CHANGED, this.getStateInfo()) } private _sendProgress(sessionId: string, step: MeetingProcessingStep, percent: number): void { const progress: MeetingProcessingProgress = { sessionId, step, percent } this.emit('processing-progress', progress) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.PROCESSING_PROGRESS, progress) } // ── Phase 14.5: 전사 수정 ── updateTranscript(sessionId: string, editedTranscript: string): void { const db = getDatabase() const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() if (!row) { throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`) } db.update(meetingSessions).set({ editedTranscript, updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() logger.info(`전사 수정 저장: sessionId=${sessionId}`) } // ── Phase 14.5: 문서 생성 ── async generateDocument(params: MeetingGenerateDocParams): Promise { const db = getDatabase() const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, params.sessionId)).get() if (!row) { throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${params.sessionId}`) } const { getMeetingDocTemplateService } = await import('./MeetingDocTemplateService') const templateService = getMeetingDocTemplateService() const template = templateService.getById(params.templateId) if (!template) { throw new D3ROError(ErrorCode.MeetingDocTemplateNotFound, `템플릿을 찾을 수 없습니다: ${params.templateId}`) } const transcript = row.editedTranscript ?? row.rawTranscript ?? '' // 메모도 함께 참조 const memoRows = db.select().from(meetingMemos) .where(eq(meetingMemos.sessionId, params.sessionId)) .orderBy(meetingMemos.timestampMs) .all() const formattedMemos = memoRows.length > 0 ? memoRows.map((m) => `[${formatTime(m.timestampMs)}] 📝 ${m.content}`).join('\n') : '' const systemPrompt = params.customPrompt ?? template.systemPrompt const docTitle = params.customTitle ?? `${template.name} — ${row.title ?? '무제 회의'}` // 진행률 전송 const sendProgress = (percent: number): void => { const progress: MeetingDocGeneratingProgress = { sessionId: params.sessionId, templateId: params.templateId, percent, } this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DOC_GENERATING_PROGRESS, progress) } sendProgress(10) const { getLocalLLMService } = await import('./LocalLLMService') const llmService = getLocalLLMService() sendProgress(30) const llmStart = Date.now() const memoSection = formattedMemos ? `\n\n## 참석자 메모 (📝 표시)\n${formattedMemos}` : '' const result = await llmService.generate( `다음 회의 전사록과 참석자 메모를 분석하여 문서를 작성해주세요:\n\n## 전사록\n${transcript}${memoSection}`, { systemPrompt, temperature: 0.3, maxTokens: 4096, }, ) const llmLatencyMs = Date.now() - llmStart sendProgress(85) const now = Date.now() const docId = nanoid() db.insert(meetingDocuments).values({ id: docId, sessionId: params.sessionId, templateType: template.templateType, title: docTitle, content: result.text, promptUsed: systemPrompt, llmModel: result.model, llmLatencyMs, createdAt: now, updatedAt: now, }).run() sendProgress(100) logger.info(`문서 생성 완료: docId=${docId}, sessionId=${params.sessionId}`) return { id: docId, sessionId: params.sessionId, templateType: template.templateType, title: docTitle, content: result.text, promptUsed: systemPrompt, llmModel: result.model, llmLatencyMs, createdAt: now, updatedAt: now, } } // ── Phase 14.5: 문서 조회/수정/삭제 ── getDocuments(sessionId: string): MeetingDocument[] { const db = getDatabase() const rows = db.select().from(meetingDocuments) .where(eq(meetingDocuments.sessionId, sessionId)) .all() return rows.map((r) => ({ id: r.id, sessionId: r.sessionId, templateType: r.templateType, title: r.title, content: r.content, promptUsed: r.promptUsed, llmModel: r.llmModel, llmLatencyMs: r.llmLatencyMs, createdAt: r.createdAt, updatedAt: r.updatedAt, })) } updateDocument(documentId: string, content: string): void { const db = getDatabase() const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get() if (!row) { throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`) } db.update(meetingDocuments).set({ content, updatedAt: Date.now() }) .where(eq(meetingDocuments.id, documentId)).run() logger.info(`문서 수정: docId=${documentId}`) } deleteDocument(documentId: string): void { const db = getDatabase() const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get() if (!row) { throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`) } db.delete(meetingDocuments).where(eq(meetingDocuments.id, documentId)).run() logger.info(`문서 삭제: docId=${documentId}`) } // ── Phase 14.5: 문서 내보내기 ── async exportDocument(documentId: string, format: MeetingExportFormat): Promise { const db = getDatabase() const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get() if (!row) { throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`) } const safeTitle = row.title.replace(/[/\\?%*:|"<>]/g, '-').slice(0, 50) const dateStr = formatDateFile(row.createdAt) let filters: Electron.FileFilter[] let defaultName: string switch (format) { case 'md': filters = [{ name: 'Markdown', extensions: ['md'] }] defaultName = `${safeTitle}_${dateStr}.md` break case 'txt': filters = [{ name: 'Text', extensions: ['txt'] }] defaultName = `${safeTitle}_${dateStr}.txt` break case 'docx': filters = [{ name: 'Word Document', extensions: ['docx'] }] defaultName = `${safeTitle}_${dateStr}.docx` break case 'pdf': filters = [{ name: 'PDF', extensions: ['pdf'] }] defaultName = `${safeTitle}_${dateStr}.pdf` break } const { filePath } = await dialog.showSaveDialog({ title: '문서 내보내기', defaultPath: defaultName, filters, }) if (!filePath) return '' switch (format) { case 'md': case 'txt': fs.writeFileSync(filePath, row.content, 'utf-8') break case 'docx': { const buf = await markdownToDocx(row.content, row.title) fs.writeFileSync(filePath, buf) break } case 'pdf': { const html = `

${row.title}

${markdownToSimpleHtml(row.content)} ` const win = new BrowserWindow({ show: false, width: 800, height: 600 }) try { await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) const pdfBuffer = await win.webContents.printToPDF({ printBackground: true, pageSize: 'A4', margins: { top: 20, bottom: 20, left: 20, right: 20 }, }) fs.writeFileSync(filePath, pdfBuffer) } finally { win.close() } break } } logger.info(`문서 내보내기 완료: format=${format}, path=${filePath}`) return filePath } async exportTranscript(sessionId: string): Promise { const db = getDatabase() const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() if (!row) { throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`) } const transcript = row.editedTranscript ?? row.rawTranscript ?? '' const dateStr = formatDateFile(row.startedAt) const safeTitle = (row.title ?? '무제_회의').replace(/[/\\?%*:|"<>]/g, '-').slice(0, 50) const { filePath } = await dialog.showSaveDialog({ title: '전사 내보내기', defaultPath: `전사_${safeTitle}_${dateStr}.txt`, filters: [{ name: 'Text', extensions: ['txt'] }], }) if (!filePath) return '' fs.writeFileSync(filePath, transcript, 'utf-8') logger.info(`전사 내보내기 완료: path=${filePath}`) return filePath } // ── 내부 헬퍼 ── private _loadSessionDetail(sessionId: string): MeetingSessionDetail | null { const db = getDatabase() const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() if (!row) return null const memoRows = db.select().from(meetingMemos) .where(eq(meetingMemos.sessionId, sessionId)) .orderBy(meetingMemos.timestampMs) .all() const memos: MeetingMemo[] = memoRows.map((m) => ({ id: m.id, sessionId: m.sessionId, content: m.content, timestampMs: m.timestampMs, createdAt: m.createdAt, })) const docRows = db.select().from(meetingDocuments) .where(eq(meetingDocuments.sessionId, sessionId)) .all() const documents: MeetingDocument[] = docRows.map((d) => ({ id: d.id, sessionId: d.sessionId, templateType: d.templateType, title: d.title, content: d.content, promptUsed: d.promptUsed, llmModel: d.llmModel, llmLatencyMs: d.llmLatencyMs, createdAt: d.createdAt, updatedAt: d.updatedAt, })) let minutes: MeetingMinutes | null = null if (row.minutesJson) { try { minutes = JSON.parse(row.minutesJson) as MeetingMinutes } catch { logger.warn(`회의록 JSON 파싱 실패: sessionId=${sessionId}`) } } return { id: row.id, title: row.title, status: row.status as MeetingSessionDetail['status'], startedAt: row.startedAt, endedAt: row.endedAt, durationMs: row.durationMs, memoCount: memos.length, rawTranscript: row.rawTranscript, editedTranscript: row.editedTranscript, minutesMarkdown: row.minutesMarkdown, minutes, memos, documents, sttModel: row.sttModel, llmModel: row.llmModel, errorMessage: row.errorMessage, } } // ── Phase 15: Auto Polish ── async polishTranscript(sessionId: string): Promise { const db = getDatabase() const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`) const transcript = row.rawTranscript if (!transcript) throw new D3ROError(ErrorCode.MeetingPolishFailed, '전사 텍스트가 없습니다') const { getLocalLLMService } = await import('./LocalLLMService') const llm = getLocalLLMService() const result = await llm.generate(transcript, { systemPrompt: '다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.', temperature: 0.3, }) db.update(meetingSessions).set({ editedTranscript: result.text, updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() logger.info(`Auto Polish 완료: sessionId=${sessionId}`) return result.text } // ── Phase 15: Meeting AI Chat ── async chatWithMeeting(sessionId: string, userMessage: string): Promise { const db = getDatabase() const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`) const transcript = row.editedTranscript ?? row.rawTranscript ?? '' const systemPrompt = `당신은 회의 내용을 분석하는 AI 어시스턴트입니다. 아래 회의 전사 내용을 참고하여 사용자의 질문에 정확하게 답변하세요. 전사 내용에 없는 것은 "전사 내용에서 확인되지 않습니다"라고 답하세요. ## 회의 전사 ${transcript}` this._chatHistory.push({ role: 'user', content: userMessage }) const { getLocalLLMService } = await import('./LocalLLMService') const llm = getLocalLLMService() const messages = [ { role: 'system' as const, content: systemPrompt }, ...this._chatHistory, ] let fullResponse = '' this._chatAbortController = new AbortController() const signal = this._chatAbortController.signal try { const generator = llm.chatStream(messages, { temperature: 0.5 }) for await (const token of generator) { if (signal.aborted) break fullResponse += token this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.DELTA, { token }) } this._chatHistory.push({ role: 'assistant', content: fullResponse }) this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.MESSAGE, { role: 'assistant', content: fullResponse, timestamp: Date.now(), }) } catch (err) { if ((err as Error).name !== 'AbortError') { this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.ERROR, { message: err instanceof Error ? err.message : String(err), }) } } finally { this._chatAbortController = null } } cancelMeetingChat(): void { if (this._chatAbortController) { this._chatAbortController.abort() this._chatAbortController = null } } clearMeetingChatHistory(): void { 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, '전사 텍스트가 없습니다') this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 10 }) // 오디오 파일 존재 확인 const { app } = await import('electron') const path = await import('path') const audioPath = path.join(app.getPath('userData'), 'meeting-audio', `${sessionId}.wav`) const hasAudioFile = fs.existsSync(audioPath) const hfToken = configGet('hfToken') as string if (hasAudioFile && hfToken) { // Phase 15.5-2: pyannote 실제 화자 구분 logger.info(`pyannote 화자 구분 시작: ${audioPath}`) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 20 }) try { const audioBuffer = fs.readFileSync(audioPath) const serverUrl = `http://127.0.0.1:18765` const formData = new FormData() formData.append('audio', new Blob([audioBuffer]), 'audio.wav') formData.append('hf_token', hfToken) if (numSpeakers && numSpeakers > 0) { formData.append('num_speakers', String(numSpeakers)) } this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 40 }) const response = await fetch(`${serverUrl}/diarize`, { method: 'POST', body: formData, }) if (!response.ok) { const errData = await response.json() as { message?: string } throw new Error(errData.message ?? `Diarization failed: ${response.status}`) } const diarResult = await response.json() as { segments: Array<{ speaker: string; start: number; end: number }> num_speakers: number } this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 70 }) // 전사 세그먼트와 화자 세그먼트 매칭 const transcript = row.editedTranscript ?? row.rawTranscript ?? '' const lines = transcript.split('\n').filter((l) => l.trim()) const diarSegments = diarResult.segments const labeledLines = lines.map((line) => { const timeMatch = /^\[(\d{2}):(\d{2})\]/.exec(line) if (!timeMatch) return line const timeSec = parseInt(timeMatch[1], 10) * 60 + parseInt(timeMatch[2], 10) // 해당 시간에 가장 가까운 화자 세그먼트 찾기 const matchedSeg = diarSegments.find( (s) => timeSec >= s.start && timeSec <= s.end, ) if (matchedSeg) { const speakerLabel = matchedSeg.speaker.replace('SPEAKER_', '화자 ') return line.replace(/^\[(\d{2}:\d{2})\]/, `[$1] [${speakerLabel}]`) } return line }) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 85 }) db.update(meetingSessions).set({ editedTranscript: labeledLines.join('\n'), updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() logger.info(`pyannote 화자 구분 완료: ${diarResult.num_speakers}명, sessionId=${sessionId}`) } catch (err) { logger.warn(`pyannote 실패, LLM 폴백: ${err instanceof Error ? err.message : String(err)}`) // pyannote 실패 시 LLM 폴백 await this._diarizeLLMFallback(sessionId, row, numSpeakers) } } else { // LLM 기반 화자 추정 (오디오 없거나 HF 토큰 없음) await this._diarizeLLMFallback(sessionId, row, numSpeakers) } this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 100 }) logger.info(`화자 구분 완료: sessionId=${sessionId}`) } private async _diarizeLLMFallback( sessionId: string, row: { rawTranscript: string | null; editedTranscript: string | null }, numSpeakers?: number, ): Promise { 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 }) const db = getDatabase() db.update(meetingSessions).set({ editedTranscript: result.text, updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() } private _buildPdfHtml(session: MeetingSessionDetail): string { const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0 const minutesHtml = session.minutesMarkdown ? markdownToSimpleHtml(session.minutesMarkdown) : '

회의록이 생성되지 않았습니다.

' return `

${session.title ?? '무제 회의'}

일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분

${minutesHtml} ` } } // ── 싱글톤 ── let instance: MeetingModeService | null = null export function getMeetingModeService(): MeetingModeService { if (!instance) { instance = new MeetingModeService() } return instance }