Короткая версия: за несколько месяцев на одной домашней видеокарте у крымскотатарского языка появились работающее распознавание речи (WER 0.3463 → 0.1701) и синтез, который прочитал вслух целую книгу — шестнадцать глав, 1 час 58 минут звучания. На обучение моделей из этого ушли часы. Всё остальное время съели данные, проверки и выяснение того, какие из моих собственных измерений врут.
Вот про эту вторую часть я и хочу рассказать, потому что она переносится на любой язык, а первая — нет.
Сразу оговорка про то, чего в статье не будет: конкретных источников аудио, договорённостей с правообладателями, точных рецептов пайплайна и внутренних скриптов. Будут метрики, порядки величин, методология проверки и грабли. Грабель много.
Откуда вообще растёт задача
Я делаю крымскотатарские языковые инструменты не первый год: словарь, конвертер между нашими двумя алфавитами (кириллица и латиница — любой крымскотатарский текст живёт в двух несовместимых версиях), разговорник. Когда ML стал доступен на домашнем железе, я взял то, что было под рукой: вычистил крымскотатарский речевой датасет и попробовал получить нормальный синтез. Датасет и модель тогда же выложил открыто. Качество было «ну, говорит».
План, который из этого вырос, звучит нескромно: пройти путь от словаря до голосового ассистента целиком. Речь — середина этого пути, и она пошла раньше переводчика по двум причинам.
Первая — календарь. Книга полежит на полке ещё двадцать лет, и с ней ничего не случится. Носители, чью живую речь можно записать, не молодеют: это единственная часть задачи с дедлайном, и назначен он не мной.
Вторая — маховик. ASR превращает старые записи в текст; текст плюс звук — готовые пары для TTS; TTS делает аудиокниги, их слушают, речи в обиходе становится больше, ASR учится дальше. Для языка без корпусов это единственный способ выбраться из бедности своими силами, а не ждать, пока на тебя обратит внимание кто‑то большой.
Стартовые условия
Их стоит перечислить явно, потому что почти каждое инженерное решение дальше выведено ровно из них.
Ноль пригодных речевых корпусов. Не «мало» — ноль. Ничего, на чём можно было бы обучать всерьёз.
Нет ASR — значит, нельзя разметить данные автоматически. Стандартный способ собрать TTS‑корпус («берём аудио, прогоняем распознавалкой, чистим») здесь схлопывается: плохое ASR порождает плохой корпус, из которого получается ещё одно плохое ASR. Круг замкнутый и крутится не в ту сторону.
Нет фонемизатора. Значит, вся ветка «TTS на IPA + espeak» отпадает: espeak крымскотатарского не знает, а писать правила самому — отдельный многомесячный проект с непроверяемым результатом.
Одна видеокарта. RTX 5090 Laptop, 24 ГБ, вечерами и ночами. Точечные облачные раны были, и про то, как я на них сжёг деньги почти без результата, будет отдельная статья.
Одно живое ухо. Моё. Носитель, доступный для слепого прослушивания в любой момент, — как выяснилось, самый производительный прибор во всей лаборатории.
Проблема размером в одну фонему
Слово qara, «чёрный». Готовый мультиязычный TTS с турецким языковым токеном произносил его как «кара» — аккуратно, мягко, по‑турецки. А надо «къара», с увулярным /q/.
Это не косметика: в крымскотатарском /q/ и /k/ — разные фонемы, и на /q/ стоят самые частотные слова (yoq, qız, vaqıt, qadar). Модель, которая их не различает, ошибается не изредка, а в каждом втором предложении.
Очевидный путь — «возьми ближайший крупный тюркский» — ведёт ровно не туда:
|
база |
что происходит с /q/ |
|---|---|
|
турецкий |
увулярного /q/ в инвентаре нет вообще, только велярный /k/ (аллофоническое отодвигание перед задними гласными — не то) → /kɑɾɑ/, измерено дважды |
|
азербайджанский |
⟨q⟩ исторически стал велярным /ɡ/ и внутри морфемы ленируется дальше до [x] → /ɡ/ |
|
казахский, уйгурский, башкирский |
/q/ фонемный, и у него есть собственная буква: қ, ҡ, ق |
То есть «интуитивно близкие» языки — это ровно те два, которые не подходят, а подходят те, о которых никто в первую очередь не думает. Дообучение на трёх часах эту дырку не закрывало вообще.
Четыре решения, на которых всё держится
1. Инверсия задачи выравнивания
Раз распознавать нечем — не будем распознавать. У аудиокниг уже есть точный текст: он напечатан. Значит, задача не «узнать, что сказано», а «совместить известный текст со звуком и нарезать по границам слов». Forced alignment вместо ASR. Одним движением обходится ровно то, чего у языка нет.
Дальше начинается то, о чём в статьях обычно не пишут.
Один прогон на 7.4 часа записи честно отработал 115 минут на скорости ~1.8 слова/с, потом просел до 0.67 слова/с и молча испортил 3.5 часа аудио. Без исключения, без варнинга, с уверенными и полностью неправильными таймингами на выходе. Из семи с лишним часов пригодными остались 110 минут.
Такие отказы — худший класс. Упавший процесс это подарок: ты сразу видишь, где. Процесс, который выдаёт правдоподобный мусор, забирает неделю.
Починка была не в коде выравнивателя, а в постановке. Вместо трёх больших кусков — сорок глав, каждая ищется в тексте отдельно. И искать надо не по словам, а по символьным n‑граммам: на паре «глава ↔ фрагмент текста», про которую я точно знал, что она совпадает, пересечение по словным n‑граммам составило 0.8%. По символьным 6-граммам нашлись 38 глав из 40. Причина, если задуматься, очевидна: у архивного аудио и печатного текста расходятся орфография, пунктуация, дореформенные написания, диалектные формы — на уровне слова это разные строки, на уровне цепочки букв это одна и та же строка.
Итог: 336 минут чистого звука из того же материала. 76% против 25%.
Каждая проверка в пайплайне появилась после конкретной аварии, и одна из них учит общему: сторож дрейфа, откалиброванный по началу собственного прогона, не увидит прогон, который разъехался с первого чанка. Относительный порог здесь бесполезен, нужен абсолютный пол.
2. Выбор базовой модели по фонетическому приору
Прежде чем чинить /q/, я проверил гипотезу «модель просто не видела этого звука». Не подтвердилась: буква q встречалась в 88% записей моего набора, чаще, чем k. Модель слышала правильное произношение тысячи раз.
Дело было глубже: в самой базе, до всякого дообучения, графема q уже была намертво озвучена как /k/ — по английскому, испанскому, французскому. Файнтюн этого не перебивал. Вывод неприятный, но полезный: сколько данных ни лей, не поможет; менять надо не количество данных, а фундамент.
Поэтому базу я выбирал по одному признаку: чтобы она уже умела произносить нужный звук. Требований набралось три:
-
текст на вход идёт как BPE‑графемы, без фонемизатора в инференс‑пути (см. стартовые условия);
-
выход 24 кГц, а не 16 — «металлический потолок» 16 кГц убил предыдущую ветку;
-
в обучающей смеси — реальные часы языков с фонемным /q/.
Нашлась многоязычная модель на 0.6B поверх Qwen3-архитектуры, с 24-кГц аудиотокенайзером и zero‑shot клонированием голоса. Конкретное имя не важно (и через год оно всё равно будет другим), важен признак, по которому я её отбирал. В её смеси: казахский 1537 ч, уйгурский 429 ч, башкирский 249 ч — против турецкого 125 ч и азербайджанского 9.8 ч. То есть на порядок больше именно тех языков, которые интуиция подсказывает в последнюю очередь.
Крымскотатарского в ней, разумеется, нет — своего языкового id не существует. Обходится это орфографией. Крымскотатарская кириллица пишет три «твёрдых» согласных диграфами: къ, гъ, нъ. Символьный токенайзер режет къ на к + ъ, и пара учится слабо. А башкирская кириллица пишет ровно те же звуки одиночными буквами: ҡ, ғ, ң, которые модель уже знает.
Механическая замена алфавита — и модель управляется как башкирская. Никакой транслитерации: крымскотатарский текст берётся дословно, меняется только письмо. Два независимых дефекта (отсутствующая фонема и диграф) закрываются одним отображением.
Работает или нет, было видно до всякого обучения: база выговаривала наш /q/ правильно в 88–100% случаев. Я перестал учить модель новому звуку и начал просто не мешать ей.
Первый пробник, которым я это мерил, был спектральный: рендерим фразу с q и с k, сравниваем спектральный центроид взрывов. Он выдал шум — и правильно сделал. Это две независимые стохастические генерации: они отличаются длительностью и просодией, детектор онсета попадает на разные события, и дельта меряет сэмплер, а не согласный. Рабочим оказался синтез + обратная вычитка CTC‑моделью, у которой в алфавите есть и q, и k, с двумя контролями: та же фраза дважды (шумовой пол) и та же фраза через k (не изобретает ли цепочка q сама). Даже так пробник сказал «модель реагирует на букву», но не смог ранжировать языковые id — контроль оказался несопоставим между строками. Признать «мои цифры не различают условия» было дешевле, чем изобрести результат.
Казахский и башкирский варианты я довёл до конца оба. По пиковой метрике — ничья до третьего знака: CER 0.136 ± 0.003 против 0.136 ± 0.005. Выбрал башкирский, и не за пик: у казахской лестницы обнаружилась воспроизводимая дыра — на определённом шаге четыре клипа возвращались пустыми, а retention /q/ проваливался до 62%. При равных метриках выигрывает тот, у кого лучше режим отказа.
3. LoRA, а не полный файнтюн — по той же причине, что и выбор базы
Весь смысл этой базы — в знаниях, которые в ней уже есть. Полный файнтюн на шести часах — это ровно тот способ, которым такие знания затираются. Прецедент, из которого я брал рецепт, использовал ~150 часов и всё равно опускал lr до 2e-5, чтобы защитить базу. Заморозить её и учить адаптер — единственный вариант, который сохраняет то, ради чего база и бралась.
Та же логика сработала и на ASR‑стороне, где цифры получились самые внятные:
|
|
WER |
CER |
|---|---|---|
|
базовая модель (whisper‑large‑v3, ранее дотюненная под crh) |
0.3463 |
0.1188 |
|
+ LoRA |
0.2010 |
0.0938 |
|
+ настройка декодирования, без единого изменения весов |
0.1701 |
0.0702 |
Стоимость второй строки: LoRA r=32 на q/k/v/out проекциях, 31.4 М обучаемых параметров из 1.57 B (2.0%), 15.5 часа аудио, 705 шагов = 3 эпохи, 1 час 30 минут на одной ноутбучной видеокарте, пик 10.4 ГБ VRAM. Адаптер — 126 МБ.
Стоимость третьей строки: ноль. Про неё отдельно ниже, потому что там самое интересное не выигрыш, а его разбор.
4. Сплит, который надо построить раньше модели
Это то, ради чего я готов написать всю статью заново.
Случайный сплит по клипам здесь бесполезен: соседние клипы — это одна сессия, один диктор, а выравниватель регулярно режет одно предложение на два соседних клипа. Половина предложения в трейне, вторая в тесте. Поэтому холд‑аут целыми книгами: разом убираются диктор, тракт записи, эпоха и лексика.
Но настоящая ловушка была не в этом. Одни и те же четыре аудиокниги лежали в корпусе дважды, нарезанные двумя разными пайплайнами под совершенно не связанными схемами id. По именам файлов и id пересечение — нулевое. Обнаружились двойники только поиском по общим словным 6-граммам:
|
книга в холд‑ауте |
доля клипов, у которых нашёлся двойник в обучающем пуле |
|---|---|
|
книга A |
82.1% |
|
книга B |
69.8% |
|
книга C |
96.9% |
|
книга D |
58.8% |
96.9%. Если бы я этого не поймал, модель сдавала бы экзамен по билетам, которые уже видела. Я бы получил красивое число и опубликовал бы ноль информации.
Вывод, который я считаю главным практическим уроком проекта: в низкоресурсном корпусе, собранном несколькими пайплайнами по одним и тем же немногочисленным записям, пересегментированные дубликаты — это состояние по умолчанию, а не редкий инцидент. Проверять надо текстовым пересечением, а не именами файлов и не хешами аудио.
Дальше — дисциплина: чекпоинт и конфиг декодирования выбираются на dev (255 клипов, две отдельные короткие вещи, вынутые целиком; 0 общих id и 0 общих словных 6-грамм с трейном), тест декодируется ровно один раз на решение.
Что ломалось
Метрика встала, а качество продолжало расти
Самый полезный отрицательный результат за всё время.
Корпус рос: 5.9 ч → 11.5 ч → 15.3 ч. Качество TTS я мерил обратной вычиткой — синтезируем, распознаём, считаем CER относительно исходного текста. Получилось:
|
корпус |
CER (враждебный пробный набор, среднее из 3 замеров) |
ухо |
|---|---|---|
|
база, без нашего языка |
0.173–0.198 |
акцент, но читает |
|
5.9 ч |
0.136 ± 0.003 |
— |
|
11.5 ч |
0.138 ± 0.005 |
просодия заметно живее |
|
15.3 ч |
0.141 ± 0.019 |
ещё немного лучше |
Данные выросли в 2.6 раза, метрика формально ухудшилась. Разброс между повторными замерами одного и того же чекпоинта перекрывает всю разницу целиком, так что честная формулировка одна: по этой метрике три прогона неразличимы.
А слепое прослушивание каждый раз выбирало более новый голос. Устойчиво.
Корпус вырос в два с половиной раза, машинная метрика стоит на месте — а слепое прослушивание каждый раз выбирает более новый голос.
Объяснение оказалось важнее самого факта: разборчивость насыщается рано, часов на шести. Просодия продолжает улучшаться дальше. А CER по обратной вычитке останавливается ровно там, где начинается остаток качества. Человек, который посмотрел бы на эту таблицу и прекратил сбор данных на шести часах, сообщил бы чистую правду и принял неправильное решение.
Оттуда же правило, которое стоило мне нескольких вычеркнутых «побед»: одиночный замер CER врёт. Один и тот же чекпоинт дал 0.125 в одиночном прогоне и 0.138 при усреднении трёх. Две серии повторов одного чекпоинта разошлись сильнее, чем их собственная σ. В этой постановке всё, что меньше ~0.02 CER, — не сигнал. Задним числом это обесценило несколько моих же более ранних решений.
И отдельное: всегда пишите, на каком наборе взята цифра. Тот же чекпоинт даёт 0.142 на враждебном пробнике и 0.0184 на обычной прозе. У меня в журнале какое‑то время ходили обе цифры как «метрика модели», и это чистое самозаражение.
Прибор, который описал сам себя
Я искал, переносится ли придыхательность референсного клипа на рендер. Нашёл корреляцию: r = +0.558, p = 0.038, n = 14. Выглядит публикабельно.
Это был детектор. При корректном измерении связь оказалась r = +0.137, ρ = +0.270, p = 0.35 — то есть ничего.
Тот же прибор подвёл меня второй раз, в противоположную сторону. Триммер вдохов искал «первый озвонченный фрейм» через librosa.pyin — а он по построению не может увидеть глухой онсет: /t/, /q/, /ç/, /ş/. Результат: у слов отрезалось начало. Tışta возвращалось как yeşergen, Qız — как saçlarını. На 147 клипах pyin промахивался мимо истинного онсета на медианные 43 мс, максимум 384 мс. Починка была не в пороге, а в вопросе: перестать спрашивать «где начинается голос» и начать спрашивать «где растёт уровень» — то есть задать наконец тот вопрос, который имелся в виду.
Общая форма обоих случаев: если ваш измеритель и ваш объект разделяют одно допущение, найденная корреляция может быть этим допущением. Второй случай — причина верить первому: один и тот же инструмент дважды за проект соврал в разные стороны.
43.8 секунды дрейфа, которых не было в звуке
Самый коварный баг, и заметил я его только потому, что тыкал в отдельные предложения, а не слушал главу насквозь.
Нажал на 195-е предложение озвученной главы — услышал не то. Расхождение росло линейно:
|
предложение |
сдвиг разметки |
|---|---|
|
1 |
0 с |
|
49 |
+10.7 с |
|
98 |
+21.8 с |
|
147 |
+33.0 с |
|
195 |
+43.9 с |
Первая гипотеза была очевидной и неверной: «модель разъезжается на длинной форме». Проверка этой гипотезы — то, ради чего историю стоит рассказывать: сходство диктора с первой минутой держалось 0.96–0.98 до самого конца, темп был плоский. Со звуком всё было в порядке.
Баг был в том, что один и тот же таймлайн независимо считали два куска кода. Сборщик добивал каждый стык до целевой паузы за вычетом тишины, которую соседние клипы уже несут в себе; измеритель восстанавливал таймлайн как «длительность + целевой зазор», учитывая эту тишину дважды. 43.8 с — ровно сумма одного слагаемого по 194 стыкам.
Проверял я не чтением кода, а ножом: нарезал звук по старым и по новым отметкам и прогнал куски через распознавание. CER 0.712 по старым, 0.008 по новым. На последней строке старая отметка ушла за конец файла, и распознавалка добросовестно «услышала» в тишине свои дежурные субтитровые титры.
Мораль в двух частях. Техническая: один таймлайн, один владелец, записан явно; сборка падает, если конец последнего предложения и длина файла расходятся больше чем на полсекунды. Неприятная: этот баг несколько недель портил мои собственные оценки на слух — я судил модель по предложениям, которые она в этот момент не читала.
Слово, произнесённое безупречно
Слушаю главу — вместо «нидже» слышу «ниже». Полез отлаживать, а отлаживать нечего: правило переписывания алфавита, то самое с ҡ и ғ, заодно отображало дж → ж. Модели буквально подали строку «ниже». Она произнесла её идеально.
Правило склеило контраст, который в языке есть: /dʒ/ в исконных словах (джан, оджа, нидже) и /ʒ/ в заимствованиях («журналист»). После склейки ничто ниже по цепочке уже физически не могло произнести их по‑разному.
Таких слов в главе было 55. Заметил я одно — единственное, которое случайно попало в другое реально существующее слово. Остальные 54 звучали «чуть не так», и ухо на них не спотыкалось.
Исправление не изменило метрику: WER 0.0903 против 0.0935 на 195 предложениях, внутри шума. И всё равно было правильным.
Эмоции, которые опроверг носитель
История той же формы, поэтому коротко. Референсы для стилевого переноса размечал эмоциональный классификатор; сам перенос я измерил на 105 рендерах, и эти цифры настоящие: тембр и высота r = 0.91, темп r = 0.64, ширина интонации r = 0.66 но сжата втрое, динамика громкости r = 0.27 (то есть не переносится вовсе).
Потом я прослушал все 15 референсов вслепую и в 11 из них услышал ровный нейтрал — при том, что классификатор на этом материале не вернул «нейтрально» ни разу. Пайплайн точно измерял перенос и неверно размечал вход. Метки англоязычного классификатора эмоций пережили контакт с носителем один раз из четырнадцати.
Где всё это оказалось
ASR. 0.3463 → 0.1701 WER, 0.1188 → 0.0702 CER. Вторая половина пути — LoRA, третья — только декодирование.
Слева направо: исходная модель, после дообучения, после настройки декодирования.
Про «бесплатные» 0.2010 → 0.1701 стоит сказать честнее, чем это обычно делают. Beam search (4 луча) отыграл 409 словных ошибок из 2664. Но 160 из этих 409 (39%) пришли всего с трёх клипов из 893, где жадный декодинг сваливался в петлю вида – dedi Akimov,– dedi Akimov,…. На остальных 890 клипах это не −15%, а −10% относительных. Заголовочная цифра верна, но собрана из двух разных явлений, и знать это полезнее, чем её саму.
И встречный результат, ради которого стоило проверять: no_repeat_ngram_size — очевидная ручка против петель — делает хуже, монотонно по n. Под жадным декодингом n=3 ломает 17 клипов и чинит один, причём 12 из 17 были уже практически правильными. Механизм видно прямо в гипотезах: запрет не мешает модели повторяться, он заставляет её повторяться неправильно.
ref – Qartbaba, qartbaba, – tez-tez çapıp kelgen kiçkene Aziz
greedy – Qartbaba! Qartbaba! – tez-tez çapıp kelgen kiçkene Aziz
n=3 – Qartbaba! Qartbabaa! – tez-tez çapıp kelgen kiçkene Aziz
n=4 – Qartbaba! Qartbava! – tez-tez çapıp kelgen kiçkene Aziz
Удвоенное обращение и удвоенная мольба — обычный крымскотатарский. Запрет на повтор n‑граммы не отличает риторический повтор от разгонной петли, а в агглютинативном языке с богатой суффиксацией второй случай встречается постоянно.
TTS. Синтезированный голос прочитал целую книгу — роман Şamil Alâdin “Merdiven”. Не отрывок и не одну демонстрационную главу:
|
глав |
16 |
|---|---|
|
слов |
15 444 |
|
звучание |
1 ч 58 мин |
|
машинного времени |
177 минут, то есть ×1.5 к длительности звука |
|
покрытие пословных таймингов |
100% слов во всех 16 главах |
|
CER обратной вычитки по главам |
медиана 0.022, худшая 0.027, лучшая 0.015 |
Каждая глава проверялась отдельно, а не книга по одной случайной главе. Разброс лучшей и худшей главы — меньше двух раз, деградации к концу книги нет, зависимости от длины главы нет.
Отдельно я доказал измерением, а не рассуждением, что текст книги не попал в обучение: ноль общих словных n‑грамм против почти полутора миллионов слов корпуса. Иначе это было бы не чтение, а пересказ заученного.
Тайминги проверялись с контролем, который обязан провалиться: по 30 слов из каждой главы, вырезанных ровно по записанным отметкам, отправлялись на распознавание — совпало 447 фрагментов из 480. Те же фрагменты, сдвинутые на 0.4 с, совпали 4 раза из 480. То есть проверка действительно умеет отличать правильную отметку от неправильной, и «совпало» здесь не артефакт метода. Контроль, который не проваливается, ничего не проверяет.
Практический вывод, который мне кажется важнее всех метрик: два часа звучания стоят примерно три часа машинного времени — со всем включённым: синтез, пословное выравнивание, проверки, полная обратная вычитка каждой главы. Аудиокнига на языке без индустрии стоит одну ночь работы одного домашнего компьютера. Не студию с дикторами и не год ожидания.
Честный список того, что модель до сих пор делает плохо: вдыхает не там, где нужно; зачитывает отдельные буквы слитным слуром; ставит ровное ударение там, где носитель ударяет последний слог. Статья не должна быть мягче, чем limitations в карточке собственной модели.
Куда на самом деле ушло время
Обучение — часы. Полтора часа тут, несколько часов там. Это вообще не главная статья расходов, и это, наверное, единственная новость в статье, которая кого‑то по‑настоящему удивит.
Месяцы ушли на: сбор и выравнивание данных с починкой того, что ломалось молча; доказательство честности сплита; перемеры, потому что одиночное измерение ничего не значит; и прослушивание — ушами, вслепую, по одному предложению.
Под конец я подвёл баланс: все найденные за проект ошибки разделились примерно поровну между приборами и живым человеком. Приборы нашли 43-секундный дрейф разметки, который ухом не поймать никогда — при сквозном прослушивании всё звучит правильно. Человек нашёл вдохи, опроверг эмоциональные метки и услышал то самое «нидже».
Для низкоресурсного языка самый дешёвый и самый сильный инструмент — один носитель, пятнадцать минут и наушники. Он переигрывает любую автоматику в стеке, и, что приятно, чтобы им быть, не нужно уметь программировать. Достаточно хорошо слышать родной язык.
Это первая статья серии. Дальше планирую: как машину учили слышать крымскотатарскую речь; как озвучивалась книга, глава за главой, и что ломалось на стыках; как ставить слепые сравнения, чтобы не обманывать самого себя; и как я сжёг деньги на аренде облачных видеокарт, посчитав на них почти ничего — там набралось десять тихих блокеров, каждый со своим ценником.
До ассистента ещё далеко. Но участок, который считался непроходимым, оказался проходимым — и, судя по всему, не только для крымскотатарского.
Примеры голоса и текущее состояние проекта на сайте.
Если вы носитель крымскотатарского и готовы помогать — слепые прослушивания, вычитка, «пятнадцать минут и наушники» — напишите на support@ana‑yurt.com: выше видно, что одно живое ухо в этом проекте стоит больше любого прибора. Поддержать работу материально можно на ko‑fi.com/anayurt.
Автор: servinosm
