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 // src/main/services/AudioCaptureService.ts
// 마이크 PCM 캡처 서비스. 설계서 01의 IAudioCaptureService 구현. // 마이크 PCM 캡처 서비스. 설계서 01의 IAudioCaptureService 구현.
// node-record-lpcm16 + SoX로 PCM16 16kHz mono 캡처. // Windows: SoX 직접 spawn (-t waveaudio). 기타: node-record-lpcm16.
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import path from 'path' import path from 'path'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { record } from 'node-record-lpcm16' import { spawn, type ChildProcess } from 'child_process'
import type { Recording } from 'node-record-lpcm16'
import type { Readable } from 'stream' import type { Readable } from 'stream'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { configGet } from './ConfigService' import { configGet } from './ConfigService'
@ -57,7 +56,7 @@ class AudioCaptureService extends EventEmitter {
private _state: CaptureState = 'idle' private _state: CaptureState = 'idle'
private _currentDevice: AudioDevice | null = null private _currentDevice: AudioDevice | null = null
private _refCount = 0 private _refCount = 0
private _recording: Recording | null = null private _soxProcess: ChildProcess | null = null
private _stream: Readable | null = null private _stream: Readable | null = null
private _levelInterval: ReturnType<typeof setInterval> | 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)` `format: ${AUDIO_FORMAT.SAMPLE_RATE}Hz ${AUDIO_FORMAT.CHANNELS}ch ${AUDIO_FORMAT.BIT_DEPTH}bit)`
) )
// node-record-lpcm16은 'sox' 명령어를 PATH에서 찾으므로, // SoX 직접 spawn (Windows: -t waveaudio 필수)
// 번들된 SoX 디렉토리를 PATH 앞에 추가한다.
const soxExe = getSoxPath() 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}`) logger.info(`Using SoX: ${soxExe}`)
const recordingOptions: Record<string, unknown> = { if (!existsSync(soxExe) && soxExe !== 'sox') {
sampleRate: AUDIO_FORMAT.SAMPLE_RATE, throw new D3ROError(
channels: AUDIO_FORMAT.CHANNELS, ErrorCode.AudioCaptureStartFailed,
recorder: 'sox', `SoX 실행 파일을 찾을 수 없습니다: ${soxExe}`
audioType: 'raw', // 헤더 없는 PCM raw 출력 )
endOnSilence: false
} }
// 특정 디바이스가 지정된 경우 AUDIODEV 환경변수로 전달 const deviceArg = selectedDeviceId && selectedDeviceId !== 'default'
if (selectedDeviceId && selectedDeviceId !== 'default') { ? selectedDeviceId
recordingOptions.device = selectedDeviceId : 'default'
}
this._recording = record(recordingOptions) // Windows: sox -t waveaudio <device> --no-show-progress -r 16000 -c 1 -b 16 -e signed-integer -t raw -
this._stream = this._recording.stream() // 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._residualBuffer = Buffer.alloc(0)
this._levelAccumulator = [] this._levelAccumulator = []
if (!this._stream) {
throw new D3ROError(ErrorCode.AudioCaptureStartFailed, 'SoX stdout stream is null')
}
// 스트림 데이터 수신: 60ms 프레임 단위로 잘라 emit // 스트림 데이터 수신: 60ms 프레임 단위로 잘라 emit
this._stream.on('data', (chunk: Buffer) => { this._stream.on('data', (chunk: Buffer) => {
this._onAudioChunk(chunk) this._onAudioChunk(chunk)
}) })
// 스트림 에러 처리 // SoX stderr → 로그
this._stream.on('error', (errorMessage: string | Error) => { this._soxProcess.stderr?.on('data', (chunk: Buffer) => {
const msg = typeof errorMessage === 'string' ? errorMessage : errorMessage.message const msg = chunk.toString().trim()
logger.error(`Audio stream error: ${msg}`) if (msg) logger.warn(`SoX stderr: ${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 프로세스 종료 감지 // SoX 프로세스 종료 감지
this._stream.on('end', () => { this._soxProcess.on('close', (code) => {
if (this._state === 'capturing') { if (this._state === 'capturing') {
logger.warn('Audio stream ended unexpectedly') logger.warn(`SoX process exited unexpectedly (code: ${code})`)
this._handleError( this._handleError(
new D3ROError(ErrorCode.AudioCaptureFailed, 'Audio capture process terminated unexpectedly'), new D3ROError(ErrorCode.AudioCaptureFailed, `SoX process exited with code ${code}`),
'device-lost' 'device-lost'
) )
} }
}) })
// SoX child process 에러 이벤트 (spawn 실패 등) this._soxProcess.on('error', (err: Error) => {
if (this._recording.process) { logger.error(`SoX process spawn error: ${err.message}`)
this._recording.process.on('error', (err: Error) => { this._handleError(
logger.error(`SoX process spawn error: ${err.message}`) new D3ROError(ErrorCode.AudioCaptureStartFailed, `SoX 프로세스 시작 실패: ${err.message}`),
'error'
const d3roError = new D3ROError( )
ErrorCode.AudioCaptureStartFailed, })
`SoX 프로세스 시작 실패: ${err.message}. SoX가 설치되어 있는지 확인해주세요.`
)
this._handleError(d3roError, 'error')
})
}
this._currentDevice = { this._currentDevice = {
deviceId: selectedDeviceId ?? 'default', deviceId: selectedDeviceId ?? 'default',
@ -357,13 +351,13 @@ class AudioCaptureService extends EventEmitter {
this._stream = null this._stream = null
} }
if (this._recording) { if (this._soxProcess) {
try { try {
this._recording.stop() this._soxProcess.kill()
} catch { } catch {
// 이미 종료된 프로세스 kill 시 에러 무시 // 이미 종료된 프로세스 kill 시 에러 무시
} }
this._recording = null this._soxProcess = null
} }
this._residualBuffer = Buffer.alloc(0) this._residualBuffer = Buffer.alloc(0)

View file

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