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
|
// 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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue