Windows 시스템 오디오 실시간 번역 — 가상 케이블 없는 드라이버리스 방식
요약
Windows 시스템 오디오를 가상 케이블 없이 실시간으로 캡처하여 번역하는 오픈 소스 앱 Voxis의 개발 과정을 다룹니다. WASAPI의 루프백 인터페이스 활성화 방법과 COM 객체 구현 시 주의사항을 설명합니다.
핵심 포인트
- WASAPI 루프백을 통해 가상 오디오 케이블 없이 시스템 사운드 캡처 가능
- ActivateAudioInterfaceAsync 사용 시 IAgileObject 인터페이스 구현 필수
- 번역 모델에 최적화된 WAVEFORMATEX 포맷을 직접 요청하여 리샘플링 방지
- 실시간성을 위해 캡처와 처리 스레드를 분리하고 링 버퍼 오버플로 방지
저는 시스템에서 재생되는 모든 것(비디오, 게임, 통화 상대방의 목소리 등)을 번역하여, 원래 화자의 목소리보다 몇 초 뒤에 음성으로 다시 들려주는 오픈 소스 Windows 앱인 Voxis를 만들었습니다. 자막도, 가상 오디오 케이블 (virtual audio cable)도, 회의에 참여하는 봇도 필요하지 않습니다.
이 클라이언트는 일반적인 IMMDeviceEnumerator 경로를 통해 가져올 수 없습니다. BLOB을 담은 PROPVARIANT에 루프백 (loopback) 파라미터를 전달하여 ActivateAudioInterfaceAsync를 통해 이름으로 활성화합니다.
params = AUDIOCLIENT_ACTIVATION_PARAMS()
params.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK
params.u.ProcessLoopbackParams.TargetProcessId = my_pid
...
장치 이름은 VAD\Process_Loopback이라는 마법의 문자열입니다. 활성화는 비동기적 (asynchronous)으로 이루어집니다. ActivateAudioInterfaceAsync에 완료 핸들러 (completion handler)를 전달하고 그것이 실행될 때까지 기다려야 합니다.
IAgileObject 함정
제게 오후 시간을 통째로 날리게 만든 부분입니다. 완료 핸들러는 직접 구현해야 하는 COM 객체입니다 (Python의 경우 comtypes.COMObject를 통해 구현). 만약 IActivateAudioInterfaceCompletionHandler만 구현되어 있다면, ActivateAudioInterfaceAsync는 E_ILLEGAL_METHOD_CALL을 반환하며 그 이유를 알려주지 않습니다.
해결책: 핸들러는 반드시 IAgileObject도 함께 구현해야 합니다. 이는 메서드는 없지만 해당 객체가 아파트 (apartment)에 구애받지 않음을 선언하는 마커 인터페이스 (marker interface)입니다. 이를 COM 인터페이스 목록에 추가하면 활성화에 성공합니다.
class _Handler(COMObject):
_com_interfaces_ = [IActivateAudioInterfaceCompletionHandler, IAgileObject]
IAgileObject는 비어 있는 메서드 목록을 가지고 있습니다. 이는 순수하게 "어떤 아파트에서도 나를 호출할 수 있다"는 약속입니다. WASAPI는 이것 없이는 진행을 거부합니다.
실제로 원하는 포맷 요청하기
또 다른 장점은 WASAPI를 통해 원하는 정확한 WAVEFORMATEX로 루프백 클라이언트를 Initialize할 수 있다는 점입니다. 저는 16 kHz, 모노 (mono), 16-bit PCM을 직접 요청합니다. 이는 번역 모델이 입력값으로 원하는 것과 정확히 일치하므로, 핫 패스 (hot path)에서 리샘플링 (resampling) 단계가 없습니다.
wfx.nChannels = 1
wfx.nSamplesPerSec = 16000
wfx.wBitsPerSample = 16
...
여기서 2_000_000은 100-ns 단위로 계산된 200 ms 버퍼입니다.
캡처를 실시간 안전하게 유지하기
루프백 캡처 루프(loopback capture loop)에는 절대 놓쳐서는 안 될 단 하나의 임무가 있습니다. 바로 GetBuffer를 호출하고, 바이트를 복사한 뒤, ReleaseBuffer를 호출하는 것입니다. 만약 다운스트림(downstream)의 무언가가 느려져서 ReleaseBuffer 호출이 지연되면, 링 버퍼(ring buffer)가 오버플로(overflow)되어 글리치(glitch)가 발생합니다.
따라서 캡처와 처리는 두 개의 스레드로 분리되며, 그 사이에는 유한 큐(bounded queue)가 존재합니다.
- 캡처 스레드 (Capture thread):
GetNextPacketSize→GetBuffer→ numpy 배열로 복사 →ReleaseBuffer→ deque에 추가. 이것이 스레드가 하는 전부입니다. VAD(음성 활동 감지)나 네트워크 코드를 절대 실행하지 않습니다. - 프로세서 스레드 (Processor thread): deque의 데이터를 소진하며 (때때로 느려질 수 있는) 청크별 콜백(per-chunk callback)을 실행합니다. 즉, VAD 게이팅(gating)을 수행한 후 번역기로 전달합니다.
이 큐는 collections.deque(maxlen=N) 형태이며, **구조적으로 가장 오래된 데이터를 삭제(drop-oldest)**합니다. 프로세서가 뒤처지면, 캡처 스레드를 블로킹(blocking)하는 대신 지연 시간(latency)을 제한하기 위해 오래된 오디오를 버립니다. 따라서 컨슈머(consumer) 측의 GC(Garbage Collection) 일시 중단이나 VAD 정체로 인해 ReleaseBuffer가 지연되는 일은 결코 발생할 수 없습니다. 이것은 캡처 경로에서 가장 중요한 설계 결정이며, 단 세 줄의 코드로 구현됩니다.
self._queue = collections.deque(maxlen=64) # 유한 큐; 버퍼 한 개 분량의 패킷
# 캡처 스레드:
self._queue.append(x) # 절대 블로킹되지 않음; 부하가 걸리면 가장 오래된 데이터가 폐기됨
오디오를 건드리지 않고 덕킹(Ducking)하기
번역된 음성이 나올 때, 두 목소리가 충돌하지 않도록 원본 음성을 더 작게 만들고 싶을 것입니다. 유혹적인 접근 방식은 믹싱(mixing)입니다. 즉, 오디오를 캡처하고, 감쇠(attenuate)시킨 뒤, 직접 재생하는 것이죠. 하지만 그렇게 하면 시스템의 모든 앱에 대해 재생, 지연 시간, 장치 라우팅(device routing)을 직접 제어해야 합니다.
대신, Voxis는 Windows의 세션 볼륨 API (session-volume API) (pycaw를 통한 ISimpleAudioVolume)를 사용하여 소스(source) 단계에서 덕킹(ducking)을 수행합니다. 즉, 파이프라인 내의 바이트를 조절하는 것이 아니라, 재생 중인 앱의 오디오 세션 볼륨을 낮추는 것입니다. 원본 오디오는 볼륨 레벨을 제외하고는 아무런 영향을 받지 않은 채 자신의 경로를 통해 계속 재생되며, 번역이 끝나면 다시 볼륨이 올라갑니다. 믹싱도 필요 없고, 원본에 추가되는 지연 시간도 없으며, 라우팅할 것도 없습니다.
(가상 케이블을 설치하는 사람들을 위한 두 번째 캡처 경로가 있습니다. 이 경우 Voxis는 스테레오 음악은 보존하면서 대화(dialogue)를 줄이는 실시간 M/S 센터-억제(M/S center-suppression) 기능을 수행할 수 있지만, 이는 선택적 기능이며 위에서 설명한 드라이버리스 방식이 기본값입니다.)
제가 제어하지 못하는 지연 시간 — 그리고 제가 통제하는 비트
사람들은 항상 왜 즉각적이지 않은지 묻습니다. 솔직하게 두 문장으로 말씀드리겠습니다:
번역 모델은 **네이티브 동시통역기(native simultaneous interpreter)**입니다. 연속적인 스트림을 공급받아 화자가 말하는 동안 번역하며, 음질과 싱크를 스스로 균형 있게 유지하고 몇 초 정도의 지연 시간을 갖습니다. 이 귀-목소리 간격(ear-voice span)은 설계상 그렇습니다 (구절 전체를 정확하게 번역하기 위해 충분한 문맥을 기다립니다). 그리고 이는 클라이언트가 조절할 수 있는 다이얼이 아닙니다.
그것은 주장하기는 쉽지만 실수로 깨뜨리기도 쉽습니다. 따라서 퍼블릭 리포지토리(public-repo) 경계는 CI(지속적 통합)에 연결된 릴리스 위생(release-hygiene) 스크립트와 pre-push hook에 의해 관리됩니다. 이 시스템은 모든 폐쇄형 코어(closed-core) 경로, 모든 라이브 시크릿(live-secret) 서명, 그리고 폐쇄형 패키지의 보호되지 않은 임포트(import)를 거부합니다. 깨끗한 실행 결과는 릴리스의 전제 조건입니다. 이러한 분리는 README에 적힌 약속이 아니라, 빌드 과정이 직접 증명하는 속성입니다.
아직 지원하지 않는 기능
- Windows 전용. 전체 캡처(capture) 프로세스는 Windows 전용인 WASAPI 기능입니다. 다른 플랫폼은 완전히 다른 캡처 전략이 필요합니다.
- Gemini 의존적. 이 시스템은 특정 제공업체의 실시간 번역 모델을 기반으로 구축되었습니다. 해당 모델이 변경되면 Voxis도 함께 변경됩니다.
- 회의 송신(outgoing)을 위해서는 가상 마이크가 필요함. 번역된 사용자의 목소리를 회의에 전송하려면 회의 앱이 선택할 수 있는 마이크를 제공해야 하며, Windows에서는 가상 오디오 드라이버만이 이를 수행할 수 있습니다. 수신(incoming) 번역은 아무것도 필요하지 않지만, 송신은 케이블 없이 듣기 전용(listen-only)으로 전환됩니다.
시도해보기 / 읽어보기
엔진, 루프백(loopback) 코드, 그리고 CI 경계는 모두 리포지토리에 있습니다: https://github.com/DavutAkca/voxislive (PolyForm Noncommercial).
만약 관리형/스크립트 언어(managed/scripted language)에서 WASAPI 루프백을 구현해 본 경험이 있다면, 활성화 핸들러(activation handler)와 애자일 객체(agile-object) 요구 사항에 대해 진심으로 의견을 나누고 싶습니다. 댓글을 남겨주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기