fix: 회의 모드 녹음 개선
마이크 캡처 강제(시스템 오디오→마이크), CaptionOverlay 숨김, 오디오 레벨 인디케이터 추가, 상태 전환 꼬임 수정, 전사 타임스탬프 상대 시간 변환.
This commit is contained in:
parent
acab319589
commit
43a2acb89d
3 changed files with 73 additions and 6 deletions
|
|
@ -75,6 +75,9 @@ class MeetingModeService extends EventEmitter {
|
||||||
private _segments: CaptionSegment[] = []
|
private _segments: CaptionSegment[] = []
|
||||||
private _memos: MeetingMemo[] = []
|
private _memos: MeetingMemo[] = []
|
||||||
private _segmentHandler: ((segment: CaptionSegment) => void) | null = null
|
private _segmentHandler: ((segment: CaptionSegment) => void) | null = null
|
||||||
|
private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
|
||||||
|
private _audioLevelTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
private _lastRms = 0
|
||||||
/** CaptionService session-saved 방지 플래그 */
|
/** CaptionService session-saved 방지 플래그 */
|
||||||
private _meetingModeActive = false
|
private _meetingModeActive = false
|
||||||
|
|
||||||
|
|
@ -148,9 +151,18 @@ class MeetingModeService extends EventEmitter {
|
||||||
}
|
}
|
||||||
captionService.on('segment', this._segmentHandler)
|
captionService.on('segment', this._segmentHandler)
|
||||||
|
|
||||||
|
// 회의 모드는 마이크 캡처 강제 (시스템 오디오가 아닌 마이크)
|
||||||
|
const prevAudioSource = captionService.getConfig().audioSource
|
||||||
|
captionService.setConfig({ audioSource: 'mic' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await captionService.start()
|
await captionService.start()
|
||||||
|
// 회의 모드 UI가 자체 자막 패널에 표시하므로 오버레이 숨김
|
||||||
|
const { hideCaptionOverlay } = await import('../windows/WindowManager')
|
||||||
|
hideCaptionOverlay()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 오디오 소스 복원
|
||||||
|
captionService.setConfig({ audioSource: prevAudioSource })
|
||||||
// 시작 실패 시 복원
|
// 시작 실패 시 복원
|
||||||
captionService.off('segment', this._segmentHandler)
|
captionService.off('segment', this._segmentHandler)
|
||||||
this._segmentHandler = null
|
this._segmentHandler = null
|
||||||
|
|
@ -168,6 +180,18 @@ class MeetingModeService extends EventEmitter {
|
||||||
throw err
|
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}`)
|
logger.info(`회의 녹음 시작: sessionId=${sessionId}`)
|
||||||
this._sendStateToRenderer()
|
this._sendStateToRenderer()
|
||||||
|
|
||||||
|
|
@ -217,6 +241,17 @@ class MeetingModeService extends EventEmitter {
|
||||||
const sessionId = this._sessionId!
|
const sessionId = this._sessionId!
|
||||||
const now = Date.now()
|
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 종료
|
// CaptionService 종료
|
||||||
const { getCaptionService } = await import('./CaptionService')
|
const { getCaptionService } = await import('./CaptionService')
|
||||||
const captionService = getCaptionService()
|
const captionService = getCaptionService()
|
||||||
|
|
|
||||||
|
|
@ -598,6 +598,8 @@ const electronAPI = {
|
||||||
on(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, cb),
|
on(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, cb),
|
||||||
onError: (cb: (e: { code: number; message: string }) => void): Unsubscribe =>
|
onError: (cb: (e: { code: number; message: string }) => void): Unsubscribe =>
|
||||||
on(IPC_CHANNELS.MEETING_MODE.ERROR, cb),
|
on(IPC_CHANNELS.MEETING_MODE.ERROR, cb),
|
||||||
|
onAudioLevel: (cb: (e: { level: number }) => void): Unsubscribe =>
|
||||||
|
on('meetingMode:audioLevel', cb),
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export function MeetingModePage(): React.ReactElement {
|
||||||
const [memoInput, setMemoInput] = useState('')
|
const [memoInput, setMemoInput] = useState('')
|
||||||
const [elapsedMs, setElapsedMs] = useState(0)
|
const [elapsedMs, setElapsedMs] = useState(0)
|
||||||
const [progress, setProgress] = useState<MeetingProcessingProgress | null>(null)
|
const [progress, setProgress] = useState<MeetingProcessingProgress | null>(null)
|
||||||
|
const [audioLevel, setAudioLevel] = useState(0)
|
||||||
const transcriptRef = useRef<HTMLDivElement>(null)
|
const transcriptRef = useRef<HTMLDivElement>(null)
|
||||||
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const recordingStartRef = useRef<number>(0)
|
const recordingStartRef = useRef<number>(0)
|
||||||
|
|
@ -79,11 +80,10 @@ export function MeetingModePage(): React.ReactElement {
|
||||||
const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => {
|
const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => {
|
||||||
setStateInfo(info)
|
setStateInfo(info)
|
||||||
if (info.state === 'idle' && view === 'recording') {
|
if (info.state === 'idle' && view === 'recording') {
|
||||||
// 후처리 완료 → 목록으로 이동
|
// 후처리 완료 또는 에러 → 목록으로 이동 + 새로고침
|
||||||
if (!progress) {
|
setProgress(null)
|
||||||
setView('list')
|
setView('list')
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const unsubSegment = window.electronAPI.meetingMode.onSegment((seg) => {
|
const unsubSegment = window.electronAPI.meetingMode.onSegment((seg) => {
|
||||||
|
|
@ -110,14 +110,19 @@ export function MeetingModePage(): React.ReactElement {
|
||||||
setView('list')
|
setView('list')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const unsubAudioLevel = window.electronAPI.meetingMode.onAudioLevel((data) => {
|
||||||
|
setAudioLevel(Math.min(data.level * 10, 1)) // RMS 정규화
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubState()
|
unsubState()
|
||||||
unsubSegment()
|
unsubSegment()
|
||||||
unsubProgress()
|
unsubProgress()
|
||||||
unsubCompleted()
|
unsubCompleted()
|
||||||
unsubError()
|
unsubError()
|
||||||
|
unsubAudioLevel()
|
||||||
}
|
}
|
||||||
}, [view, progress])
|
}, [view])
|
||||||
|
|
||||||
// ── 경과 시간 타이머 ──
|
// ── 경과 시간 타이머 ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -355,7 +360,7 @@ export function MeetingModePage(): React.ReactElement {
|
||||||
component="span"
|
component="span"
|
||||||
sx={{ color: d3roPalette.text.inactive, fontSize: 11, mr: 1, fontFamily: d3roFontMono }}
|
sx={{ color: d3roPalette.text.inactive, fontSize: 11, mr: 1, fontFamily: d3roFontMono }}
|
||||||
>
|
>
|
||||||
[{formatTime(seg.timestamp)}]
|
[{formatTime(seg.timestamp - (recordingStartRef.current || seg.timestamp))}]
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography component="span" sx={{ fontSize: 13, fontFamily: d3roFontMono }}>
|
<Typography component="span" sx={{ fontSize: 13, fontFamily: d3roFontMono }}>
|
||||||
{seg.text}
|
{seg.text}
|
||||||
|
|
@ -409,6 +414,31 @@ export function MeetingModePage(): React.ReactElement {
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* 오디오 레벨 미터 */}
|
||||||
|
{!isProcessing && (
|
||||||
|
<Box sx={{ mt: 1.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Led color={audioLevel > 0.02 ? 'green' : 'amber'} size={8} />
|
||||||
|
<Box sx={{
|
||||||
|
flex: 1,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: d3roPalette.bg.inset,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<Box sx={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.max(audioLevel * 100, 0)}%`,
|
||||||
|
bgcolor: audioLevel > 0.7 ? d3roPalette.tag.red : audioLevel > 0.3 ? d3roPalette.tag.orange : d3roPalette.tag.green,
|
||||||
|
borderRadius: 3,
|
||||||
|
transition: 'width 0.1s ease-out',
|
||||||
|
}} />
|
||||||
|
</Box>
|
||||||
|
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono, minWidth: 30 }}>
|
||||||
|
{Math.round(audioLevel * 100)}%
|
||||||
|
</PhosphorText>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue