diff --git a/src/main/services/AudioCaptureService.ts b/src/main/services/AudioCaptureService.ts index 18e443e..4385c9a 100644 --- a/src/main/services/AudioCaptureService.ts +++ b/src/main/services/AudioCaptureService.ts @@ -1,12 +1,11 @@ // src/main/services/AudioCaptureService.ts // 마이크 PCM 캡처 서비스. 설계서 01의 IAudioCaptureService 구현. -// node-record-lpcm16 + SoX로 PCM16 16kHz mono 캡처. +// Windows: SoX 직접 spawn (-t waveaudio). 기타: node-record-lpcm16. import { EventEmitter } from 'events' import path from 'path' import { existsSync } from 'fs' -import { record } from 'node-record-lpcm16' -import type { Recording } from 'node-record-lpcm16' +import { spawn, type ChildProcess } from 'child_process' import type { Readable } from 'stream' import { getLogger } from './LoggerService' import { configGet } from './ConfigService' @@ -57,7 +56,7 @@ class AudioCaptureService extends EventEmitter { private _state: CaptureState = 'idle' private _currentDevice: AudioDevice | null = null private _refCount = 0 - private _recording: Recording | null = null + private _soxProcess: ChildProcess | null = null private _stream: Readable | null = null private _levelInterval: ReturnType | null = null @@ -96,89 +95,84 @@ class AudioCaptureService extends EventEmitter { `format: ${AUDIO_FORMAT.SAMPLE_RATE}Hz ${AUDIO_FORMAT.CHANNELS}ch ${AUDIO_FORMAT.BIT_DEPTH}bit)` ) - // node-record-lpcm16은 'sox' 명령어를 PATH에서 찾으므로, - // 번들된 SoX 디렉토리를 PATH 앞에 추가한다. + // SoX 직접 spawn (Windows: -t waveaudio 필수) const soxExe = getSoxPath() - const soxDir = path.dirname(soxExe) - if (existsSync(soxExe) && soxExe !== 'sox') { - const sep = process.platform === 'win32' ? ';' : ':' - process.env.PATH = soxDir + sep + (process.env.PATH ?? '') - logger.info(`Bundled SoX added to PATH: ${soxDir}`) - } logger.info(`Using SoX: ${soxExe}`) - const recordingOptions: Record = { - sampleRate: AUDIO_FORMAT.SAMPLE_RATE, - channels: AUDIO_FORMAT.CHANNELS, - recorder: 'sox', - audioType: 'raw', // 헤더 없는 PCM raw 출력 - endOnSilence: false + if (!existsSync(soxExe) && soxExe !== 'sox') { + throw new D3ROError( + ErrorCode.AudioCaptureStartFailed, + `SoX 실행 파일을 찾을 수 없습니다: ${soxExe}` + ) } - // 특정 디바이스가 지정된 경우 AUDIODEV 환경변수로 전달 - if (selectedDeviceId && selectedDeviceId !== 'default') { - recordingOptions.device = selectedDeviceId - } + const deviceArg = selectedDeviceId && selectedDeviceId !== 'default' + ? selectedDeviceId + : 'default' - this._recording = record(recordingOptions) - this._stream = this._recording.stream() + // Windows: sox -t waveaudio --no-show-progress -r 16000 -c 1 -b 16 -e signed-integer -t raw - + // Linux/Mac: sox -d --no-show-progress ... + 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', + '-', + ] + + logger.info(`SoX args: ${soxArgs.join(' ')}`) + this._soxProcess = spawn(soxExe, soxArgs, { stdio: ['pipe', 'pipe', 'pipe'] }) + this._stream = this._soxProcess.stdout this._residualBuffer = Buffer.alloc(0) this._levelAccumulator = [] + if (!this._stream) { + throw new D3ROError(ErrorCode.AudioCaptureStartFailed, 'SoX stdout stream is null') + } + // 스트림 데이터 수신: 60ms 프레임 단위로 잘라 emit this._stream.on('data', (chunk: Buffer) => { this._onAudioChunk(chunk) }) - // 스트림 에러 처리 - this._stream.on('error', (errorMessage: string | Error) => { - const msg = typeof errorMessage === 'string' ? errorMessage : errorMessage.message - logger.error(`Audio stream error: ${msg}`) - - // SoX 미설치 감지 - const isSoxMissing = - msg.includes('ENOENT') || - msg.includes('not found') || - msg.includes('is not recognized') - - const errorCode = isSoxMissing - ? ErrorCode.AudioCaptureStartFailed - : ErrorCode.AudioStreamError - - const d3roError = new D3ROError( - errorCode, - isSoxMissing - ? 'SoX가 설치되어 있지 않거나 PATH에 없습니다. SoX를 설치해주세요: https://sox.sourceforge.net' - : `Audio stream error: ${msg}` - ) - - this._handleError(d3roError, 'error') + // SoX stderr → 로그 + this._soxProcess.stderr?.on('data', (chunk: Buffer) => { + const msg = chunk.toString().trim() + if (msg) logger.warn(`SoX stderr: ${msg}`) }) // SoX 프로세스 종료 감지 - this._stream.on('end', () => { + this._soxProcess.on('close', (code) => { if (this._state === 'capturing') { - logger.warn('Audio stream ended unexpectedly') + logger.warn(`SoX process exited unexpectedly (code: ${code})`) this._handleError( - new D3ROError(ErrorCode.AudioCaptureFailed, 'Audio capture process terminated unexpectedly'), + new D3ROError(ErrorCode.AudioCaptureFailed, `SoX process exited with code ${code}`), 'device-lost' ) } }) - // SoX child process 에러 이벤트 (spawn 실패 등) - if (this._recording.process) { - this._recording.process.on('error', (err: Error) => { - logger.error(`SoX process spawn error: ${err.message}`) - - const d3roError = new D3ROError( - ErrorCode.AudioCaptureStartFailed, - `SoX 프로세스 시작 실패: ${err.message}. SoX가 설치되어 있는지 확인해주세요.` - ) - - this._handleError(d3roError, 'error') - }) - } + this._soxProcess.on('error', (err: Error) => { + logger.error(`SoX process spawn error: ${err.message}`) + this._handleError( + new D3ROError(ErrorCode.AudioCaptureStartFailed, `SoX 프로세스 시작 실패: ${err.message}`), + 'error' + ) + }) this._currentDevice = { deviceId: selectedDeviceId ?? 'default', @@ -357,13 +351,13 @@ class AudioCaptureService extends EventEmitter { this._stream = null } - if (this._recording) { + if (this._soxProcess) { try { - this._recording.stop() + this._soxProcess.kill() } catch { // 이미 종료된 프로세스 kill 시 에러 무시 } - this._recording = null + this._soxProcess = null } this._residualBuffer = Buffer.alloc(0) diff --git a/src/renderer/pages/DashboardPage.tsx b/src/renderer/pages/DashboardPage.tsx index 47f7609..e17d0a8 100644 --- a/src/renderer/pages/DashboardPage.tsx +++ b/src/renderer/pages/DashboardPage.tsx @@ -144,6 +144,7 @@ export function DashboardPage(): React.ReactElement { return ( {/* ── 1. HERO 영역 ──────────────────────────── */} + + {/* ── 2. 통계 카드 ──────────────────────────── */} {/* ── 3. 서비스 상태 (CRT 컴팩트) ────────────── */} - + {/* ── 4. 최근 히스토리 ──────────────────────── */} - + RECENT TRANSCRIPTIONS