fix(desktop): AudioCaptureService.getDevices Mac 분기 — system_profiler CoreAudio 열거 (U7, 빅뱅 Phase 5 Part 6)

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 플랫폼 감사 누락 항목 해소.
This commit is contained in:
윤찬 2026-04-11 23:18:18 +09:00
parent 412a2e71f9
commit dd591d06e7
2 changed files with 118 additions and 28 deletions

View file

@ -222,7 +222,7 @@ class AudioCaptureService extends EventEmitter {
}
async getDevices(): Promise<AudioDevice[]> {
// 캐싱: 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<AudioDevice[]> {
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<AudioDevice[]> {
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
}

View file

@ -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 분기를 실제로 경험할 수 있는 상태.