// src/main/services/MeetingModeService.ts // Phase 14: Meeting Mode — 실시간 녹음 + 타임스탬프 메모 + LLM 구조화 회의록 // 싱글톤 + 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 } from './ConfigService' import { getMainWindow } from '../windows/WindowManager' import { getDatabase } from '../db' import { meetingSessions, meetingMemos } from '../db/schema' import { IPC_CHANNELS } from '@shared/ipc-channels' import { D3ROError, ErrorCode } from '@shared/errors' import type { MeetingModeState, MeetingMemo, MeetingSessionSummary, MeetingSessionDetail, MeetingMinutes, MeetingSessionPage, MeetingStartResult, MeetingModeStateInfo, MeetingProcessingStep, MeetingProcessingProgress, CaptionSegment, } from '@shared/types' const logger = getLogger('MeetingModeService') const MEETING_MODE_SYSTEM_PROMPT = `당신은 전문 회의록 작성 비서입니다. 전사록과 참석자 메모를 분석하여 구조화된 회의록을 작성합니다. 규칙: - 전사록/메모에 없는 내용을 추가하지 마세요 - 메모(📝)는 참석자가 직접 작성한 핵심 포인트이므로 우선 반영하세요 - 시간 순서를 유지하세요 - 한국어로 작성하세요 (원문이 영어면 원어 유지) 출력 형식: ## 요약 (3-5줄 핵심 요약) ## 핵심 결정사항 - (결정 1) - (결정 2) ## 할 일 목록 - [ ] (담당자가 있으면 포함) (할 일) ## 타임라인 | 시간 | 내용 | |------|------| | MM:SS | 주요 발언/이벤트 |` /** 긴 회의 청크 분할 기준 (자) */ const LONG_MEETING_THRESHOLD = 20000 const CHUNK_SIZE = 5000 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 // ── 공개 접근자 ── 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) // 회의 모드는 마이크 캡처 강제 (시스템 오디오가 아닌 마이크) const prevAudioSource = captionService.getConfig().audioSource captionService.setConfig({ audioSource: 'mic' }) try { await captionService.start() // 회의 모드 UI가 자체 자막 패널에 표시하므로 오버레이 숨김 const { hideCaptionOverlay } = await import('../windows/WindowManager') hideCaptionOverlay() } catch (err) { // 오디오 소스 복원 captionService.setConfig({ audioSource: prevAudioSource }) // 시작 실패 시 복원 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._audioDataHandler = (payload: { buffer: Buffer; timestamp: number }) => { this._lastRms = calculateRMS(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(`메모 추가: [${this._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', 20) const rawTranscript = this._segments .map((s) => `[${this._formatTime(s.timestamp)}] ${s.text}`) .join('\n') // Step 2: 메모 포맷팅 const formattedMemos = this._memos .map((m) => `[${this._formatTime(m.timestampMs)}] 📝 ${m.content}`) .join('\n') // Step 3: LLM 구조화 this._sendProgress(sessionId, 'generating', 40) const { getLocalLLMService } = await import('./LocalLLMService') const llmService = getLocalLLMService() const prompt = this._buildPrompt(rawTranscript, formattedMemos) const sttEnd = Date.now() let resultText: string let resultModel: string if (rawTranscript.length > LONG_MEETING_THRESHOLD) { // 긴 회의: 2-pass 요약 const chunks = this._splitIntoChunks(rawTranscript, CHUNK_SIZE) const chunkResults = [] for (let i = 0; i < chunks.length; i++) { const pct = 40 + Math.floor((i / chunks.length) * 30) this._sendProgress(sessionId, 'generating', pct) const chunkResult = await llmService.generate( `다음 회의 전사 일부를 3줄로 요약하세요:\n\n${chunks[i]}`, { temperature: 0.3 }, ) chunkResults.push(chunkResult.text) } const condensed = chunkResults.join('\n\n') const finalResult = await llmService.generate( this._buildPrompt(condensed, formattedMemos), { systemPrompt: MEETING_MODE_SYSTEM_PROMPT, temperature: 0.3, maxTokens: 4096, }, ) resultText = finalResult.text resultModel = finalResult.model } else { const result = await llmService.generate(prompt, { systemPrompt: MEETING_MODE_SYSTEM_PROMPT, temperature: 0.3, maxTokens: 4096, }) resultText = result.text resultModel = result.model } const llmEnd = Date.now() // Step 4: 마크다운 파싱 this._sendProgress(sessionId, 'parsing', 85) const minutes = this._parseMinutes(resultText) // Step 5: DB 저장 this._sendProgress(sessionId, 'saving', 90) const now = Date.now() db.update(meetingSessions).set({ status: 'completed', rawTranscript, minutesMarkdown: resultText, minutesJson: JSON.stringify(minutes), sttModel: configGet('sttModelId') as string | undefined, llmModel: resultModel, sttLatencyMs: sttEnd - (this._sessionStartedAt ?? sttEnd), llmLatencyMs: llmEnd - sttEnd, updatedAt: now, }).where(eq(meetingSessions.id, sessionId)).run() // 자동 제목 생성 const autoTitle = minutes.summary.split('\n')[0].slice(0, 50) || '무제 회의' db.update(meetingSessions).set({ title: autoTitle, updatedAt: now, }).where(eq(meetingSessions.id, sessionId)).run() // Step 6: Windows 알림 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: `회의록_${this._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: `회의록_${this._formatDateFile(session.startedAt)}.md`, filters: [{ name: 'Markdown', extensions: ['md'] }], }) if (!filePath) return '' const md = this._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 } 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) } private _formatTime(ms: number): string { const totalSeconds = Math.floor(ms / 1000) const minutes = Math.floor(totalSeconds / 60) const seconds = totalSeconds % 60 return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` } private _formatDateFile(epochMs: number): string { const d = new Date(epochMs) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}` } private _formatDateRange(startedAt: number, endedAt: number | null): string { const fmt = (ts: number): string => { const d = new Date(ts) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` } if (!endedAt) return fmt(startedAt) return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}` } private _buildPrompt(rawTranscript: string, formattedMemos: string): string { return `## 전사록\n${rawTranscript}\n\n## 참석자 메모 (📝 표시)\n${formattedMemos}\n\n위 전사록과 메모를 바탕으로 회의록을 작성해주세요.` } private _parseMinutes(markdown: string): MeetingMinutes { const minutes: MeetingMinutes = { summary: '', decisions: [], actionItems: [], timeline: [], } const sections = markdown.split(/^## /m) for (const section of sections) { const lines = section.trim().split('\n') const heading = lines[0]?.toLowerCase() ?? '' const body = lines.slice(1).join('\n').trim() if (heading.includes('요약') || heading.includes('summary')) { minutes.summary = body } else if (heading.includes('결정') || heading.includes('decision')) { minutes.decisions = body .split('\n') .filter((l) => l.startsWith('-') || l.startsWith('*')) .map((l) => l.replace(/^[-*]\s*/, '').trim()) } else if (heading.includes('할 일') || heading.includes('action')) { minutes.actionItems = body .split('\n') .filter((l) => l.startsWith('-') || l.startsWith('*')) .map((l) => { const text = l.replace(/^[-*]\s*(\[.\]\s*)?/, '').trim() return { task: text } }) } else if (heading.includes('타임라인') || heading.includes('timeline')) { const tableRows = body .split('\n') .filter((l) => l.includes('|') && !l.includes('---')) for (const row of tableRows) { const cells = row.split('|').map((c) => c.trim()).filter(Boolean) if (cells.length >= 2) { minutes.timeline.push({ time: cells[0], content: cells[1], type: cells[1].includes('📝') ? 'memo' : 'transcript', }) } } } } return minutes } 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, })) 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, minutesMarkdown: row.minutesMarkdown, minutes, memos, sttModel: row.sttModel, llmModel: row.llmModel, errorMessage: row.errorMessage, } } private _splitIntoChunks(text: string, chunkSize: number): string[] { const chunks: string[] = [] for (let i = 0; i < text.length; i += chunkSize) { chunks.push(text.slice(i, i + chunkSize)) } return chunks } private _buildPdfHtml(session: MeetingSessionDetail): string { const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0 const minutesHtml = session.minutesMarkdown ? this._markdownToSimpleHtml(session.minutesMarkdown) : '

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

