From fa73ad3bb2ccf32d3e8e492eb7b6951381207d87 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 8 Apr 2026 09:04:03 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=98=A4=EB=94=94=EC=98=A4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B2=84=ED=8A=BC=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=E2=80=94=20=EC=8A=A4=ED=85=81=20=EC=A0=9C=EA=B1=B0,=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20SoX=20=EC=BA=A1=EC=B2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2초간 마이크 캡처 후 평균/피크 RMS 레벨 반환. 설정 오디오 탭에서 테스트 버튼 정상 작동. --- src/main/ipc/audio-handlers.ts | 12 +++++- src/main/services/AudioCaptureService.ts | 55 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/audio-handlers.ts b/src/main/ipc/audio-handlers.ts index 102478e..51d7446 100644 --- a/src/main/ipc/audio-handlers.ts +++ b/src/main/ipc/audio-handlers.ts @@ -31,7 +31,15 @@ export function registerAudioHandlers(): void { }) ipcMain.handle(IPC_CHANNELS.AUDIO.TEST_DEVICE, async () => { - // Phase 1: 스텁 - return ipcSuccess({ averageLevel: 0, peakLevel: 0, hasAudio: false }) + try { + const service = getAudioCaptureService() + const result = await service.testCapture(2000) + return ipcSuccess(result) + } catch (err) { + return ipcError( + ErrorCode.AudioDeviceNotFound, + `Audio test failed: ${err instanceof Error ? err.message : String(err)}`, + ) + } }) } diff --git a/src/main/services/AudioCaptureService.ts b/src/main/services/AudioCaptureService.ts index 729e851..90ea84f 100644 --- a/src/main/services/AudioCaptureService.ts +++ b/src/main/services/AudioCaptureService.ts @@ -278,6 +278,61 @@ class AudioCaptureService extends EventEmitter { logger.info('AudioCaptureService disposed') } + /** + * 지정 시간(ms) 동안 마이크 캡처 후 평균/피크 레벨을 반환한다. + */ + async testCapture(durationMs: number): Promise<{ averageLevel: number; peakLevel: number; hasAudio: boolean }> { + const soxExe = getSoxPath() + const selectedDeviceId = configGet('selectedDeviceId') + const deviceArg = selectedDeviceId && selectedDeviceId !== 'default' + ? selectedDeviceId + : 'default' + + const soxArgs = process.platform === 'win32' + ? ['-t', 'waveaudio', deviceArg, '--no-show-progress', + '-r', String(AUDIO_FORMAT.SAMPLE_RATE), '-c', String(AUDIO_FORMAT.CHANNELS), + '-b', '16', '-e', 'signed-integer', '-t', 'raw', '-'] + : ['-d', '--no-show-progress', + '-r', String(AUDIO_FORMAT.SAMPLE_RATE), '-c', String(AUDIO_FORMAT.CHANNELS), + '-b', '16', '-e', 'signed-integer', '-t', 'raw', '-'] + + return new Promise((resolve, reject) => { + const proc = spawn(soxExe, soxArgs, { stdio: ['ignore', 'pipe', 'pipe'] }) + const buffers: Buffer[] = [] + let peakRms = 0 + + proc.stdout?.on('data', (chunk: Buffer) => { + buffers.push(chunk) + const rms = calculateRMS(chunk) + if (rms > peakRms) peakRms = rms + }) + + proc.on('error', (err) => { + reject(new D3ROError(ErrorCode.AudioCaptureStartFailed, `SoX 실행 실패: ${err.message}`)) + }) + + setTimeout(() => { + proc.kill() + }, durationMs) + + proc.on('close', () => { + if (buffers.length === 0) { + resolve({ averageLevel: 0, peakLevel: 0, hasAudio: false }) + return + } + const combined = Buffer.concat(buffers) + const avgRms = calculateRMS(combined) + const avgLevel = avgRms > 0 ? Math.min(1.0, Math.pow(avgRms, 0.28)) : 0 + const peakLevel = peakRms > 0 ? Math.min(1.0, Math.pow(peakRms, 0.28)) : 0 + resolve({ + averageLevel: avgLevel, + peakLevel: peakLevel, + hasAudio: avgRms > 0.001, + }) + }) + }) + } + /** * 수신된 오디오 청크를 60ms 프레임(1920 bytes) 단위로 분할하여 emit한다. * 잔여 바이트는 다음 청크와 결합한다.