dimhold.by
← Статьи

Мой живой транскрайбер на Whisper ошибался в 2,6 раза чаще, чем Whisper на целом файле

С сентября я прогоняю записи звонков через Whisper, когда звонок уже кончился. Это работает, но текст приходит, когда он мне уже не нужен. Я хотел получать его, пока люди ещё говорят, поэтому написал самую простую живую версию, какую смог придумать: прочитать 5 секунд звука, отдать их в transcribe(), напечатать текст, повторить.

Это около 40 строк на Python. ffmpeg пишет сырой PCM 16 кГц в stdout, скрипт его читает и раз в несколько секунд появляется строка текста. Какое-то время меня это устраивало. Потом я прогнал ту же нарезку по главе аудиокниги, прочитал целую страницу вывода и увидел вот это:

... a golden fortune, and a-
a happy life. He was like unto my father in a way, and yet was not my f-.
father. Also, there was a stripling page who turned into a maid.

Каждая строка это один кусок по 5 секунд. В конце куска Whisper видит половину слова и пишет f-.. В следующем куске он видит вторую половину и пишет father ещё раз. Конец другого куска вышел как thank waitags heißa, а это не язык.

Я считал, что нарезка изредка съедает по слову и ни разу этого не мерил. Теперь померил.

Стенд

Мне нужна была речь с заранее известным верным текстом, поэтому свои звонки я не брал. В LibriSpeech test-clean лежат книги, прочитанные вслух, с точными расшифровками. Я взял 4 чтецов, из них 2 женщины и 2 мужчины. Для каждого склеил первую главу в один поток, а где глава длиннее, обрезал её до 5 минут. Вместе это 19 минут и около 3200 слов.

Whisper взят из репозитория по состоянию на 9 декабря, модель base, на процессоре в fp32, язык я задал английский. На машине 4 виртуальных ядра AMD EPYC 7713 и torch 1.12.1. Долю неверных слов я считаю после английского нормализатора текста из самого Whisper, так что запятые и “Mr” против “mister” ошибкой не считаются.

Точка отсчёта это целый 5-минутный файл, отданный в transcribe() за один раз. Так я пользовался Whisper с сентября и это дало 5,79% неверных слов.

Во что обходится нарезка

Версия с кусками по 5 секунд дала 14,89%, то есть в 2,6 раза больше ошибок на том же звуке. Я разделил ошибки по месту и считал соседними со стыком первые 2 и последние 2 слова каждого куска. В эту зону попадает около 30% слов и 252 ошибки. На остальные 70% приходится 224, а у целого файла на все его слова было 185 ошибок. Значит, середину куска Whisper тоже читает хуже, просто не так плохо, как края. Лишних слов стало 148 вместо 18 и примерно половина из них стоит прямо у стыка.

Первым делом я заподозрил случайность. Вдруг эта сетка разрезов просто задела необычно много слов? Я сдвинул её на 2,5 секунды и прогнал ещё раз. Вышло 14,11%, то есть положение сетки меняет результат меньше чем на процентный пункт.

файл целиком a golden fortune and a happy life. He was like unto my father, in a way, and yet was not my father. куски по 5 с a golden fortune, and a- a happy life. He was like unto my father in a way, and yet was not my f-. father. Also ... рез в тишине от 4 до 6 с a golden fortune and a ha- be life, he was like unto my father in a way, and yet was not my father. строка = один кусок
Whisper base, один и тот же чтец, одна и та же фраза. Цветом отмечены слова, сломанные резом.

Потом я попробовал 3 правки, которые казались очевидными.

  1. Брать куски длиннее. 10 секунд дали 11,76%, 30 секунд дали 8,20%. Лучше, но на 30 секундах текст приходит с опозданием в полминуты.
  2. Отдавать Whisper текст прошлого куска как initial_prompt. Вышло 14,02%, практически то же самое. Я ждал от этого чуть большего и не знаю, почему помогло так мало.
  3. Резать в тихом месте. Скрипт ищет между 4 и 6 секундами самые тихие 100 миллисекунд и режет посередине этого отрезка. Это дало 10,01%, самый большой выигрыш при той же задержке.

Рез в тишине заодно показал, где я ошибался. В той же фразе он выдал a ha- и be life, потому что самые тихие 100 миллисекунд в этом окне оказались внутри слова “happy”. Подозреваю короткую паузу перед “p”, но на осциллограмме я это не проверял.

Почему даже 30 секунд хуже целого файла

