# Vext 1.2.0: Двухпроходная диаризация, самовосстанавливающийся event tap и пять языков

> Технический разбор релиза Vext 1.2.0. Почему потоковая диаризация в одиночку ломается при быстром обмене репликами, почему мы убрали API эхоподавления Apple вместо того чтобы с ним бороться, и как глобальный event tap может врать о своём состоянии после сна системы.

[Vext 1.1](/blog/introducing-vext-voice-to-text-for-mac) вышел три недели назад. **Vext 1.2.0** выходит сегодня.

[**getvext.app**](https://getvext.app) · 1.1.0 → **1.2.0**

[Пользовательский анонс 1.2.0](https://getvext.app/blog/vext-1-2-0-multilanguage-speakers) рассказывает о том, что нового в приложении — вкладка «Говорящие», полноценный мультиязычный интерфейс, более чёткие транскрипции встреч. Этот пост — технический разбор: проблемы, которые было по-настоящему сложно решить, и как они выглядят в коде сейчас.

---

## Потоковой диаризации недостаточно — даже когда она «работает правильно»

Транскрипция встреч в реальном времени имеет одну степень свободы, которую мы не можем восстановить позже: она слышит аудио один раз, по порядку. Потоковый диаризатор присваивает каждому VAD-чанку одну метку говорящего, используя один embedding на чанк. Это хорошее приближение, когда люди говорят по очереди. При быстром обмене репликами оно ломается и ставит неверную метку.

Два говорящих, перебивающих друг друга десять секунд, не должны превращаться в «Говорящий 1 в течение десяти секунд».

В версии 1.2.0 потоковый проход сохранён — транскрипции встреч по-прежнему появляются в реальном времени, чанк за чанком. Но после окончания встречи и сохранения черновой транскрипции Vext запускает второй проход диаризации по архивным WAV-файлам потоков. Офлайн-пайплайн:

- **pyannote Community-1** для сегментации
- **WeSpeaker** эмбеддинги с маскированием перекрывающихся фреймов
- **VBx** байесовское уточнение для консолидации кластеров

Офлайн-проход переатрибутирует каждый чанк к его глобально правильному кластеру. Когда он распознаёт известного говорящего, его эмбеддинг обновляется в базе данных — следующая встреча распознает его быстрее. Временные WAV-архивы Vext удаляет после завершения уточнения.

Во время встречи ты видишь потоковый результат. После — читаешь уточнённый. Это не один и тот же артефакт — и это сделано намеренно.

---

## Чанки с несколькими говорящими: резать, а не метить

Вторая половина истории с диаризацией происходит внутри одного чанка.

Sortformer выдаёт покадровую временну́ю шкалу говорящих. Если внутри одного VAD-чанка появляются два или больше различных индексов говорящих, транскрибировать его как единый блок значит заставить модель приписать всё одному голосу. 30-секундный чанк с быстрым диалогом становится одним транскрибированным блоком с одной меткой говорящего — два голоса схлопнулись в один.

Версия 1.2.0 разрезает аудио в точках смены говорящего и транскрибирует каждый ход самостоятельно. На входе один чанк — на выходе N чанков, каждый со своей меткой говорящего, каждый транскрибированный как отдельное высказывание.

Одна деталь, которая заняла больше времени, чем должна была: Sortformer генерирует шумовые мерцания менее 300ms — единственный фрейм, приписанный другому говорящему посреди высказывания. Резка по каждому мерцанию фрагментирует транскрипцию и порождает фантомные реплики. Теперь регионы менее 300ms поглощаются в самый длинный соседний участок перед запуском резчика — так что разрезы, которые мы делаем, — это разрезы, которые реально существуют.

---

## Баг отравления микрофона

`setVoiceProcessingEnabled` у Apple на `AVAudioInputNode` делает то, что написано: AGC, подавление шума, эхоподавление. Но ещё делает кое-что, что документация не подчёркивает — он мутирует общее HAL-состояние на устройстве ввода.

Включишь в Vext — и каждое другое приложение, читающее тот же микрофон — Zoom, FaceTime, OBS, любой рекордер — тоже получит AGC и подавление шума на своём фиде. Голос пользователя звучит тихо и с пониженным усилением в том звонке, в котором он реально участвует. Выключишь — и получишь ту же проблему в обратную сторону, когда другое приложение включит это снова.

Инстинкт — бороться с API: выключить, включить обратно, держать лок, восстанавливать состояние. Правильный ответ — «не использовать его здесь вообще». Vext захватывает участников встречи через отдельный system-audio process tap, а не через путь микрофона. Поток микрофона и системный поток физически разделены. Эхоподавление между ними никогда не было нужно — оно решало проблему, которой нет в этой архитектуре.

Версия 1.2.0 убирает вызов. Общее HAL-состояние больше не затрагивается.

---

## Event tap, который врёт

Глобальный event tap клавиатуры — то, что делает возможным «держи горячую клавишу и говори» — имеет сценарий сбоя, который стоит описать, потому что его поиск занял немало времени.

После сна дисплея, сна системы или быстрого переключения пользователей mach port, на котором держится tap, может устареть. `CGEventTapIsEnabled` продолжает возвращать `true`. События тихо игнорируются. Пользователь держит горячую клавишу — ничего не происходит. Перезапуск приложения это лечит. Логи не объясняют почему.

Версия 1.2.0 самовосстанавливается:

- Мы теперь подписываемся на `NSWorkspace.didWakeNotification`, `screensDidWakeNotification` и `sessionDidBecomeActiveNotification`. Каждое из них запускает полную переустановку tap — не повторное включение, а пересоздание.
- Когда система кидает `tapDisabledByTimeout`, мы проверяем, действительно ли повторное включение сработало. Если нет — запускается та же полная переустановка.
- Таймер health-check перенесён в режимы run-loop `.common` — мы переместили его, потому что раньше он блокировался во время отслеживания меню и drag-операций, а это именно те окна, когда устаревший tap с наибольшей вероятностью станет следующим взаимодействием пользователя.

Это не гламурный код. Это код, от которого зависит, достаточно ли приложение надёжно, чтобы жить в строке меню.

---

## Один путь запуска

Vext может начать диктовку, заметку или встречу с горячей клавиши или из строки меню. В версии 1.1 это были два отдельных пути кода. Они разошлись.

Путь через горячую клавишу шёл через координатор: пузырь курсора отображается, лицензия проверяется, коллбэки состояния срабатывают, сериализация вставки применяется. Путь через строку меню обходил большую часть этого и вызывал слой записи напрямую. Всплыли тонкие баги — сессия, начатая из меню с устаревшим состоянием лицензии, отсутствующий пузырь курсора, из-за которого пользователи не понимали, идёт ли запись.

Версия 1.2.0 направляет действия меню через тот же путь кода, что и горячая клавиша, с одним задекларированным отличием: диктовка, запущенная из меню, считается hands-free (тоггл для старта, тоггл для стопа) — нет физической клавиши, которую нужно держать. Всё остальное идентично, потому что это один и тот же вызов функции.

---

## Метки говорящих, которые не расходятся

Два пути производили имена для одного и того же аудио. Снепшот говорящих встречи — список голосов и их назначенных имён, который ты видишь в деталях встречи — раньше восстанавливался из `DiarizerManager.getSpeakerList()` после окончания встречи, а потом отдельно маппился к отображаемым именам — в стороне от меток чанков.

Версия 1.2.0 строит снепшот постепенно во время записи через `liveSnapshot`, используя тот же вызов `diarizeSpeaker()`, что метит чанки. Один источник правды — по конструкции. Говорящие, уже присутствующие в глобальном `KnownSpeakerRepository`, исключаются из снепшота встречи, поскольку их эмбеддинги живут глобально и не нужно перечислять их заново по каждой встрече.

---

## Пять языков, одна таблица

История с локализацией короткая, потому что реализация скучная намеренно.

Каждая видимая пользователю строка — метки сайдбара, пункты меню, пустые состояния, подсказки онбординга, описания разрешений, тултипы панели инструментов, текст About, метки выбора модели — проходит через единую централизованную таблицу переводов. Пять языков: английский, испанский, русский, хинди, тайский. Отсутствующие ключи молча откатываются к английскому.

Выбор языка (Настройки → Основные, и в онбординге) содержит опцию AUTO, следующую за локалью системы macOS. Выбор конкретного языка переключает без перезапуска — никакого релонча, никакой перезагрузки представления, никакого мигания. Это возможно по той же причине, по которой таблицу дёшево расширять: каждая видимая строка читается из таблицы в момент рендера, а не при запуске приложения.

Если добавим шестой язык — работа будет в переводе, не в инженерии.

---

## Как обновиться

Если ты на 1.1.0:

```bash
brew upgrade muvon/tap/vext
```

Или возьми DMG с [getvext.app/download](https://getvext.app/download).

Если ты новый пользователь:

```bash
brew install muvon/tap/vext
```

Существующие говорящие, диктовки, заметки и встречи сохраняются. Первая встреча, записанная в 1.2.0 — первая с двухпроходной диаризацией в действии.

[Полные релизные заметки Vext 1.2.0 →](https://getvext.app/blog/vext-1-2-0-multilanguage-speakers)

— Дон
