From 43a2acb89d68af2e046ea59bd4dbd9f9bb0d980c Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 8 Apr 2026 08:56:38 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9D=98=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=20=EB=85=B9=EC=9D=8C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이크 캡처 강제(시스템 오디오→마이크), CaptionOverlay 숨김, 오디오 레벨 인디케이터 추가, 상태 전환 꼬임 수정, 전사 타임스탬프 상대 시간 변환. --- src/main/services/MeetingModeService.ts | 35 +++++++++++++++++++++ src/preload/index.ts | 2 ++ src/renderer/pages/MeetingModePage.tsx | 42 +++++++++++++++++++++---- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/main/services/MeetingModeService.ts b/src/main/services/MeetingModeService.ts index 93583ca..1848ac1 100644 --- a/src/main/services/MeetingModeService.ts +++ b/src/main/services/MeetingModeService.ts @@ -75,6 +75,9 @@ class MeetingModeService extends EventEmitter { 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 @@ -148,9 +151,18 @@ class MeetingModeService extends EventEmitter { } 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 @@ -168,6 +180,18 @@ class MeetingModeService extends EventEmitter { 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() @@ -217,6 +241,17 @@ class MeetingModeService extends EventEmitter { 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() diff --git a/src/preload/index.ts b/src/preload/index.ts index e476313..4964fe8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -598,6 +598,8 @@ const electronAPI = { on(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, cb), onError: (cb: (e: { code: number; message: string }) => void): Unsubscribe => on(IPC_CHANNELS.MEETING_MODE.ERROR, cb), + onAudioLevel: (cb: (e: { level: number }) => void): Unsubscribe => + on('meetingMode:audioLevel', cb), }, } as const diff --git a/src/renderer/pages/MeetingModePage.tsx b/src/renderer/pages/MeetingModePage.tsx index 9d4e854..9cb1873 100644 --- a/src/renderer/pages/MeetingModePage.tsx +++ b/src/renderer/pages/MeetingModePage.tsx @@ -50,6 +50,7 @@ export function MeetingModePage(): React.ReactElement { const [memoInput, setMemoInput] = useState('') const [elapsedMs, setElapsedMs] = useState(0) const [progress, setProgress] = useState(null) + const [audioLevel, setAudioLevel] = useState(0) const transcriptRef = useRef(null) const elapsedTimerRef = useRef | null>(null) const recordingStartRef = useRef(0) @@ -79,10 +80,9 @@ export function MeetingModePage(): React.ReactElement { const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => { setStateInfo(info) if (info.state === 'idle' && view === 'recording') { - // 후처리 완료 → 목록으로 이동 - if (!progress) { - setView('list') - } + // 후처리 완료 또는 에러 → 목록으로 이동 + 새로고침 + setProgress(null) + setView('list') } }) @@ -110,14 +110,19 @@ export function MeetingModePage(): React.ReactElement { setView('list') }) + const unsubAudioLevel = window.electronAPI.meetingMode.onAudioLevel((data) => { + setAudioLevel(Math.min(data.level * 10, 1)) // RMS 정규화 + }) + return () => { unsubState() unsubSegment() unsubProgress() unsubCompleted() unsubError() + unsubAudioLevel() } - }, [view, progress]) + }, [view]) // ── 경과 시간 타이머 ── useEffect(() => { @@ -355,7 +360,7 @@ export function MeetingModePage(): React.ReactElement { component="span" sx={{ color: d3roPalette.text.inactive, fontSize: 11, mr: 1, fontFamily: d3roFontMono }} > - [{formatTime(seg.timestamp)}] + [{formatTime(seg.timestamp - (recordingStartRef.current || seg.timestamp))}] {seg.text} @@ -409,6 +414,31 @@ export function MeetingModePage(): React.ReactElement { )} + + {/* 오디오 레벨 미터 */} + {!isProcessing && ( + + 0.02 ? 'green' : 'amber'} size={8} /> + + 0.7 ? d3roPalette.tag.red : audioLevel > 0.3 ? d3roPalette.tag.orange : d3roPalette.tag.green, + borderRadius: 3, + transition: 'width 0.1s ease-out', + }} /> + + + {Math.round(audioLevel * 100)}% + + + )} ) }