Phase 1+2 구현: Electron 뼈대 + STT/핫키/오케스트레이터
Phase 1: - 프로젝트 초기화 (TypeScript strict, electron-vite, ESLint, Prettier) - shared 타입 (ipc-channels 113채널, types, errors, constants) - 메인 프로세스 뼈대 (bootstrap, lifecycle, 단일 인스턴스) - LoggerService, ConfigService (electron-store ESM dynamic import) - React 19 + MUI 7 Dashboard, 시스템 트레이 Phase 2: - AudioCaptureService (node-record-lpcm16, PCM16 16kHz mono) - HotkeyService (uiohook-napi, 더블프레스, holdMode/toggleMode) - LocalSTTService (faster-whisper Python sidecar, 이중 조건 플러시) - VoiceModeService 오케스트레이터 (이중 상태머신, Action Queue) - Python sidecar (FastAPI: health/load/transcribe/shutdown) - IPC 핸들러 (voice, stt, hotkey) + Preload API 확장
This commit is contained in:
parent
e24bb8378c
commit
1d152d01a1
46 changed files with 10828 additions and 4 deletions
365
src/main/services/AudioCaptureService.ts
Normal file
365
src/main/services/AudioCaptureService.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
// src/main/services/AudioCaptureService.ts
|
||||
// 마이크 PCM 캡처 서비스. 설계서 01의 IAudioCaptureService 구현.
|
||||
// node-record-lpcm16 + SoX로 PCM16 16kHz mono 캡처.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { record } from 'node-record-lpcm16'
|
||||
import type { Recording } from 'node-record-lpcm16'
|
||||
import type { Readable } from 'stream'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet } from './ConfigService'
|
||||
import type { AudioDevice } from '@shared/types'
|
||||
import { AUDIO_FORMAT, TIMING } from '@shared/constants'
|
||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||
|
||||
const logger = getLogger('AudioCaptureService')
|
||||
|
||||
/** 60ms 프레임 크기 (바이트): 16000 * 2 * 0.06 = 1920 */
|
||||
const FRAME_SIZE_BYTES = (AUDIO_FORMAT.SAMPLE_RATE * AUDIO_FORMAT.BYTES_PER_SAMPLE * 60) / 1000
|
||||
|
||||
export type CaptureState = 'idle' | 'starting' | 'capturing' | 'stopping' | 'error'
|
||||
|
||||
interface AudioCaptureEvents {
|
||||
'audio-data': (payload: { buffer: Buffer; timestamp: number }) => void
|
||||
'audio-level': (payload: { level: number; timestamp: number }) => void
|
||||
'device-changed': (payload: {
|
||||
previous: AudioDevice | null
|
||||
current: AudioDevice
|
||||
}) => void
|
||||
started: (payload: { deviceId: string }) => void
|
||||
stopped: (payload: { reason: 'manual' | 'device-lost' | 'error' }) => void
|
||||
error: (payload: { error: D3ROError }) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* PCM16 버퍼에서 RMS(Root Mean Square) 오디오 레벨을 계산한다.
|
||||
* 반환값은 0.0 ~ 1.0 범위로 정규화된다.
|
||||
*/
|
||||
function calculateRMS(buffer: Buffer): number {
|
||||
const samples = buffer.length / AUDIO_FORMAT.BYTES_PER_SAMPLE
|
||||
if (samples === 0) return 0
|
||||
|
||||
let sumSquares = 0
|
||||
for (let i = 0; i < buffer.length; i += AUDIO_FORMAT.BYTES_PER_SAMPLE) {
|
||||
const sample = buffer.readInt16LE(i)
|
||||
sumSquares += sample * sample
|
||||
}
|
||||
|
||||
const rms = Math.sqrt(sumSquares / samples)
|
||||
// PCM16 최대값 32768로 나눠서 0.0~1.0 범위로 정규화
|
||||
return Math.min(1.0, rms / 32768)
|
||||
}
|
||||
|
||||
class AudioCaptureService extends EventEmitter {
|
||||
private _state: CaptureState = 'idle'
|
||||
private _currentDevice: AudioDevice | null = null
|
||||
private _refCount = 0
|
||||
private _recording: Recording | null = null
|
||||
private _stream: Readable | null = null
|
||||
private _levelInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 프레임 조립용 잔여 바이트 버퍼 */
|
||||
private _residualBuffer: Buffer = Buffer.alloc(0)
|
||||
|
||||
/** RMS 계산용 누적 버퍼 (100ms 간격 emit) */
|
||||
private _levelAccumulator: Buffer[] = []
|
||||
|
||||
get state(): CaptureState {
|
||||
return this._state
|
||||
}
|
||||
|
||||
get currentDevice(): AudioDevice | null {
|
||||
return this._currentDevice
|
||||
}
|
||||
|
||||
async start(deviceId?: string): Promise<void> {
|
||||
if (this._state === 'capturing') {
|
||||
this._refCount++
|
||||
logger.debug(`Reference count increased to ${this._refCount}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (this._state !== 'idle' && this._state !== 'error') {
|
||||
logger.warn(`Cannot start capture in state: ${this._state}`)
|
||||
return
|
||||
}
|
||||
|
||||
this._state = 'starting'
|
||||
const selectedDeviceId = deviceId ?? configGet('selectedDeviceId')
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`Starting audio capture (device: ${selectedDeviceId ?? 'default'}, ` +
|
||||
`format: ${AUDIO_FORMAT.SAMPLE_RATE}Hz ${AUDIO_FORMAT.CHANNELS}ch ${AUDIO_FORMAT.BIT_DEPTH}bit)`
|
||||
)
|
||||
|
||||
// node-record-lpcm16 으로 SoX rec 프로세스 spawn
|
||||
const recordingOptions: Record<string, unknown> = {
|
||||
sampleRate: AUDIO_FORMAT.SAMPLE_RATE,
|
||||
channels: AUDIO_FORMAT.CHANNELS,
|
||||
recorder: 'sox',
|
||||
audioType: 'raw', // 헤더 없는 PCM raw 출력
|
||||
endOnSilence: false
|
||||
}
|
||||
|
||||
// 특정 디바이스가 지정된 경우 AUDIODEV 환경변수로 전달
|
||||
if (selectedDeviceId && selectedDeviceId !== 'default') {
|
||||
recordingOptions.device = selectedDeviceId
|
||||
}
|
||||
|
||||
this._recording = record(recordingOptions)
|
||||
this._stream = this._recording.stream()
|
||||
this._residualBuffer = Buffer.alloc(0)
|
||||
this._levelAccumulator = []
|
||||
|
||||
// 스트림 데이터 수신: 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 프로세스 종료 감지
|
||||
this._stream.on('end', () => {
|
||||
if (this._state === 'capturing') {
|
||||
logger.warn('Audio stream ended unexpectedly')
|
||||
this._handleError(
|
||||
new D3ROError(ErrorCode.AudioCaptureFailed, 'Audio capture process terminated unexpectedly'),
|
||||
'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._currentDevice = {
|
||||
deviceId: selectedDeviceId ?? 'default',
|
||||
label: 'Default Microphone',
|
||||
isDefault: !selectedDeviceId || selectedDeviceId === 'default'
|
||||
}
|
||||
|
||||
this._state = 'capturing'
|
||||
this._refCount = 1
|
||||
|
||||
// 100ms 간격으로 audio-level 이벤트 emit
|
||||
this._levelInterval = setInterval(() => {
|
||||
this._emitAudioLevel()
|
||||
}, TIMING.AUDIO_LEVEL_INTERVAL)
|
||||
|
||||
this.emit('started', { deviceId: this._currentDevice.deviceId })
|
||||
logger.info('Audio capture started')
|
||||
} catch (error) {
|
||||
this._state = 'error'
|
||||
const d3roError = new D3ROError(
|
||||
ErrorCode.AudioCaptureStartFailed,
|
||||
`Failed to start audio capture: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
this.emit('error', { error: d3roError })
|
||||
throw d3roError
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this._state !== 'capturing') {
|
||||
return
|
||||
}
|
||||
|
||||
this._refCount--
|
||||
if (this._refCount > 0) {
|
||||
logger.debug(`Reference count decreased to ${this._refCount}`)
|
||||
return
|
||||
}
|
||||
|
||||
this._state = 'stopping'
|
||||
this._cleanup()
|
||||
this._state = 'idle'
|
||||
this._currentDevice = null
|
||||
this.emit('stopped', { reason: 'manual' })
|
||||
logger.info('Audio capture stopped')
|
||||
}
|
||||
|
||||
async getDevices(): Promise<AudioDevice[]> {
|
||||
// 현재 Phase에서는 기본 디바이스만 반환. 실제 디바이스 열거는 추후.
|
||||
logger.debug('Getting audio devices (default only)')
|
||||
return [
|
||||
{
|
||||
deviceId: 'default',
|
||||
label: 'Default Microphone',
|
||||
isDefault: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
getCurrentDevice(): AudioDevice | null {
|
||||
return this._currentDevice
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._cleanup()
|
||||
this._state = 'idle'
|
||||
this._currentDevice = null
|
||||
this._refCount = 0
|
||||
this.removeAllListeners()
|
||||
logger.info('AudioCaptureService disposed')
|
||||
}
|
||||
|
||||
/**
|
||||
* 수신된 오디오 청크를 60ms 프레임(1920 bytes) 단위로 분할하여 emit한다.
|
||||
* 잔여 바이트는 다음 청크와 결합한다.
|
||||
*/
|
||||
private _onAudioChunk(chunk: Buffer): void {
|
||||
// 잔여 버퍼와 새 청크를 결합
|
||||
const combined = this._residualBuffer.length > 0
|
||||
? Buffer.concat([this._residualBuffer, chunk])
|
||||
: chunk
|
||||
|
||||
let offset = 0
|
||||
|
||||
// 60ms 프레임 단위로 분할하여 emit
|
||||
while (offset + FRAME_SIZE_BYTES <= combined.length) {
|
||||
const frame = combined.subarray(offset, offset + FRAME_SIZE_BYTES)
|
||||
offset += FRAME_SIZE_BYTES
|
||||
|
||||
this.emit('audio-data', {
|
||||
buffer: Buffer.from(frame), // 방어적 복사
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// RMS 계산용 누적
|
||||
this._levelAccumulator.push(frame)
|
||||
}
|
||||
|
||||
// 남은 바이트는 잔여 버퍼에 보관
|
||||
if (offset < combined.length) {
|
||||
this._residualBuffer = Buffer.from(combined.subarray(offset))
|
||||
} else {
|
||||
this._residualBuffer = Buffer.alloc(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 100ms 간격으로 누적된 오디오 데이터의 RMS 레벨을 계산하여 emit한다.
|
||||
*/
|
||||
private _emitAudioLevel(): void {
|
||||
if (this._levelAccumulator.length === 0) {
|
||||
this.emit('audio-level', { level: 0, timestamp: Date.now() })
|
||||
return
|
||||
}
|
||||
|
||||
// 누적된 프레임들을 하나로 합쳐서 RMS 계산
|
||||
const combined = Buffer.concat(this._levelAccumulator)
|
||||
this._levelAccumulator = []
|
||||
|
||||
const level = calculateRMS(combined)
|
||||
this.emit('audio-level', { level, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 발생 시 리소스 정리 + 에러 이벤트 emit.
|
||||
*/
|
||||
private _handleError(error: D3ROError, stopReason: 'device-lost' | 'error'): void {
|
||||
if (this._state === 'idle' || this._state === 'stopping') {
|
||||
return
|
||||
}
|
||||
|
||||
this._cleanup()
|
||||
this._state = 'error'
|
||||
this._currentDevice = null
|
||||
this.emit('error', { error })
|
||||
this.emit('stopped', { reason: stopReason })
|
||||
}
|
||||
|
||||
/**
|
||||
* 녹음 프로세스와 타이머를 정리한다.
|
||||
*/
|
||||
private _cleanup(): void {
|
||||
if (this._levelInterval) {
|
||||
clearInterval(this._levelInterval)
|
||||
this._levelInterval = null
|
||||
}
|
||||
|
||||
if (this._stream) {
|
||||
this._stream.removeAllListeners()
|
||||
this._stream = null
|
||||
}
|
||||
|
||||
if (this._recording) {
|
||||
try {
|
||||
this._recording.stop()
|
||||
} catch {
|
||||
// 이미 종료된 프로세스 kill 시 에러 무시
|
||||
}
|
||||
this._recording = null
|
||||
}
|
||||
|
||||
this._residualBuffer = Buffer.alloc(0)
|
||||
this._levelAccumulator = []
|
||||
}
|
||||
|
||||
// EventEmitter 타입 오버라이드
|
||||
override on<K extends keyof AudioCaptureEvents>(
|
||||
event: K,
|
||||
listener: AudioCaptureEvents[K]
|
||||
): this {
|
||||
return super.on(event, listener)
|
||||
}
|
||||
|
||||
override off<K extends keyof AudioCaptureEvents>(
|
||||
event: K,
|
||||
listener: AudioCaptureEvents[K]
|
||||
): this {
|
||||
return super.off(event, listener)
|
||||
}
|
||||
|
||||
override emit<K extends keyof AudioCaptureEvents>(
|
||||
event: K,
|
||||
...args: Parameters<AudioCaptureEvents[K]>
|
||||
): boolean {
|
||||
return super.emit(event, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤
|
||||
let instance: AudioCaptureService | null = null
|
||||
|
||||
export function getAudioCaptureService(): AudioCaptureService {
|
||||
if (!instance) {
|
||||
instance = new AudioCaptureService()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue