教授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确实有通话API。你可以将机器人添加到会议中。但会议机器人API设计用于结构化集成——转录服务、录制机器人、会议记录员。它公开的音频管道不适合低延迟的对话代理,该代理需要听到所有参与者、进行说话者识别、在两秒内响应,并处理中断。
我们还有特定的约束:我们在Mac(Teams客户端运行的地方)和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 + 说话者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内流,从唤醒词到第一个说话词的整个往返通常在两秒以内。
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完全透明。它不知道它在与虚拟设备交谈。不需要API访问。不需要机器人注册。Linux服务器看起来就像一个具有非常聪明麦克风的会议参与者。
有一个复杂情况:虚拟设备需要在Chrome Remote Desktop(CRD)PulseAudio会话内创建,而不是系统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 以16kHz单声道(faster-whisper期望的格式)从 teams_speaker.monitor 流式传输原始PCM。音频以连续的30ms块的形式传入。
语音活动检测使用Silero VAD。原始音频流被分块成512样本帧并馈送到模型。VAD运行速度足够快,不会增加明显延迟。语音段被累积,直到沉默间隙触发转录。
转录在CUDA上使用faster-whisper与 distil-large-v3。Distil-large-v3是Whisper large-v3的蒸馏版本——可比准确性,大约快5倍。对于中英文混合会议(我们的主要使用情况),它在不需要语言提示的情况下处理代码切换。
回声消除是花费最多调整时间的部分。没有它,AI会转录自己的TTS输出,这会创建反馈循环,其中它听到自己说话并尝试响应。解决方案是一个 SpeakingState 对象,在TTS和STT管道之间协调:
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缓冲区排出的音频。
强行插入检测是中断故事的另一半。当人类在AI说话时说话时,我们想要停止AI的中途。这是通过基于能量的检测而不是VAD完成的,因为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()
如果快速窗口的能量超过慢速基准线的比率,它会发出强行插入信号。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”和一些中文等效物。桥梁也支持”演示模式”,其中唤醒词要求被放宽,所有转录都流过。
当检测到唤醒词时,桥梁转换为”engaged”模式:它缓冲后续转录,从多个说话者积累上下文,并在有自然暂停时将缓冲区刷新到OpenClaw代理的HTTP API。这意味着AI不只听一句话——它获得召唤时正在讨论内容的多轮上下文窗口。
桥梁也处理响应路径。当OpenClaw生成回复时,桥梁将 speak 命令发送回Linux服务器的WebSocket中继:
{"cmd": "speak", "text": "Here's the answer to your question..."}
TTS管道获取这个,合成它,并通过虚拟麦克风播放。
连接弹性在这里很重要——会议可以持续数小时。桥梁实现自动重新连接,具有指数退避和心跳ping,以在它们导致转录丢失之前检测到静默断开连接。
TTS管道:流式合成
Edge-TTS是一个免费的高质量TTS服务,产生自然语音。限制是它不流式传输——它在返回音频之前等待全文合成。
我们通过分块生成来解决这个问题。Edge-TTS在内部流式传输MP3数据,edge-tts Python库公开了这一点。我们通过ffmpeg管道MP3流来动态将其转换为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 集成。在写入每个块之前,它检查中断信号。如果在音频生成时检测到强行插入,播放在中途停止,管道向桥梁发送确认,以便代理知道响应被切短。
一命令启动
整个系统——SSH隧道、远程PulseAudio设置、两个进程在tmux会话中——以单个脚本启动:
./start_meeting.sh
脚本处理完整的编排:
- 检查SSH隧道是否已在端口8765上运行;如果没有则创建
- SSH到Linux服务器,检测CRD PulseAudio套接字路径
- 加载三个虚拟音频设备(幂等——如果已加载则跳过)
- 在远程写入启动器脚本,以避免套接字路径的shell转义问题
- 在远程tmux会话中启动
main_linux.py(teams-voice) - 轮询直到WebSocket接受连接
- 在本地tmux会话中启动
bridge.py(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”时,桥梁捕捉唤醒词,缓冲接下来的几句话的上下文,并将整个内容发送到代理。代理在两秒内响应,语音通过每个人的扬声器传出(因为AI通过虚拟麦克风说话),然后管道再次安静。
我们主要在技术讨论期间用于知识检索——查找文档、交叉引用笔记、总结之前在通话中决定的内容。代理可以访问OpenClaw内存系统,因此它可以从关于同一项目的过去会议中检索上下文。这仍然让会议中的人们惊讶的部分是:AI不仅回答问题,而且引用上周通话中的决议。
强行插入比预期的效果更好。如果有人在AI中途响应时开始说话,它在大约半秒内停止。已经在PulseAudio缓冲区中的音频有简短的伪影,但它不是破坏性的。
主要限制是它需要Chrome Remote Desktop在Linux服务器上运行。CRD创建虚拟设备所在的PulseAudio环境。没有它,你需要调整启动脚本以与你拥有的任何PulseAudio设置一起工作。核心管道代码不关心——它只需要正确的设备名称存在。
我们会做不同的事
当前架构将所有信号处理放在Linux服务器上,这对我们有意义,但不通用。如果你有支持GPU的Mac或只是想要更简单的设置,STT管道可以使用MLX-Whisper在本地运行——Apple Silicon上的准确性大约相同,不需要远程服务器。
说话者识别当前基于预注册的语音纹。更稳健的方法是进行diarization风格的”谁在什么时候说”识别,而不需要注册,使用pyannote之类的东西。这将让代理属性会议贡献,即使对于没有注册的人。
唤醒词系统是基于正则表达式的,有效但容易出错,容易出现口音和语音识别错误。适当的唤醒词模型(如openWakeWord)会更可靠,特别是对于非英语激活。
要点
这里有趣的工程不是AI部分——Claude处理这个。这是音频管道:在正确的时间将正确的音频送到正确的位置,而不是让AI将自己的声音与输入混淆,不添加足够的延迟以使对话变得尴尬。
PulseAudio虚拟设备对于这种音频路由确实很强大。null-sink + monitor模式是拦截音频流的干净方式,无需修补任何应用程序的内部管道。更多人应该知道它的存在。
完整源代码在github.com/QiushiWu95/teams-meeting-agent-public。README有我们使用的硬件配置(GPU服务器+ Mac通过Tailscale)的设置说明,但管道本身应该以适度的努力适应其他设置。