AI에게 Teams 회의 참석을 가르치기: 실시간 음성 파이프라인
Microsoft Teams에는 웨이크 워드로 회의 중간에 어시스턴트를 소환할 수 있는 진정한 AI 음성 통합 기능이 없습니다. 우리가 만들었습니다. 이것이 GPU 서버에서 STT를 실행하고, 가상 PulseAudio 장치를 통해 오디오를 라우팅하며, 웨이크 워드로 트리거되는 AI 에이전트를 실시간 회의에 연결하는 실시간 음성 파이프라인 뒤의 아키텍처입니다.
Microsoft Teams에는 이제 많은 AI 기능이 있습니다. Copilot은 회의 후 요약할 수 있습니다. 실행 항목을 생성할 수 있습니다. 전사할 수 있습니다. 최소한 우리가 작동하는 방식으로는 할 수 없는 것은 웨이크 워드로 회의 중간에 AI 에이전트를 소환하고 실제 대화를 나누는 것입니다. AI가 모든 사람의 말을 듣고, 말을 하고, 전체 통화의 문맥을 기억하는 것입니다.
우리는 그것을 원했습니다. 그래서 우리가 만들었습니다.
그 결과는 Claude 기반 AI 에이전트를 실시간 Teams 회의에 통합하는 오픈 소스 음성 파이프라인입니다. 웨이크 워드를 말하면, AI가 활성화되고, 문맥에서 당신의 질문을 듣고, 자연 음성으로 응답합니다 — 모두 실시간으로, 자신의 음성을 입력으로 혼동하지 않도록 에코 제거가 있습니다.
GitHub: teams-meeting-agent-public
Teams API를 사용하지 않는 이유는?
Teams에는 calling API가 있습니다. 회의에 봇을 추가할 수 있습니다. 하지만 회의 봇 API는 구조화된 통합을 위해 설계되었습니다 — 전사 서비스, 녹음 봇, 회의 노트 작성기. 노출하는 오디오 파이프라인은 모든 참가자를 듣고, 발표자 식별을 수행하고, 2초 이내에 응답하고, 중단을 처리해야 하는 낮은 지연 시간 대화형 에이전트에는 적합하지 않습니다.
우리는 또한 특정 제약이 있습니다: Teams 클라이언트가 실행되는 Mac과 추론을 실행하는 Linux GPU 서버를 통해 작동합니다. GPU 서버는 음성 인식을 원하는 곳입니다 — CUDA에서 faster-whisper를 실행하는 것은 Mac에서 실시간으로 수행할 수 있는 모든 것보다 상당히 빠르고 더 정확합니다. 즉, SSH 터널을 통해 오디오가 흐르는 두 머신 간의 브리지가 필요합니다.
최소 저항 경로는 다음과 같습니다: Linux 쪽의 PulseAudio 가상 장치를 사용하여 Teams 오디오를 가로챈 다음 모든 처리를 거기서 수행하고, 모든 것을 조정하기 위한 사용자 정의 WebSocket 릴레이를 구축합니다.
아키텍처 개요
Mac (Teams client) Linux GPU Server
┌─────────────────────┐ ┌──────────────────────────────────┐
│ │ │ │
│ Microsoft Teams │◄──────────────│ teams_speaker (null-sink) │
│ (speaker output) │ │ teams_virtual_mic (null-sink) │
│ (mic input) │ │ teams_mic_input (virtual-source)│
│ │ │ │
│ bridge.py │◄─WebSocket────│ ws_relay.py (port 8765) │
│ (wake word, │───speak cmd──►│ │
│ transcript buf) │ │ stt_pipeline.py │
│ │ │ (faster-whisper + VAD) │
│ OpenClaw Agent │ │ │
│ (Claude + memory) │ │ tts_pipeline.py │
│ │ │ (Edge-TTS → PulseAudio) │
└─────────────────────┘ │ │
│ │ speaker_id.py │
└────────SSH tunnel──────────│ (ECAPA-TDNN voiceprints) │
(port 8765) └──────────────────────────────────┘
단일 교환의 흐름:
- Teams 오디오가
teams_speaker(PulseAudio null-sink)를 통해 재생됩니다 - STT 파이프라인이
.monitor스트림을 캡처하고 VAD + speaker ID + whisper를 실행합니다 - 전사가 WebSocket을 통해 Mac 브리지로 전송됩니다
- 브리지가 웨이크 워드를 감지합니다 → 문맥을 버퍼링합니다 → HTTP를 통해 OpenClaw 에이전트로 전송합니다
- 에이전트가 응답을 생성합니다 → 브리지가 WebSocket을 통해
speak명령을 다시 전송합니다 - TTS 파이프라인이 Edge-TTS로 합성합니다 →
teams_virtual_mic으로 스트리밍합니다 - Teams는
teams_mic_input을 통해 AI가 말하는 것을 듣습니다
에이전트 LLM 호출을 제외한 모든 것이 로컬에서 실행됩니다. STT는 온디바이스 CUDA이고, TTS는 첫 번째 Edge-TTS 청크 이후 200ms 내에 스트리밍되며, 웨이크 워드에서 첫 번째 말이 나오기까지의 전체 왕복은 일반적으로 2초 미만입니다.
PulseAudio 트릭
전체 시스템은 대부분의 사람들이 본 적이 없는 PulseAudio 설정에 달려 있습니다. Linux 서버에서 우리는 세 개의 가상 오디오 장치를 만듭니다:
pactl load-module module-null-sink \
sink_name=teams_speaker \
sink_properties=device.description=Teams_Speaker
pactl load-module module-null-sink \
sink_name=teams_virtual_mic \
sink_properties=device.description=Teams_Virtual_Mic
pactl load-module module-virtual-source \
source_name=teams_mic_input \
master=teams_virtual_mic.monitor \
source_properties=device.description=Teams_Mic_Input
teams_speaker는 null-sink입니다 — 오디오가 들어가서 아무것도 재생되지 않습니다. 하지만 PulseAudio의 null-sink는 자동으로 .monitor 소스를 만들어 오디오를 읽을 수 있는 스트림으로 노출합니다. Teams의 스피커 출력을 Teams_Speaker로 설정함으로써, 우리는 teams_speaker.monitor를 얻습니다 — Teams가 재생 중인 모든 것의 실시간 PCM 스트림, 모든 회의 참가자 포함. STT 파이프라인은 여기서 읽습니다.
teams_virtual_mic과 teams_mic_input는 반대 방향으로 같은 방식으로 작동합니다. TTS 파이프라인은 합성된 음성을 teams_virtual_mic에 씁니다. 모니터가 이를 읽을 수 있는 소스(teams_mic_input)로 노출합니다. 이를 Teams의 마이크 입력으로 설정합니다. 따라서 AI가 “말”할 때, Teams는 이를 마이크 신호로 듣습니다.
이것은 Teams에 완전히 투명합니다. Teams는 가상 장치와 통신하고 있다는 것을 모릅니다. API 접근 불필요. 봇 등록 불필요. Linux 서버는 그저 매우 똑똑한 마이크를 가진 회의 참가자로 보입니다.
하나의 복잡성이 있습니다: 가상 장치는 시스템 PulseAudio가 아닌 Chrome Remote Desktop(CRD) PulseAudio 세션 내에서 생성되어야 합니다. CRD는 비표준 소켓 경로를 가진 자체 격리된 PulseAudio 데몬을 실행합니다. 시작 스크립트는 이것을 자동으로 감지합니다:
PULSE_PATH=$(ssh "${SSH_ALIAS}" \
"cat /proc/\$(pgrep -u \$USER pulseaudio | tail -1)/environ 2>/dev/null \
| tr '\0' '\n' | grep PULSE_RUNTIME_PATH | cut -d= -f2")
실행 중인 PulseAudio 프로세스의 환경을 읽어 소켓 경로를 찾은 다음 모든 pactl 호출 전에 PULSE_SERVER=unix:${PULSE_PATH}/native를 내보냅니다. 나머지는 모두 작동합니다.
STT 파이프라인: faster-whisper + VAD + 에코 제거
STT 파이프라인은 대부분의 흥미로운 신호 처리가 일어나는 곳입니다. Linux 서버에서 실행되며 네 가지 책임이 있습니다: 오디오 캡처, 음성 경계 감지, 전사, 그리고 자신의 음성 억제입니다.
오디오 캡처는 PulseAudio의 parec을 사용하여 teams_speaker.monitor에서 16kHz 모노(faster-whisper가 예상하는 형식)로 원본 PCM을 스트리밍합니다. 오디오는 연속 30ms 청크로 들어옵니다.
음성 활동 감지는 Silero VAD를 사용합니다. 원본 오디오 스트림은 512-샘플 프레임으로 청킹되고 모델에 공급됩니다. VAD는 충분히 빠르게 실행되어 눈에 띄는 지연을 추가하지 않습니다. 음성 세그먼트는 침묵 간격이 전사를 트리거할 때까지 축적됩니다.
전사는 CUDA에서 distil-large-v3을 사용하는 faster-whisper를 사용합니다. Distil-large-v3은 Whisper large-v3의 증류 버전입니다 — 비슷한 정확도, 대략 5배 빠릅니다. 중국어-영어 혼합 회의(우리의 주요 사용 사례)의 경우, 언어 힌트를 필요로 하지 않고 코드 전환을 처리합니다.
에코 제거는 가장 많은 조정이 필요했던 부분입니다. 그 없이, AI는 자신의 TTS 출력을 전사하여 자신이 말하는 것을 듣고 응답하려고 시도하는 피드백 루프를 만듭니다. 솔루션은 TTS와 STT 파이프라인 간의 조정을 수행하는 SpeakingState 객체입니다:
class SpeakingState:
def __init__(self):
self._speaking = False
self._tail_suppress_until = 0.0
def set_speaking(self, val: bool):
self._speaking = val
if not val:
self._tail_suppress_until = time.time() + ECHO_TAIL_SUPPRESS_SEC
def is_suppressed(self) -> bool:
return self._speaking or time.time() < self._tail_suppress_until
TTS가 재생을 시작할 때, set_speaking(True)이 호출됩니다. STT는 VAD가 트리거한 세그먼트를 처리하기 전에 is_suppressed()을 확인합니다. TTS가 완료된 후, 억제는 설정 가능한 꼬리 윈도우(우리는 0.8초를 사용)를 계속 유지하여 PulseAudio 버퍼를 통해 여전히 배수되는 오디오를 캐치합니다.
Barge-in 감지는 중단 이야기의 다른 절반입니다. 사람이 AI가 말하고 있는 동안 말할 때, 우리는 AI를 중간에 멈추고 싶습니다. 이것은 VAD가 실시간 인터럽트 트리거에 너무 느리기 때문에 에너지 기반 감지로 수행됩니다:
# Fast 200ms window vs slow 3.2s baseline
fast_energy = rms(audio[-200ms:])
slow_energy = rms(audio[-3200ms:])
if fast_energy > slow_energy * BARGE_IN_RATIO:
trigger_interrupt()
빠른 윈도우의 에너지가 느린 기준의 비율을 초과하면, 이는 barge-in을 신호합니다. TTS 파이프라인은 인터럽트 명령을 받고 즉시 재생을 중지합니다.
발표자 식별: 음성 인쇄 매칭
회의에서 말한 모든 것이 AI에 도달해야 하는 것은 아닙니다. 회의 호스트만 에이전트를 호출할 수 있기를 원하거나 팀의 사람들만 원할 수도 있습니다. 발표자 식별이 이를 해결합니다.
구현은 speechbrain의 ECAPA-TDNN 모델을 사용합니다 — 192차원 발표자 임베딩을 생성하는 발표자 검증 모델입니다. 각 오디오 세그먼트에 대해 우리는 임베딩을 추출하고 코사인 유사도를 사용하여 등록된 음성 인쇄 세트와 매칭합니다:
similarity = cosine_similarity(embedding, voiceprint)
if similarity > SPEAKER_MATCH_THRESHOLD: # 0.30 by default
return "matched_speaker_name"
0.30의 임계값은 의도적으로 낮습니다 — 우리는 합법적인 사용자를 놓치기보다는 거짓 양성(알려지지 않은 발표자를 알려진 것으로 인식)을 선호합니다. 특정 사람만 에이전트를 호출할 수 있는 신뢰 경계의 경우, 이를 높일 수 있습니다.
모델은 언어 불가지론적입니다. 중국어 및 영어 발표자 모두에 대해 동등하게 작동하며, 이는 우리 회의에 중요합니다. CUDA에서의 추론은 10ms 미만에서 실행되므로 전사 파이프라인에 무시할 수 있는 지연을 추가합니다.
발표자 신원은 신뢰 계층으로서 WebSocket 릴레이에 흐릅니다. 전사는 verified(알려진 음성 인쇄와 일치) 또는 untrusted(알려지지 않은 발표자)로 태그됩니다. 브리지는 이것을 사용하여 웨이크 워드를 호출할 수 있는 사람을 필터링합니다.
웨이크 워드 감지 및 브리지
브리지는 Mac에서 실행됩니다. 그 역할은 Linux 서버와 OpenClaw 에이전트 사이에 앉아 어떤 것이 어디로 가는지에 대한 라우팅 결정을 내리는 것입니다.
전사가 Linux 서버에서 도착하면, 브리지는 정규식 패턴 매칭을 사용하여 웨이크 워드를 확인합니다. 웨이크 워드 목록은 설정 가능합니다 — 우리 설정에는 “hey claude”, “hey agent” 및 몇 가지 중국어 동등어가 포함됩니다. 브리지는 또한 웨이크 워드 요구를 완화하고 모든 전사가 흐르는 “presentation mode”를 지원합니다.
웨이크 워드가 감지되면, 브리지는 “engaged” 모드로 전환됩니다: 이후 전사를 버퍼링하고, 여러 발표자로부터 문맥을 축적하고, 자연스러운 일시 중지가 있을 때 버퍼를 OpenClaw 에이전트의 HTTP API로 플러시합니다. 즉, AI는 단 하나의 문장을 듣는 것이 아닙니다 — 소환되었을 때 논의되고 있던 것에 대한 다중 턴 문맥 윈도우를 얻습니다.
브리지는 또한 응답 경로를 처리합니다. OpenClaw가 응답을 생성할 때, 브리지는 speak 명령을 Linux 서버의 WebSocket 릴레이로 다시 전송합니다:
{"cmd": "speak", "text": "Here's the answer to your question..."}
TTS 파이프라인이 이를 받고, 합성하고, 가상 마이크를 통해 재생합니다.
연결 복원력은 여기서 중요합니다 — 회의는 몇 시간 동안 지속될 수 있습니다. 브리지는 지수 백오프 및 하트비트 핑을 사용하여 자동 재연결을 구현하여 자동 재연결을 구현하고 사일런트 연결 끊김을 감지합니다.
TTS 파이프라인: 스트리밍 합성
Edge-TTS는 자연스러운 음성을 생성하는 무료의 고품질 TTS 서비스입니다. 한계는 스트리밍하지 않는다는 것입니다 — 전체 텍스트가 합성될 때까지 기다립니다.
우리는 청크된 생성으로 이를 해결합니다. Edge-TTS는 내부적으로 MP3 데이터를 스트리밍하고, edge-tts Python 라이브러리가 이를 노출합니다. MP3 스트림을 ffmpeg에 파이프하여 비행 중에 PCM으로 변환한 다음 청크가 도착하면 PulseAudio에 씁니다:
async for chunk in communicate.stream():
if chunk["type"] == "audio":
process.stdin.write(chunk["data"]) # ffmpeg stdin
# ffmpeg is already decoding and writing to PulseAudio
그 결과는 첫 번째 들을 수 있는 오디오가 speak 명령 도착 후 200-300ms 내에 재생된다는 것입니다. 사람들이 이미 말하고 있고 응답을 기다리고 있는 회의 문맥에서, 이 지연은 거의 지각할 수 없습니다.
TTS 파이프라인은 STT에서 사용되는 것과 동일한 SpeakingState와 통합됩니다. 각 청크를 쓰기 전에, 인터럽트 신호를 확인합니다. 오디오가 생성되는 동안 barge-in이 감지되었다면, 재생이 문장 중간에 멈추고 파이프라인이 브리지로 확인을 보내서 에이전트가 응답이 잘린 것을 알 수 있습니다.
한 번의 명령으로 시작
전체 시스템 — SSH 터널, 원격 PulseAudio 설정, tmux 세션의 두 프로세스 — 단일 스크립트로 시작됩니다:
./start_meeting.sh
스크립트는 전체 오케스트레이션을 처리합니다:
- SSH 터널이 포트 8765에서 이미 실행 중인지 확인합니다. 그렇지 않으면 만듭니다
- Linux 서버로 SSH하고 CRD PulseAudio 소켓 경로를 감지합니다
- 세 개의 가상 오디오 장치를 로드합니다(멱등성 — 이미 로드된 경우 건너뜁니다)
- 원격에 런처 스크립트를 쓰여서 소켓 경로의 쉘 이스케이프 문제를 피합니다
main_linux.py를 원격 tmux 세션(teams-voice)에서 시작합니다- WebSocket이 연결을 수락할 때까지 폴링합니다
bridge.py를 로컬 tmux 세션(teams-bridge)에서 시작합니다
시작 후, 출력은 실행 중인 것을 정확히 보여주고 Teams에서 구성할 사항을 보여줍니다:
══════════════════════════════════════════════════
Teams Voice Agent — RUNNING
══════════════════════════════════════════════════
Session key : voice-meeting-20260313-1430
Bridge tmux : teams-bridge (local)
Remote tmux : teams-voice (on gpu-server)
Tunnel : localhost:8765 → gpu-server:8765
⚠️ Set Teams audio: Speaker=Teams_Speaker, Mic=Teams_Mic_Input
══════════════════════════════════════════════════
유일한 수동 단계는 Teams의 오디오 장치를 가상 장치로 설정하는 것입니다. 그 후에는 모든 것이 핸즈프리입니다.
실제로
일반적인 회의에서, 파이프라인은 활성화될 때까지 완전히 침묵합니다. STT는 지속적으로 실행 중이고, 발표자 ID는 모든 사람을 태그하지만, 에이전트에 아무것도 흐르지 않습니다. AI는 듣고 있지만 존재하지 않습니다.
누군가 “hey claude, can you look something up”이라고 말하면, 브리지는 웨이크 워드를 캐치하고, 다음의 몇 문장의 문맥을 버퍼링하고, 전체를 에이전트로 전송합니다. 에이전트는 2초 이내에 응답하고, 음성은 모든 사람의 스피커를 통해 나옵니다(AI가 가상 마이크를 통해 말하고 있기 때문에), 그러면 파이프라인은 다시 조용해집니다.
우리는 주로 기술 토론 중 지식 검색에 사용합니다 — 문서를 가져오기, 노트 교차 참조, 호출의 이전에 결정된 것을 요약합니다. 에이전트는 OpenClaw 메모리 시스템에 접근할 수 있으므로 동일한 프로젝트에 대한 과거 회의의 문맥을 검색할 수 있습니다. 이것이 회의에서 여전히 사람들을 놀라게 하는 부분입니다: AI가 질문에 답변할 뿐만 아니라 지난주 호출의 결정을 참조합니다.
Barge-in은 예상보다 더 잘 작동합니다. 누군가가 AI가 중반 응답 중인 동안 말하기 시작하면, 약 반 초 내에 멈춥니다. PulseAudio 버퍼를 통해 이미 있던 오디오에서 간단한 아티팩트가 있지만, 방해가 되지는 않습니다.
주요 한계는 Linux 서버에서 Chrome Remote Desktop이 실행되어야 한다는 것입니다. CRD는 가상 장치가 있는 PulseAudio 환경을 만듭니다. 그 없이, 당신은 당신이 가진 PulseAudio 설정으로 작동하도록 시작 스크립트를 조정할 필요가 있을 것입니다. 핵심 파이프라인 코드는 신경 쓰지 않습니다 — 올바른 장치 이름이 존재하기만 하면 됩니다.
우리가 다르게 할 것
현재 아키텍처는 모든 신호 처리를 Linux 서버에 배치하는데, 우리에게는 의미가 있지만 보편적이지는 않습니다. GPU 지원 Mac이 있거나 더 간단한 설정을 원한다면, STT 파이프라인은 MLX-Whisper를 사용하여 로컬에서 실행될 수 있습니다 — Apple Silicon에서 대략 동일한 정확도, 원격 서버 불필요합니다.
발표자 식별은 현재 사전 등록된 음성 인쇄를 기반으로 합니다. 더 강력한 접근 방식은 pyannote와 같은 것을 사용하여 등록을 요청하지 않고 diarization 스타일 “누가 언제 말했는가” 식별을 수행하는 것입니다. 이것은 에이전트가 등록하지 않은 사람에 대해서도 회의 기여를 귀속시킬 수 있게 해줄 것입니다.
웨이크 워드 시스템은 정규식 기반이며, 이는 작동하지만 악센트 및 음성 인식 오류에 취약합니다. 적절한 웨이크 워드 모델(openWakeWord와 같은)은 특히 영어가 아닌 활성화의 경우 더 안정적일 것입니다.