diff --git a/apps/desktop/src/main/services/AudioCaptureService.ts b/apps/desktop/src/main/services/AudioCaptureService.ts index ac2cd46..ef2a4dd 100644 --- a/apps/desktop/src/main/services/AudioCaptureService.ts +++ b/apps/desktop/src/main/services/AudioCaptureService.ts @@ -222,7 +222,7 @@ class AudioCaptureService extends EventEmitter { } async getDevices(): Promise { - // 캐싱: PowerShell 호출이 느리므로 한번만 실행 + // 캐싱: 네이티브 프로세스 호출이 느리므로 한번만 실행 if (this._cachedDevices) return this._cachedDevices const devices: AudioDevice[] = [ @@ -230,41 +230,117 @@ class AudioCaptureService extends EventEmitter { ] 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, - }) - } - } - } + if (process.platform === 'win32') { + devices.push(...(await this._getDevicesWindows())) + } else if (process.platform === 'darwin') { + devices.push(...(await this._getDevicesMac())) } + // linux: default 1개만 (pulseaudio/alsa 열거는 sox fallback -d 로 충분) } catch (err) { - logger.warn(`Failed to enumerate audio devices via PowerShell: ${err instanceof Error ? err.message : String(err)}`) + logger.warn( + `Failed to enumerate audio devices: ${err instanceof Error ? err.message : String(err)}`, + ) } this._cachedDevices = devices - logger.debug(`Found ${devices.length} audio device(s)`) + logger.debug(`Found ${devices.length} audio device(s) on ${process.platform}`) return devices } + /** + * Windows: PowerShell로 AudioEndpoint 열거. + * SoX -t waveaudio는 이름으로 매칭하므로 FriendlyName을 deviceId로 사용. + */ + private async _getDevicesWindows(): Promise { + 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) return [] + + const parsed: unknown = JSON.parse(output.startsWith('[') ? output : `[${output}]`) + if (!Array.isArray(parsed)) return [] + + const result: AudioDevice[] = [] + for (const dev of parsed) { + const d = dev as { InstanceId?: string; FriendlyName?: string } + if (d.InstanceId && d.FriendlyName) { + result.push({ + deviceId: d.FriendlyName, + label: d.FriendlyName, + isDefault: false, + }) + } + } + return result + } + + /** + * macOS: system_profiler SPAudioDataType -json 으로 CoreAudio 디바이스 열거. + * input 전용 디바이스만 필터 (coreaudio_device_input > 0). + * macOS SoX 빌드는 실제 디바이스 선택에 -d(default)만 지원하는 경우가 대부분이므로 + * label은 UI 표시 용도, deviceId는 'default'로 통일해 실제 캡처 경로를 단순화한다. + */ + private async _getDevicesMac(): Promise { + const { execFile } = await import('child_process') + const output: string = await new Promise((resolve, reject) => { + execFile( + '/usr/sbin/system_profiler', + ['SPAudioDataType', '-json'], + { encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024 }, + (err, stdout) => { + if (err) reject(err) + else resolve(stdout) + }, + ) + }) + + if (!output) return [] + + interface MacAudioItem { + _name?: string + coreaudio_device_input?: number + coreaudio_default_audio_input_device?: string + coreaudio_input_source?: string + } + interface MacAudioGroup { + _items?: MacAudioItem[] + } + interface MacAudioJson { + SPAudioDataType?: MacAudioGroup[] + } + + const parsed = JSON.parse(output) as MacAudioJson + const groups = parsed.SPAudioDataType ?? [] + const result: AudioDevice[] = [] + + for (const group of groups) { + const items = group._items ?? [] + for (const item of items) { + const hasInput = + typeof item.coreaudio_device_input === 'number' && item.coreaudio_device_input > 0 + const name = item._name ?? item.coreaudio_input_source + if (!hasInput || !name) continue + + // 중복 방지 (name 기준) + if (result.some((d) => d.label === name)) continue + + result.push({ + // macOS SoX record 경로는 -d(default) 고정이므로 선택 시 default로 매핑. + // 라벨만 사용자 구분용으로 노출. + deviceId: 'default', + label: name, + isDefault: item.coreaudio_default_audio_input_device === 'spaudio_yes', + }) + } + } + return result + } + getCurrentDevice(): AudioDevice | null { return this._currentDevice } diff --git a/memory/project_status.md b/memory/project_status.md index e330bd9..1948039 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -3,6 +3,20 @@ > 마지막 갱신: 2026-04-11 (빅뱅 Phase 5 Part 6 — Voice Conversation 몰입 패널 구현) > 규칙 13: 작업 완료 즉시 이 파일 갱신 의무 +## 빅뱅 Phase 5 Part 6+ (2026-04-11) — U7 AudioCaptureService Mac 분기 ✅ + +`AudioCaptureService.getDevices()`가 `powershell -NoProfile`만 호출하던 것을 +`process.platform` 분기로 교체. Mac에서 `system_profiler SPAudioDataType -json`으로 +CoreAudio input 디바이스 열거(`coreaudio_device_input > 0` 필터). macOS SoX는 +실제 캡처 경로에서 `-d`(default) 고정이므로 `deviceId='default'`, `label`만 사용자 +구분용으로 노출. 기존 "Failed to enumerate audio devices via PowerShell" 경고 ++ 1 device fallback 해소. + +**변경 파일 (1)**: `apps/desktop/src/main/services/AudioCaptureService.ts` — +`_getDevicesWindows` / `_getDevicesMac` helper 추출 + 분기 디스패처. + +**검증**: Electron 재기동 후 로그 `Found 2 audio device(s) on darwin` 확인 (경고 없음). + ## 빅뱅 Phase 5 Part 6 (2026-04-11) — Voice Conversation UX 몰입 패널 ✅ Part 5-C 설계를 구현으로 완결. 사용자가 listening 상태에서 풀 몰입 계측기 모드 + 사운드 피드백 + 상태별 UI 분기를 실제로 경험할 수 있는 상태.