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한다. * 잔여 바이트는 다음 청크와 결합한다.