Это меня удивило. Whisper и так смотрит на звук окнами по 30 секунд, поэтому я ждал, что куски по 30 секунд дадут то же, что целый файл. Они дали 8,20% против 5,79%.

Часть ответа лежит в transcribe(). Когда он читает длинный файл, следующее окно он начинает с последней временной метки, на которой закончилась фраза. Слово, разрезанное краем окна, просто читается ещё раз вместе с тем, что идёт после него. Мой скрипт режет звук до того, как его увидит Whisper, поэтому вернуться Whisper не может. Всего это не объясняет. Из 77 ошибок сверх целого файла 33 приходятся на середину кусков. Может быть, дело в потерянном тексте прошлого куска, который transcribe() внутри файла держит как контекст, но отделить одно от другого я не пробовал.

Тот же код объясняет мусор на концах кусков. Если кусок кончается посреди фразы, transcribe() декодирует оставшийся хвост ещё раз отдельным окном. Когда результат декодера выглядит плохо, Whisper повторяет попытку при более высокой температуре. В 20 из 235 кусков по 5 секунд хотя бы один из следующих отрезков декодировался при температуре 0,6 и выше. В куске с heißa температура дошла до 1,0.

Во что это обходится процессору

Перед энкодером transcribe() дополняет спектрограмму каждого куска тишиной до 30 секунд. На этой машине энкодер base тратит около 1,2 секунды на дополненный кусок в 5 секунд. На настоящие 30 секунд уходит столько же. Целый файл стоит 0,17 секунды работы на секунду звука, куски по 5 секунд стоят 0,67, это в 4 раза больше.

как подаётся звук неверных слов нагрузка файл целиком 5,79% 0,17 куски по 30 с 8,20% 0,24 рез в тишине 10,01% 0,61 куски по 10 с 11,76% 0,64 5 с + подсказка 14,02% 0,56 5 с со сдвигом 14,11% 0,68 куски по 5 с 14,89% 0,67 base small
Каждая конфигурация прогнана один раз на 4 ядрах процессора. Нагрузка это секунды работы на секунду звука.

Основные варианты я прогнал и на small, следующей по размеру модели. На целом файле она дала 3,60%, на кусках по 5 секунд 13,80%, с резом в тишине 7,57%. На этой машине кусок в 5 секунд занимает у small около 12 секунд, так что вживую она отставала бы от речи.

Что я взял в живую версию

Я оставил рез в тишине между 4 и 6 секундами и модель base. Одну главу я проиграл через неё в реальном времени. На 51 куске из 52 Whisper тратил от 1,6 до 4,4 секунды. Один раз ушло 11,4 секунды и эта строка кончилась на “Who couldn’t believe for safety”, чего чтец не говорил. Если прибавить кусок длиной до 6 секунд, слово обычно попадало на экран примерно через 10 секунд. Вокруг того зависания слово доходило до экрана примерно за 17 секунд.

У этого чтеца живой текст дал 3,83% неверных слов против 2,63% у целого файла. По всем 4 чтецам рез в тишине даёт в 1,7 раза больше ошибок, чем целый файл, поэтому храню я по-прежнему файл, расшифрованный после звонка. Живой текст нужен мне только для того, чтобы следить за разговором.

Правильная починка это, скорее всего, перекрывающиеся окна. Оставлять часть прошлого звука и расшифровывать её ещё раз, как это делает transcribe() внутри файла. В whisper.cpp что-то подобное уже есть, в примере stream. Каждые 3 секунды он заново расшифровывает окно длиной до 10 секунд. Начиная новую строку, он оставляет 200 миллисекунд старого звука. whisper_mic режет звук с микрофона на паузах от 0,8 секунды. Ни того, ни другого я не мерил.

Чего я не проверял

  • Настоящий разговор. Это книги, которые читает один человек в тихой комнате. Двое, перебивающие друг друга, изменят все числа, скорее всего не в одной пропорции.
  • Настоящий микрофон. Через ffmpeg в реальном времени прошёл только живой прогон. С микрофона я ничего не записывал. Все остальные числа получены нарезкой файлов напрямую.
  • Больше одного прогона. Из-за повторных попыток при повышенной температуре некоторые куски при повторе могут декодироваться иначе, так что небольшие расхождения вроде 14,11 против 14,02 ничего не значат.
  • Видеокарту. Я её не пробовал. Там дополнение до 30 секунд должно стоить гораздо меньше времени.