Vext 1.1 вышел три недели назад. Vext 1.2.0 выходит сегодня.

getvext.app · 1.1.0 → 1.2.0

Пользовательский анонс 1.2.0 рассказывает о том, что нового в приложении — вкладка «Говорящие», полноценный мультиязычный интерфейс, более чёткие транскрипции встреч. Этот пост — технический разбор: проблемы, которые было по-настоящему сложно решить, и как они выглядят в коде сейчас.


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

Транскрипция встреч в реальном времени имеет одну степень свободы, которую мы не можем восстановить позже: она слышит аудио один раз, по порядку. Потоковый диаризатор присваивает каждому 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:

brew upgrade muvon/tap/vext

Или возьми DMG с getvext.app/download.

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

brew install muvon/tap/vext

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

Полные релизные заметки Vext 1.2.0 →

— Дон