SoX 마이크 캡처 수정 + 카드 z-index 그림자

- AudioCaptureService: node-record-lpcm16 → SoX 직접 spawn
  Windows에서 '-t waveaudio default' 필수 (--default-device 미지원)
- DashboardPage: 각 섹션에 증가하는 zIndex 적용 (아래 카드가 상위 레이어)
This commit is contained in:
Yun Chan 2026-04-05 10:13:57 +09:00
parent 1647321540
commit eed225c8d4
2 changed files with 66 additions and 68 deletions

View file

@ -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<typeof setInterval> | 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<string, unknown> = {
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 <device> --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)

View file

@ -144,6 +144,7 @@ export function DashboardPage(): React.ReactElement {
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4, pb: 8 }}>
{/* ── 1. HERO 영역 ──────────────────────────── */}
<Box sx={{ position: 'relative', zIndex: 1 }}>
<InstrumentPanel
engravingLeft="D3RO-VOICE"
engravingRight="v1.0.0"
@ -202,10 +203,13 @@ export function DashboardPage(): React.ReactElement {
</Box>
</Box>
</InstrumentPanel>
</Box>
{/* ── 2. 통계 카드 ──────────────────────────── */}
<Box
sx={{
position: 'relative',
zIndex: 2,
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 2,
@ -235,7 +239,7 @@ export function DashboardPage(): React.ReactElement {
</Box>
{/* ── 3. 서비스 상태 (CRT 컴팩트) ────────────── */}
<Box sx={{ mt: 3 }}>
<Box sx={{ mt: 3, position: 'relative', zIndex: 3 }}>
<CrtDisplay amplitude={0.05} frequency={6} height={120}>
<Box
sx={{
@ -267,7 +271,7 @@ export function DashboardPage(): React.ReactElement {
</Box>
{/* ── 4. 최근 히스토리 ──────────────────────── */}
<Box sx={{ mt: 4 }}>
<Box sx={{ mt: 4, position: 'relative', zIndex: 4 }}>
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
RECENT TRANSCRIPTIONS
</PhosphorText>