packages/core (@d3ro/core) 신규 생성:
- types.ts, errors.ts, ipc-channels.ts, constants.ts (shared에서 이동)
- utils/meeting-markdown.ts, utils/markdown-to-docx.ts (main/utils에서 이동)
- subpath exports 정의 (./types, ./errors, ./ipc-channels, ./constants,
./utils/meeting-markdown, ./utils/markdown-to-docx)
- src/index.ts barrel export 추가
- docx를 core 자체 dependency로 선언
apps/desktop 연결:
- package.json에 @d3ro/core: '*' dep 추가
- tsconfig.node/web.json paths에 @d3ro/core/* 추가
- electron.vite.config.ts 3개 섹션 alias 추가 (main/preload/renderer)
- externalizeDepsPlugin exclude에 @d3ro/core (workspace 소스 번들 대상)
- vitest.config.ts alias 추가
일괄 치환 (79 파일, 167건):
- @shared/{types,errors,ipc-channels,constants} → @d3ro/core/*
- static/dynamic import + type expression import 모두 포함
- MeetingModeService.ts의 ../utils/* 상대 경로 → @d3ro/core/utils/*
- @shared/theme-vars는 V2-1c 범위로 남김 (WindowManager만 사용)
M1 수정 포함:
- electron.vite.config.ts의 resolve('src/shared') → resolve(__dirname, ...)
CWD 독립적으로 동작하도록 견고화
검증:
- typecheck 통과
- build 통과 (main+preload+renderer)
- dev 런타임 → DB/핫키/Ollama 모두 정상, 기존 데이터 연속성 유지
463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
// src/main/services/AudioCaptureService.ts
|
|
// 마이크 PCM 캡처 서비스. 설계서 01의 IAudioCaptureService 구현.
|
|
// Windows: SoX 직접 spawn (-t waveaudio). 기타: node-record-lpcm16.
|
|
|
|
import { EventEmitter } from 'events'
|
|
import path from 'path'
|
|
import { existsSync } from 'fs'
|
|
import { spawn, type ChildProcess } from 'child_process'
|
|
import type { Readable } from 'stream'
|
|
import { getLogger } from './LoggerService'
|
|
import { configGet } from './ConfigService'
|
|
import { getSoxPath } from '../utils/paths'
|
|
import type { AudioDevice } from '@d3ro/core/types'
|
|
import { AUDIO_FORMAT, TIMING } from '@d3ro/core/constants'
|
|
import { D3ROError, ErrorCode } from '@d3ro/core/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 범위로 정규화된다.
|
|
*/
|
|
export 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 _cachedDevices: AudioDevice[] | null = null
|
|
private _soxProcess: ChildProcess | 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)`
|
|
)
|
|
|
|
// SoX 직접 spawn (Windows: -t waveaudio 필수)
|
|
const soxExe = getSoxPath()
|
|
logger.info(`Using SoX: ${soxExe}`)
|
|
|
|
if (!existsSync(soxExe) && soxExe !== 'sox') {
|
|
throw new D3ROError(
|
|
ErrorCode.AudioCaptureStartFailed,
|
|
`SoX 실행 파일을 찾을 수 없습니다: ${soxExe}`
|
|
)
|
|
}
|
|
|
|
const deviceArg = selectedDeviceId && selectedDeviceId !== 'default'
|
|
? selectedDeviceId
|
|
: 'default'
|
|
|
|
// 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)
|
|
})
|
|
|
|
// SoX stderr → 로그
|
|
this._soxProcess.stderr?.on('data', (chunk: Buffer) => {
|
|
const msg = chunk.toString().trim()
|
|
if (msg) logger.warn(`SoX stderr: ${msg}`)
|
|
})
|
|
|
|
// SoX 프로세스 종료 감지
|
|
this._soxProcess.on('close', (code) => {
|
|
if (this._state === 'capturing') {
|
|
logger.warn(`SoX process exited unexpectedly (code: ${code})`)
|
|
this._handleError(
|
|
new D3ROError(ErrorCode.AudioCaptureFailed, `SoX process exited with code ${code}`),
|
|
'device-lost'
|
|
)
|
|
}
|
|
})
|
|
|
|
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',
|
|
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[]> {
|
|
// 캐싱: PowerShell 호출이 느리므로 한번만 실행
|
|
if (this._cachedDevices) return this._cachedDevices
|
|
|
|
const devices: AudioDevice[] = [
|
|
{ deviceId: 'default', label: '시스템 기본 마이크', isDefault: true },
|
|
]
|
|
|
|
try {
|
|
// PowerShell로 Windows 오디오 입력(마이크) 엔드포인트 열거
|
|
// Get-PnpDevice -Class AudioEndpoint: 실제 오디오 엔드포인트 (마이크/스피커)
|
|
// MediaCategory가 'Capture' 또는 FriendlyName에 마이크 관련 키워드 포함
|
|
const { execSync } = await import('child_process')
|
|
const psCommand = `[Console]::OutputEncoding = [Text.Encoding]::UTF8; Get-PnpDevice -Class AudioEndpoint -Status OK | Select-Object InstanceId, FriendlyName | ConvertTo-Json -Compress`
|
|
const output = execSync(`powershell -NoProfile -Command "${psCommand}"`, {
|
|
encoding: 'utf8',
|
|
timeout: 5000,
|
|
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
}).trim()
|
|
|
|
if (output) {
|
|
const parsed: unknown = JSON.parse(output.startsWith('[') ? output : `[${output}]`)
|
|
if (Array.isArray(parsed)) {
|
|
for (const dev of parsed) {
|
|
const d = dev as { InstanceId?: string; FriendlyName?: string }
|
|
if (d.InstanceId && d.FriendlyName) {
|
|
devices.push({
|
|
deviceId: d.FriendlyName, // SoX -t waveaudio는 이름으로 매칭
|
|
label: d.FriendlyName,
|
|
isDefault: false,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logger.warn(`Failed to enumerate audio devices via PowerShell: ${err instanceof Error ? err.message : String(err)}`)
|
|
}
|
|
|
|
this._cachedDevices = devices
|
|
logger.debug(`Found ${devices.length} audio device(s)`)
|
|
return devices
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
/**
|
|
* 지정 시간(ms) 동안 마이크 캡처 후 평균/피크 레벨을 반환한다.
|
|
*/
|
|
async testCapture(durationMs: number): Promise<{ averageLevel: number; peakLevel: number; hasAudio: boolean }> {
|
|
const soxExe = getSoxPath()
|
|
const selectedDeviceId = configGet('selectedDeviceId')
|
|
const deviceArg = selectedDeviceId && selectedDeviceId !== 'default'
|
|
? selectedDeviceId
|
|
: 'default'
|
|
|
|
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', '-']
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const proc = spawn(soxExe, soxArgs, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
const buffers: Buffer[] = []
|
|
let peakRms = 0
|
|
|
|
proc.stdout?.on('data', (chunk: Buffer) => {
|
|
buffers.push(chunk)
|
|
const rms = calculateRMS(chunk)
|
|
if (rms > peakRms) peakRms = rms
|
|
})
|
|
|
|
proc.on('error', (err) => {
|
|
reject(new D3ROError(ErrorCode.AudioCaptureStartFailed, `SoX 실행 실패: ${err.message}`))
|
|
})
|
|
|
|
setTimeout(() => {
|
|
proc.kill()
|
|
}, durationMs)
|
|
|
|
proc.on('close', () => {
|
|
if (buffers.length === 0) {
|
|
resolve({ averageLevel: 0, peakLevel: 0, hasAudio: false })
|
|
return
|
|
}
|
|
const combined = Buffer.concat(buffers)
|
|
const avgRms = calculateRMS(combined)
|
|
const avgLevel = avgRms > 0 ? Math.min(1.0, Math.pow(avgRms, 0.28)) : 0
|
|
const peakLevel = peakRms > 0 ? Math.min(1.0, Math.pow(peakRms, 0.28)) : 0
|
|
resolve({
|
|
averageLevel: avgLevel,
|
|
peakLevel: peakLevel,
|
|
hasAudio: avgRms > 0.001,
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 수신된 오디오 청크를 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 rawLevel = calculateRMS(combined)
|
|
// 로그 스케일: 작은 소리도 크게, 큰 소리는 압축 (DAW 미터 방식)
|
|
// pow(x, 0.28) → 0.001→0.04, 0.01→0.14, 0.05→0.35, 0.1→0.52, 0.3→0.80
|
|
const level = rawLevel > 0 ? Math.min(1.0, Math.pow(rawLevel, 0.28)) : 0
|
|
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._soxProcess) {
|
|
try {
|
|
this._soxProcess.kill()
|
|
} catch {
|
|
// 이미 종료된 프로세스 kill 시 에러 무시
|
|
}
|
|
this._soxProcess = 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
|
|
}
|