' return `

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

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

${minutesHtml} ` } private _buildExportMarkdown(session: MeetingSessionDetail): string { const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0 const memoTable = session.memos.length > 0 ? `## 참석자 메모\n\n| 시간 | 메모 |\n|------|------|\n${session.memos.map((m) => `| ${this._formatTime(m.timestampMs)} | ${m.content} |`).join('\n')}` : '' return `# 회의록 — ${session.title ?? '무제 회의'} - **일시**: ${this._formatDateRange(session.startedAt, session.endedAt)} - **소요 시간**: ${durationMin}분 - **STT 모델**: ${session.sttModel ?? '-'} - **LLM 모델**: ${session.llmModel ?? '-'} --- ${session.minutesMarkdown ?? '회의록이 생성되지 않았습니다.'} --- ${memoTable} --- ## 원문 전사 ${session.rawTranscript ?? '(없음)'} ` } /** 간단한 마크다운→HTML 변환 (PDF 생성용) */ private _markdownToSimpleHtml(md: string): string { return md .replace(/^### (.+)$/gm, '

$1

') .replace(/^## (.+)$/gm, '

$1

') .replace(/^# (.+)$/gm, '

$1

') .replace(/^\- \[.\] (.+)$/gm, '
  • $1
  • ') .replace(/^\- (.+)$/gm, '
  • $1
  • ') .replace(/^\* (.+)$/gm, '
  • $1
  • ') .replace(/\n{2,}/g, '

    ') .replace(/^(?!<[h|l|t|p])/gm, '') } } // ── 싱글톤 ── let instance: MeetingModeService | null = null export function getMeetingModeService(): MeetingModeService { if (!instance) { instance = new MeetingModeService() } return instance }