- PVSM.RU - https://www.pvsm.ru -

Речевые технологии для языка, у которого не было ни одного датасета — на одной потребительской видеокарте

Короткая версия: за несколько месяцев на одной домашней видеокарте у крымскотатарского языка появились работающее распознавание речи (WER 0.3463 → 0.1701) и синтез, который прочитал вслух целую книгу — шестнадцать глав, 1 час 58 минут звучания. На обучение моделей из этого ушли часы. Всё остальное время съели данные, проверки и выяснение того, какие из моих собственных измерений врут.

Вот про эту вторую часть я и хочу рассказать, потому что она переносится на любой язык, а первая — нет.

Сразу оговорка про то, чего в статье не будет: конкретных источников аудио, договорённостей с правообладателями, точных рецептов пайплайна и внутренних скриптов. Будут метрики, порядки величин, методология проверки и грабли. Грабель много.

Откуда вообще растёт задача

Я делаю крымскотатарские языковые инструменты не первый год: словарь, конвертер между нашими двумя алфавитами (кириллица и латиница — любой крымскотатарский текст живёт в двух несовместимых версиях), разговорник. Когда ML стал доступен на домашнем железе, я взял то, что было под рукой: вычистил крымскотатарский речевой датасет и попробовал получить нормальный синтез. Датасет [1] и модель [2] тогда же выложил открыто. Качество было «ну, говорит».

План, который из этого вырос, звучит нескромно: пройти путь от словаря до голосового ассистента целиком. Речь — середина этого пути, и она пошла раньше переводчика по двум причинам.

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

Вторая — маховик. 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-секундный дрейф разметки, который ухом не поймать никогда — при сквозном прослушивании всё звучит правильно. Человек нашёл вдохи, опроверг эмоциональные метки и услышал то самое «нидже».

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


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

До ассистента ещё далеко. Но участок, который считался непроходимым, оказался проходимым — и, судя по всему, не только для крымскотатарского.

Примеры голоса и текущее состояние проекта на сайте [3].

Если вы носитель крымскотатарского и готовы помогать — слепые прослушивания, вычитка, «пятнадцать минут и наушники» — напишите на support@ana‑yurt.com [4]: выше видно, что одно живое ухо в этом проекте стоит больше любого прибора. Поддержать работу материально можно на ko‑fi.com/anayurt [5].

Автор: servinosm

Источник [6]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/languages/457785

Ссылки в тексте:

[1] Датасет: https://huggingface.co/datasets/servinosmanov/tts-crh-sevil-fixed

[2] модель: https://huggingface.co/servinosmanov/xtts-crh-sevil-v1

[3] на сайте: https://ai.ana-yurt.dev/blog/2026-09-kak-rodilas-ideya/

[4] support@ana‑yurt.com: mailto:support@ana-yurt.com

[5] ko‑fi.com/anayurt: http://ko-fi.com/anayurt

[6] Источник: https://habr.com/ru/articles/1081242/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1081242