SoX 마이크 캡처 수정 + 카드 z-index 그림자
- AudioCaptureService: node-record-lpcm16 → SoX 직접 spawn Windows에서 '-t waveaudio default' 필수 (--default-device 미지원) - DashboardPage: 각 섹션에 증가하는 zIndex 적용 (아래 카드가 상위 레이어)
This commit is contained in:
parent
1647321540
commit
eed225c8d4
2 changed files with 66 additions and 68 deletions
|
|
@ -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) => {
|
||||
this._soxProcess.on('error', (err: Error) => {
|
||||
logger.error(`SoX process spawn error: ${err.message}`)
|
||||
|
||||
const d3roError = new D3ROError(
|
||||
ErrorCode.AudioCaptureStartFailed,
|
||||
`SoX 프로세스 시작 실패: ${err.message}. SoX가 설치되어 있는지 확인해주세요.`
|
||||
this._handleError(
|
||||
new D3ROError(ErrorCode.AudioCaptureStartFailed, `SoX 프로세스 시작 실패: ${err.message}`),
|
||||
'error'
|
||||
)
|
||||
|
||||
this._handleError(d3roError, '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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue