Обучение ИИ участию в вашей встрече Teams: конвейер голоса в реальном времени
Microsoft Teams не имеет настоящей интеграции голосового ИИ, которая позволяет вызвать помощника во время встречи с помощью пробуждающего слова. Мы создали её. Это архитектура конвейера голоса в реальном времени, который запускает STT на GPU-сервере, маршрутизирует аудио через виртуальные устройства PulseAudio и подключает агента ИИ, активируемого пробуждающим словом, к живым встречам.
Microsoft Teams теперь имеет много функций ИИ. Copilot может резюмировать встречи постфактум. Он может создавать элементы действий. Он может создавать расшифровки. Что он не может делать — по крайней мере не в какой-либо форме, которая работала бы для нас — это позволить вам вызвать агента ИИ во время встречи с помощью пробуждающего слова и провести настоящий разговор, где ИИ слышит всех, говорит в ответ и помнит контекст из всего звонка.
Мы хотели этого. Так что мы это создали.
Результат — это открытый конвейер голоса, который интегрирует агента ИИ, работающего на Claude, в живые встречи Teams. Вы произносите пробуждающее слово, ИИ активируется, слышит ваш вопрос в контексте и отвечает естественной речью — всё в реальном времени с подавлением эха, чтобы он не спутал собственный голос с входящим сигналом.
GitHub: teams-meeting-agent-public
Почему бы просто не использовать Teams API?
Teams имеет API для звонков. Вы можете добавлять ботов на встречи. Но API ботов для встреч разработан для структурированных интеграций — сервисов расшифровки, ботов записи, создателей заметок встреч. Конвейер аудио, который он предоставляет, не очень подходит для низколатентного разговорного агента, которому нужно слышать всех участников, выполнять идентификацию говорящего, отвечать за два секунды и справляться с перебиванием.
У нас также есть специфические ограничения: мы работаем между Mac (где запущен клиент Teams) и Linux GPU-сервером (где мы запускаем инференс). На GPU-сервере мы хотим выполнять распознавание речи — запуск faster-whisper на CUDA значительно быстрее и точнее, чем что-либо, что вы можете делать на Mac в реальном времени. Это означает, что нам нужен мост между двумя машинами с аудио, проходящим через SSH-туннель.
Путь наименьшего сопротивления оказался таким: используйте виртуальные устройства PulseAudio на Linux-стороне для перехвата аудио 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(null-sink PulseAudio) - Конвейер STT захватывает поток
.monitor, запускает VAD + идентификацию говорящего + whisper - Расшифровка отправляется через WebSocket на bridge Mac
- Bridge обнаруживает пробуждающее слово → буферизирует контекст → отправляет агенту OpenClaw по HTTP
- Агент генерирует ответ → bridge отправляет команду
speakобратно через WebSocket - Конвейер TTS синтезирует с Edge-TTS → передаёт в поток
teams_virtual_mic - Teams слышит, как говорит ИИ, через
teams_mic_input
Всё кроме вызова LLM агента работает локально. STT работает на устройстве на CUDA, TTS передаёт в потоке в течение 200 мс после первого куска Edge-TTS, а полный обход от пробуждающего слова до первого произнесённого слова обычно занимает менее двух секунд.
Трюк 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, аудио входит и воспроизводится в ничто. Но null-sink в PulseAudio автоматически создают источник .monitor, который предоставляет аудио как читаемый поток. Поэтому, установив выход динамика Teams на Teams_Speaker, мы получаем teams_speaker.monitor — поток PCM в реальном времени всего, что воспроизводит Teams, включая всех участников встречи. Конвейер STT читает из этого.
teams_virtual_mic и teams_mic_input работают одинаково в обратном направлении. Конвейер TTS записывает синтезированную речь в teams_virtual_mic. Монитор предоставляет её как читаемый источник (teams_mic_input), который мы устанавливаем как микрофонный вход 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, чтобы найти путь сокета, затем экспортирует PULSE_SERVER=unix:${PULSE_PATH}/native перед каждым вызовом pactl. Всё остальное просто работает.
Конвейер STT: faster-whisper + VAD + подавление эха
Конвейер STT — это место, где происходит большая часть интересной обработки сигналов. Он работает на Linux-сервере и имеет четыре обязанности: захват аудио, обнаружение границ речи, расшифровка и подавление собственного голоса.
Захват аудио использует parec PulseAudio для потоковой передачи сырого PCM из teams_speaker.monitor на 16 кГц моно (формат, который ожидает faster-whisper). Аудио поступает непрерывными чанками по 30 мс.
Обнаружение голосовой активности использует Silero VAD. Поток сырого аудио разбивается на фреймы из 512 сэмплов и подаётся в модель. VAD работает достаточно быстро, чтобы не добавлять ощутимую задержку. Сегменты речи накапливаются, пока разрыв в тишине не вызывает расшифровку.
Расшифровка использует faster-whisper с distil-large-v3 на CUDA. Distil-large-v3 — это сжатая версия Whisper large-v3 — сравнимая точность, примерно в 5 раз быстрее. Для смешанных встреч на китайском и английском языках (наш основной случай использования) он справляется с переключением кодов без необходимости подсказок по языку.
Подавление эха — это часть, которая потребовала наибольшей настройки. Без него ИИ расшифровывал бы собственный вывод 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 проверяет is_suppressed() перед обработкой любого сегмента, вызванного VAD. После завершения TTS подавление продолжается в течение настраиваемого временного окна (мы используем 0,8 секунды), чтобы перехватить аудио, всё ещё сливаемое через буфер PulseAudio.
Обнаружение перебивания — это вторая половина истории прерывания. Когда человек говорит, пока говорит ИИ, мы хотим остановить ИИ в середине предложения. Это делается с помощью обнаружения энергии, а не VAD, потому что VAD слишком медленный для триггера прерывания в реальном времени:
# Быстрое окно 200 мс против медленной базовой линии 3,2 с
fast_energy = rms(audio[-200ms:])
slow_energy = rms(audio[-3200ms:])
if fast_energy > slow_energy * BARGE_IN_RATIO:
trigger_interrupt()
Если энергия быстрого окна превышает коэффициент медленной базовой линии, это сигнализирует о перебивании. Конвейер TTS получает команду прерывания и немедленно останавливает воспроизведение.
Идентификация говорящего: сопоставление отпечатков голоса
Не всё, что говорится на встрече, должно доходить до ИИ. Вы можете захотеть, чтобы только хост встречи мог вызывать агента, или только люди из вашей команды. Идентификация говорящего решает эту проблему.
Реализация использует модель ECAPA-TDNN из speechbrain — модель верификации говорящего, которая производит 192-мерные встраивания говорящего. Для каждого сегмента аудио мы извлекаем встраивание и сопоставляем его с набором зарегистрированных отпечатков голоса, используя косинусное сходство:
similarity = cosine_similarity(embedding, voiceprint)
if similarity > SPEAKER_MATCH_THRESHOLD: # 0.30 по умолчанию
return "matched_speaker_name"
Порог 0,30 намеренно низкий — мы предпочли бы ложное срабатывание (признание неизвестного говорящего за известного), чем пропустить законного пользователя. Для границы доверия, где только определённые люди могут вызывать агента, вы бы подняли это значение.
Модель независима от языка. Она одинаково хорошо работает для китайских и английских говорящих, что важно для наших встреч. Инференс работает менее чем за 10 мс на CUDA, поэтому он добавляет незначительную задержку к конвейеру расшифровки.
Личность говорящего поступает в WebSocket-реле как уровень доверия. Расшифровки помечаются как verified (сопоставлено известное отпечатку голоса) или untrusted (неизвестный говорящий). Bridge использует это для фильтрации того, кто может вызывать пробуждающее слово.
Обнаружение пробуждающего слова и bridge
Bridge работает на Mac. Его задача — сидеть между Linux-сервером и агентом OpenClaw, принимая решения маршрутизации о том, что отправляется куда.
Когда расшифровка поступает с Linux-сервера, bridge проверяет пробуждающие слова, используя сопоставление с регулярными выражениями. Список пробуждающих слов настраивается — в нашей установке он включает “hey claude”, “hey agent” и несколько китайских эквивалентов. Bridge также поддерживает “режим презентации”, в котором требование пробуждающего слова ослабляется и все расшифровки проходят.
Когда обнаруживается пробуждающее слово, bridge переходит в режим “engaged”: он буферизирует последующие расшифровки, накапливает контекст от нескольких говорящих и очищает буфер к HTTP API агента OpenClaw при естественной паузе. Это означает, что ИИ слышит не просто одно предложение — он получает окно контекста из нескольких поворотов того, что обсуждалось, когда он был вызван.
Bridge также обрабатывает путь ответа. Когда OpenClaw генерирует ответ, bridge отправляет команду speak обратно на WebSocket-реле Linux-сервера:
{"cmd": "speak", "text": "Here's the answer to your question..."}
Конвейер TTS подхватывает это, синтезирует и воспроизводит через виртуальный микрофон.
Устойчивость соединения здесь важна — встречи могут длиться часами. Bridge реализует автоматическое переподключение с экспоненциальной задержкой и пинги сердцебиения для обнаружения тихих разрывов соединения до того, как они вызовут потерю расшифровок.
Конвейер TTS: синтез потоковой передачи
Edge-TTS — это бесплатный высококачественный сервис TTS, который производит естественно звучащую речь. Ограничение в том, что он не передаёт данные потоком — он ждёт полного синтеза текста перед возвращением аудио.
Мы обходим это с помощью генерации чанков. Edge-TTS внутри передаёт данные MP3 потоком, и библиотека Python edge-tts это предоставляет. Мы пропускаем поток MP3 через ffmpeg, чтобы преобразовать его в PCM на лету, затем записываем в PulseAudio по мере поступления чанков:
async for chunk in communicate.stream():
if chunk["type"] == "audio":
process.stdin.write(chunk["data"]) # ffmpeg stdin
# ffmpeg уже декодирует и записывает в PulseAudio
Результат в том, что первое слышимое аудио воспроизводится в течение 200–300 мс после поступления команды speak. Для контекста встречи, где люди уже разговаривают и ждут ответа, эта задержка почти не заметна.
Конвейер TTS интегрируется с тем же SpeakingState, который используется STT. Перед записью каждого чанка он проверяет наличие сигнала прерывания. Если перебивание было обнаружено во время генерации аудио, воспроизведение останавливается в середине предложения и конвейер отправляет подтверждение обратно к bridge, чтобы агент знал, что ответ был прерван.
Запуск одной командой
Вся система — SSH-туннель, удалённая настройка PulseAudio, оба процесса в сеансах tmux — запускается одним скриптом:
./start_meeting.sh
Скрипт обрабатывает полную оркестровку:
- Проверяет, работает ли уже SSH-туннель на порту 8765; создаёт его, если нет
- SSH в Linux-сервер, обнаруживает путь сокета 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 работает непрерывно, идентификация говорящего помечает всех, но ничего не проходит к агенту. ИИ слушает, но не присутствует.
Когда кто-то говорит “hey claude, can you look something up”, bridge ловит пробуждающее слово, буферизирует следующие несколько предложений контекста и отправляет всё это агенту. Агент отвечает менее чем за две секунды, голос проходит через динамики всех (так как ИИ говорит через виртуальный микрофон), а затем конвейер снова замолкает.
Мы используем его в основном для извлечения знаний во время технических обсуждений — поиск документации, перекрёстные ссылки на заметки, резюмирование того, что было решено ранее на звонке. Агент имеет доступ к системе памяти OpenClaw, поэтому он может извлекать контекст из прошлых встреч по одному и тому же проекту. Это часть, которая до сих пор удивляет людей на встречах: ИИ не только отвечает на вопрос, но и ссылается на решение с последнего звонка неделю назад.
Перебивание работает лучше, чем ожидалось. Если кто-то начинает говорить, пока ИИ выдаёт ответ, он останавливается примерно за полсекунды. Есть краткий артефакт от аудио, которое уже было в буфере PulseAudio, но это не нарушает работу.
Основное ограничение в том, что требуется запуск Chrome Remote Desktop на Linux-сервере. CRD создаёт окружение PulseAudio, в котором живут виртуальные устройства. Без него вам потребуется адаптировать скрипт запуска для работы с любой настройкой PulseAudio, которая у вас есть. Основной код конвейера не заботит — ему просто нужны правильные имена устройств.
Что бы мы сделали по-другому
Текущая архитектура помещает всю обработку сигналов на Linux-сервер, что имеет смысл для нас, но не универсально. Если у вас есть Mac с поддержкой GPU или вам просто нужна более простая настройка, конвейер STT может работать локально, используя MLX-Whisper — примерно такую же точность на Apple Silicon, удалённый сервер не требуется.
Идентификация говорящего в настоящее время основана на предварительно зарегистрированных отпечатках голоса. Более надёжный подход состоял бы в выполнении идентификации в стиле диаризации “кто говорил когда” без необходимости регистрации, используя что-нибудь вроде pyannote. Это позволило бы агенту атрибутировать вклады в встречу даже для людей, которые не зарегистрировались.
Система пробуждающих слов основана на регулярных выражениях, что работает, но хрупко для акцентов и ошибок распознавания речи. Правильная модель пробуждающего слова (как openWakeWord) была бы более надёжной, особенно для активаций на английском языке.
Вывод
Интересная инженерия здесь — это не часть ИИ — Claude справляется с этим. Это аудиотехника: доставка нужного аудио в нужное место в нужное время, без того, чтобы ИИ спутал собственный голос с входящим сигналом, без добавления достаточно большой задержки, чтобы сделать разговор неудобным.
Виртуальные устройства PulseAudio действительно мощны для таких видов аудиомаршрутизации. Паттерн null-sink + monitor — это чистый способ перехвата аудиопотоков без подключения к внутреннему конвейеру любого приложения. Больше людей должно знать, что это существует.
Полный исходный код находится в github.com/QiushiWu95/teams-meeting-agent-public. README содержит инструкции по настройке для конфигурации оборудования, которую мы используем (GPU-сервер + Mac через Tailscale), но сам конвейер должен адаптироваться к другим установкам с умеренными усилиями.