From dd591d06e79a779414ccd210b14c19fa76c8d841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sat, 11 Apr 2026 23:18:18 +0900 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20AudioCaptureService.getDevices?= =?UTF-8?q?=20Mac=20=EB=B6=84=EA=B8=B0=20=E2=80=94=20system=5Fprofiler=20C?= =?UTF-8?q?oreAudio=20=EC=97=B4=EA=B1=B0=20(U7,=20=EB=B9=85=EB=B1=85=20Pha?= =?UTF-8?q?se=205=20Part=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDevices()가 powershell -NoProfile을 플랫폼 불문 호출해 Mac에서 "Failed to enumerate audio devices via PowerShell" 경고 + 1 device fallback이 발생하던 문제. process.platform 분기로 _getDevicesWindows / _getDevicesMac helper로 분리하고, Mac은 system_profiler SPAudioDataType -json 을 파싱해 coreaudio_device_input > 0 인 항목만 input 디바이스로 추출. macOS SoX 빌드는 실제 캡처 경로에서 -d(default) 고정이므로 deviceId는 'default'로 통일하고 label만 사용자 구분용으로 노출한다. Linux는 default 1개만 반환(pulseaudio/alsa 열거는 sox -d 로 충분). Electron 재기동 후 로그: "Found 2 audio device(s) on darwin" 확인. V2-5 플랫폼 감사 누락 항목 해소. --- .../src/main/services/AudioCaptureService.ts | 132 ++++++++++++++---- memory/project_status.md | 14 ++ 2 files changed, 118 insertions(+), 28 deletions(-) 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 분기를 실제로 경험할 수 있는 상태.