Для развития навыков чтения и кругозора, школа дает список для внеклассного чтения. Признайтесь, сколько книжек за лето прочитал вслух ваш спиногрыз? Сидели ли вы рядом с измученным ребенком мечтающим погулять в свои законные каникулы, что бы внимательно слушать как он читает книжку и периодически его поправлять?
А может вы и сами стараетесь почитывать иностранную литературу, в попытках подучить иностранный язык?
Да, для этого есть репетиторы. Но их услуги стоят денег. И не всегда есть возможность пригласить репетитора в удобное для вас время.
ИИ нам поможет! Конечно весь спектр услуг репетитора он заменить пока не может (по крайней мере бесплатно), но функцию внимательно слушающего вашу речь - вполне.
Идея простая: открываем текст на (почти) любом интересующем языке, и читаем в микрофон. Если ИИ распознал слово из вашего голосового потока - вы молодец. Не распознал - повторяем слово или фразу. И так до победного.
Распознанное слово - это гарантия того что вы правильно его прочитали.
Из чего будем делать?
Запускаем Visual Studio 2022, создаем проект на c# (WinForms).
Делаем форму с возможностью загрузить текст и кнопку "Читать". За распознавание прочитанного будет отвечать Vosk (https://alphacephei.com/vosk/ https://github.com/alphacep/vosk-api). В наличии 20+ языков, должно хватить.
Для удобства добавим кнопки: увеличить/уменьшить шрифт, выравнивание текста, автопрокрутка (прочитанный текст центрируется по середине).
Технология чтения
Итак, пользователь читает текст с экрана, и произносит его вслух в микрофон. Нам понадобится через NuGet установить пакеты Vosk
, NAudio
, Newtonsoft.Json
, что бы сделать вот так:
Создаем "распознавателя" слов
//логирование
Vosk.Vosk.SetLogLevel(-1);
//создаем объект "модель", указываем папку где она лежит
var model = new Model(modelPath);
//создаем объект "распознаватель"
var recognizer = new VoskRecognizer(model, 16000.0f);
//отключаем вывод альтернативных распознанных фраз
recognizer.SetMaxAlternatives(0);
//включаем вывод массива слов
recognizer.SetWords(true);
Слушаем микрофон
//создаем объект
WaveInEvent waveIn = new WaveInEvent();
//выбираем микрофон (первый в системе по умолчанию для простоты)
waveIn.DeviceNumber = 0;
//обязательно выставляем такой формат для vosk
waveIn.WaveFormat = new WaveFormat(16000, 1);
//событие поступления данных от микрофона
waveIn.DataAvailable += WaveIn_DataAvailable;
//запуск
waveIn.StartRecording();
Распознаем
private void WaveIn_DataAvailable(object sender, WaveInEventArgs e){
//пытаемся распознать
if (recognizer.AcceptWaveform(e.Buffer, e.BytesRecorded))
{
//результат полностью распознанной строки
string text = recognizer.FinalResult();
//{
// "result" : [{
// "conf" : 0.639870,
// "end" : 0.600000,
// "start" : 0.360000,
// "word" : "это"
// }, {
// "conf" : 0.435801,
// "end" : 0.750000,
// "start" : 0.600000,
// "word" : "текст"
// }],
// "text" : "это текст"
//}
}
else
{
//распознана какая-то часть
string text = recognizer.PartialResult();
//{
// "partial" : "это"
//}
}
}
Теперь можно преобразовать текст в объект для удобства:
private class RecognizeWord
{
public double conf { get; set; }
public double end { get; set; }
public double start { get; set; }
public string word { get; set; }
}
private class RecognizeResult
{
public RecognizeWord[] result { get; set; }
public string partial { get; set; }
public string text { get; set; }
}
//десереализуем
RecognizeResult values = JsonConvert.DeserializeObject<RecognizeResult>(text);
//преобразуем
List<string> words = new List<string>();
if (values.text != null && values.text.Length > 0)
{
words.AddRange(values.result.Select((f) => f.word).ToList());
}
if (values.partial != null && values.partial.Length > 0)
{
words.AddRange(values.partial.Split(" "));
}
Почему сразу два метода? Чем больше - тем быстрее, а значит и отзывчивее будет распознавание текста.
Вызов recognizer.FinalResult();
намного реже чем recognizer.PartialResult();
, а нам нужно пободрее.
У нас есть исходный текст, и распознанный текст, осталось только пометить распознанное слово в исходном тексте. Для этого необходимо последовательно "сканировать" все слова в исходном слове на совпадение с тем что мы распознали. Ну а что бы не сканировать каждый раз с самого начала текста, нужно запомнить позицию до который мы уже распознали слова в исходном тексте, и начинать следующее сканирование с этой позиции.
Ниже небольшая иллюстрация в которой наглядно показан этот процесс. Красная линия - позиция с которой начинаем распознавание. Эту позицию мы устанавливаем если распознанное слово совпадает с тем которое проверяем. Конечно необходимо игнорировать пробелы, знаки препинания и прочее, в том числе и регистр букв (ведь Ёлочка != ёлочка).

Попробуем реализовать чтение в коде
using System.Text;
using System.Text.RegularExpressions;
using static System.Net.Mime.MediaTypeNames;
//исходный текст
var text = "В лесу родилась ёлочка.";
//позиция до которой мы прочитали текст
var index = 0;
//поехали!
read("в");
read("лесу");
read("родился");
read("родилась");
read("ёлочка");
void read(string readWord)
{
//Console.Write("check " + readWord + ": ");
//получаем текст с позиции указанной в индексе
var readtext = text.Substring(index);
//пробегаемся по всем словам
Regex regex = new Regex(@"\w+", RegexOptions.IgnoreCase);
var maches = regex.Matches(readtext);
foreach (Match m in maches)
{
//текущее слово
var currentWord = readtext.Substring(m.Index, m.Length);
//нормализуем слова
var w1 = currentWord.Normalize(NormalizationForm.FormKD);
var word1 = readWord.Normalize(NormalizationForm.FormKD);
//если слова совпали
if (w1.Equals(word1, StringComparison.InvariantCultureIgnoreCase))
{
//сдвигаем позицию вправо
index = index + m.Index + m.Length;
//пишем "прочитанный" текст
Console.WriteLine(text[0..index]);
chechRead();
//выходим
return;
}
}
Console.WriteLine(readWord + " not read!");
chechRead();
}
//проверяем весь ли текст прочитан
void chechRead()
{
var wordExists = Regex.IsMatch(text.Substring(index), @"\w");
if (!wordExists)
Console.WriteLine("All text is read!");
}
Результат:
В
В лесу
родился not read!
В лесу родилась
В лесу родилась ёлочка
All text is read!
Теперь зная позицию, можно закрасить прочитанные слова другим цветом.
Самое сложное - создать компонент отображающий текст, с возможностью выделения уже прочитанных слов.
Компонент для отрисовки текста
Зашел я значит, в эти самые интернеты, подсмотреть, как можно реализовать такой компонент. Сначала были попытки переписать TextBox. Но было много глюков. Посетила идея использовать RichEdit. Но как только почитал статью https://habr.com/ru/articles/939902/ - захотелось попробовать ИИ для написания такого компонента через Qoder.
После незабываемых часов вайбкодинга (прости господи), Qoder таки написал компонент на основе Control. Аппетиты росли, текста было мало. Захотелось картинок в тексте. А как можно в тексте указать в какое место вставить картинку? Конечно надо сделать так, что бы компонент умел читать html.
Примеры запросов к Qoder
Создаем компонент
создай компонент VoiceReaderComponent основанный на Control. Его основные функции:
1. свойства: Text, Font, WordWrap, ScrollBars, TextAlign (Left,Center,Right).
При выборе TextAlign всё содержимое должно быть выравнено в соответствии с выбранным выравниванием.
2. компонент должен отображать html с картинками, переданный через свойство Text. компонент должен понимать следующие теги для отображения текста: <p>, <img>, <br>, <i>, <b>, <a>.
пример текста с картинкой: "<p> текст </p><img src='three_kittens/1.jpg' /> <p> текст2 </p>".
Картинка должна быть как отдельный элемент среди текста.
При нажатии на ссылке должно вызыватся событие OnLinkClick;
3. компонент должен иметь свойство HighlightEnd - позиция до которой необходимо закрашивать цветом HighLightColor.
при выделении всех слов должно вызываться событие HighlightedFinish (знаки ! . , и прочие не считаются).
4. компонент должен при нажатии кнопкой мыши на слове выделять его фоновым цветом SelectionWordColor.
При создании компонента не нужно формировать пример по его запуску. Не нужно создавать markdown описание компонента.
Очень внимательно проанализируй требования и напиши чистый лаконичный код.
Затем полный переход на html
необходимо очень аккуратно полностью убрать highlightEnd, и вместо него добавить методы:
1. nextUnread возвращает следующее непрочитанное слово в виде класса TWord с полями string Word, WordStart WordEnd ClickLocation.
2. setRead(TWord word) для пометки этого слова как прочитанное
3. при клике на слове необходимо возвращать переделанный WordContextMenuEventArgs содержащий в себе TWord word.
4. массив List<TWord> Words для вывода всех слов для программиста.
PlainText всё что с ним связано необходимо убрать.
Свойство Text для установки html содержимого необходимо оставить.
То есть фактически нам не интересен сам текст, мы теперь работаем с html тегами.
Дорабатываем
В TWord необходимо заменить свойство IsRead в State (Unread, Read, Unrecognize, Ignored).
по умолчанию все слова должны иметь State=Unread.
метод ClearWordHighlight проставит всем словам (List<TWord> words) по умолчанию State=Unread.
необходимо переименовать HighlightColor в ReadColor.
в методе setRead слову проставляется State=Read. Слово будет подсвечиваться цветом ReadColor.
небходимо так же добавить UnrecognizedColor (цвет red).
небходимо так же добавить IgnoredColor (цвет gray).
необходимо добавить метод setUnrecognize(TWord word) при котором помеченное слово станет State=Unrecognize и будет подсвечиваться цветом UnrecognizedColor. при этом все слова до него у которых State=Unread будут помечены State=Ignored и будут подсвечиваться цветом IgnoredColor.
Ну и так далее. Всё индивидуально.
Интерфейс Qoder

Как выглядит html отрендеренный этим компонентом

Qoder работает в двух режимах: чат и агент. В режиме чата он в текстовом виде предлагает полное описание всех действий, и куски кода, которые можно использовать для вставки/правки. А в режиме агента - он делает всё за тебя. Даже создает Unit тесты и запускает код. Читает ошибки при запуске, и заново правит код. Так может продолжаться десятками минут.
Сам бы я такой компонент вряд ли когда написал без ИИ.
Текст для чтения
Если мы хотим прочитать абсолютно любой текст (например "Война и мир" Толстого) - вместо малой модели, нам необходимо выбрать самую большую. Казалось бы - качай по потребностям с https://alphacephei.com/vosk/models и радуйся.
Но нет, так не работает. Даже самая большая модель может не поддерживать сложносоставные слова. Для этого надо предусмотреть пометку неподдерживаемых слов в компоненте. Для этого у Vosk модели есть метод FindWord, позволяющий определить неподдерживаемое моделью слово. Все неподдерживаемые слова перед началом чтения будем помечать серым цветом и игнорировать при чтении.
Скрытый текст

А что бы при многократном повторе чтения одного и того же трудного слова не хотелось кинуть в монитор кирпич, необходимо добавить возможность по клику правой кнопки мыши пометить слово как "нераспознанное". И нервы в порядке, и монитор - целый.
Скрытый текст

Адаптируем модель
Не всё так грустно. Можно перекомпилировать модель под определенный текст, тем самым убив двух зайцев: физически уменьшить размер модели и добавить неподдерживаемые сложносоставные слова.
По статье "Адаптация языковой модели vosk" (https://habr.com/ru/articles/735480/), делаем адаптированную для конкретного текста модель: качаем для русского языка не скомпилированную малую модель, обрезаем словарь ru-250k.dic до нужных слов, и добавляем в extra.txt нужные сложносоставные слова. Компилируем, и получаем адаптированную модель.
Т.к. на дворе уже 2025, и при компиляции модели есть проблемы, открываем статью для устранения проблем "Добавление слов в языковую модель Vosk" (https://habr.com/ru/articles/909788/).
Сборку Kaldi (на которой основан Vosk) и компиляцию новой модели производил на виртуальной машине с Ubuntu на борту, в Oracle VirtualBox. Конфигурация виртуальной машины: 4Гб ОЗУ, видеопамять 128Мб, диск на 30Гб.
У лукоморья дуб зелёный
Создаем адаптированную модель на примере отрывка из поэмы "Руслан и Людмила", "У локоморья дуб зеленый".
Само произведение
У лукоморья дуб зелёный;
Златая цепь на дубе том:
И днём и ночью кот учёный
Всё ходит по цепи кругом;
Идёт направо - песнь заводит,
Налево - сказку говорит.
Там чудеса: там леший бродит,
Русалка на ветвях сидит;
Там на неведомых дорожках
Следы невиданных зверей;
Избушка там на курьих ножках
Стоит без окон, без дверей;
Там лес и дол видений полны;
Там о заре прихлынут волны
На брег песчаный и пустой,
И тридцать витязей прекрасных
Чредой из вод выходят ясных,
И с ними дядька их морской;
Там королевич мимоходом
Пленяет грозного царя;
Там в облаках перед народом
Через леса, через моря
Колдун несёт богатыря;
В темнице там царевна тужит,
А бурый волк ей верно служит;
Там ступа с Бабою Ягой
Идёт, бредёт сама собой,
Там царь Кащей над златом чахнет;
Там русский дух... там Русью пахнет!
И там я был, и мёд я пил;
У моря видел дуб зелёный;
Под ним сидел, и кот учёный
Свои мне сказки говорил.
Перебором всех слов в произведении, находим все слова которые есть в оригинальной модели ru-250k.dic, и перезаписываем ru-250k.dic.
Новый ru-250k.dic
!SIL SIL
[unk] GBG
а a0
а a1
без bj e0 z
без bj e1 z
богатыря b o0 g a0 t y0 rj a1
брег b rj e1 g
бредёт b rj e0 dj o1 t
бродит b r o1 dj i0 t
бурый b u1 r y0 j
был b y1 l
в v
верно vj e1 r n o0
ветвях vj e0 t vj a1 h
видел vj i1 dj e0 l
видений vj i0 dj e1 nj i0 j
видений vj i1 dj e0 nj i0 j
витязей vj i1 tj a0 zj e0 j
вод v o1 d
волк v o1 l k
волны v o0 l n y1
волны v o1 l n y0
всё v sj o1
выходят v y0 h o1 dj a0 t
выходят v y1 h o0 dj a0 t
говорил g o0 v o0 rj i1 l
говорит g o0 v o0 rj i1 t
грозного g r o1 z n o0 g o0
дверей d vj e0 rj e1 j
днём d nj o1 m
дол d o1 l
дорожках d o0 r o1 zh k a0 h
дуб d u1 b
дубе d u1 bj e0
дух d u1 h
дядька dj a1 dj k a0
ей j e1 j
заводит z a0 v o1 dj i0 t
заре z a0 rj e1
зверей z vj e0 rj e1 j
зелёный zj e0 lj o1 n y0 j
златом z l a0 t o1 m
златом z l a1 t o0 m
и i0
и i1
и y0
идёт i0 dj o1 t
из i0 z
из i1 z
избушка i0 z b u1 sh k a0
их i1 h
кащей k a1 sch e0 j
колдун k o0 l d u1 n
королевич k o0 r o0 lj e1 vj i0 ch
кот k o1 t
кругом k r u0 g o1 m
кругом k r u1 g o0 m
курьих k u1 rj i0 h
лес lj e1 s
леса lj e0 s a1
леса lj e1 s a0
леший lj e1 sh i0 j
лукоморья l u0 k o0 m o1 rj j a0
мимоходом mj i0 m o0 h o1 d o0 m
мне m nj e1
морской m o0 r s k o1 j
моря m o0 rj a1
моря m o1 rj a0
мёд mj o1 d
на n a1
над n a0 d
над n a1 d
налево n a0 lj e1 v o0
направо n a0 p r a1 v o0
народом n a0 r o1 d o0 m
неведомых nj e0 vj e1 d o0 m y0 h
невиданных nj e0 vj i1 d a0 n n y0 h
несёт nj e0 sj o1 t
ним nj i1 m
ними nj i1 mj i0
ножках n o1 zh k a0 h
ночью n o1 ch j u0
о o0
о o1
облаках o0 b l a0 k a1 h
окон o0 k o1 n
окон o1 k o0 n
пахнет p a0 h nj o1 t
пахнет p a1 h nj e0 t
перед pj e0 rj e0 d
перед pj e0 rj o1 d
перед pj e1 rj e0 d
песнь pj e1 s nj
песчаный pj e0 s ch a1 n y0 j
пил pj i1 l
пленяет p lj e0 nj a1 j e0 t
по p o0
по p o1
под p o1 d
полны p o0 l n y1
прекрасных p rj e0 k r a1 s n y0 h
пустой p u0 s t o1 j
русалка r u0 s a1 l k a0
русский r u1 s s kj i0 j
русью r u1 sj j u0
с s
сама s a0 m a1
свои s v o0 i1
сидел sj i0 dj e1 l
сидит sj i0 dj i1 t
сказки s k a1 z kj i0
сказку s k a1 z k u0
следы s lj e0 d y1
служит s l u1 zh i0 t
собой s o0 b o1 j
стоит s t o0 i1 t
стоит s t o1 i0 t
ступа s t u1 p a0
там t a1 m
темнице tj e0 m nj i1 c e0
том t o1 m
тридцать t rj i1 d c a0 tj
у u0
у u1
учёный u0 ch o1 n y0 j
ходит h o1 dj i0 t
царевна c a0 rj e1 v n a0
царь c a1 rj
царя c a0 rj a1
цепи c e0 pj i1
цепи c e1 pj i0
цепь c e1 pj
чахнет ch a1 h nj e0 t
через ch e0 rj e0 z
через ch e1 rj e0 z
чудеса ch u0 dj e0 s a1
я j a1
ягой j a0 g o1 j
ясных j a1 s n y0 h
Далее, все слова которые есть в произведении но нет в оригинальной модели ru-250k.dic, записываем в extra.txt
Новый extra.txt
златая
прихлынут
чредой
тужит
бабою
Запускаем ./compile-graph.sh
, копируем из kaldi/egs/wsj/s5/exp/tdnn/lgraph/
файлы Gr.fst
и HCLr.fst
и заменяем в папке graph
своей модели.
Размер такой модели 25,8 Мб.
Английский (и любой другой) язык
Для создания оптимизированной модели на другом языке, проще всего клонировать машину, скачать нескомпилированную малую модель этого языка, и компилировать уже ее.
Итак, в Oracle VirtualBox закрываем текущую виртуальную машину с опцией "Сохранить текущее состояние", затем в списке "Снимков" жмем на "Текущее состояние" - "Клонировать". Назовем например vosk-en.
Клонируем виртуальную машину

Запускаем виртуальную машину, скачиваем новую модель, заменяем файлы.
Команды в терминале
cd /home/user/new-model/kaldi/egs/wsj/s5
rm -r conf
rm -r db
rm -r exp
rm -r local
wget "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15-compile.tar.gz"
tar -xf vosk-model-small-en-us-0.15-compile.tar.gz
mv vosk-model-small-en-us-0.15-compile/* .
Далее, в папке /home/user/new-model/kaldi/egs/wsj/s5/db
правим файлы en.dic
и extra.txt
, и можно компилировать ./compile-graph.sh
.
Далее скачиваем саму модель https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip , распаковываем её, и заменяем в ней скомпилированые файлы Gr.fst
и HCLr.fst
.
На примере произведения "Маленький принц"

Важная ремарка: при создании текста необходимо придерживаться символов доступных в самой модели, например для слова it's
апостроф (запятая сверху) должен быть именно апострофом как в en.dic
а не специальным символом очень похожим на апостроф.
Свой формат
В процессе написания программы статьи, меня посетила мысль: давайте при открытии любого текстового файла предоставим пользователю возможность скачать и выбрать любую модель из https://alphacephei.com/vosk/models для распознавания. А под конкретное произведение будем адаптировать модель и упаковывать сам текст и оптимизированную модель в контейнер с новым расширением *.vrbook. Контейнер - обычный zip архив папки с оптимизированной моделью и текстом.
Открывает пользователь файл *.txt, нажимает "Читать" - и выбирает из списка нужную модель (русский, английский, французский и т.д.). Если нет - скачивает.
Открывает пользователь файл *.vrbook, программа автоматически распаковывает контейнер с текстом и моделью во временную папку, и можно читать.
Содержимое контейнера vrbook:
am - папка модели
conf - папка модели
graph - папка модели
ivector - папка модели
img - папка с картинками
book.json - само произведение
Пример book.json
{
"title":"Название произведения",
"description": "Описание произведения",
"author": "Автор",
"contentType": "html",
"text":
"
<p>Текст</p>
<img src='img/1.png' />
<p>Текст</p>
"
}
Модели загружаются в %TEMP%\VoiceReader\models
, а контейнеры .vrbook распаковываются в %TEMP%\VoiceReader\vrbook_xxxx
(где xxxx
- уникальный guid сформированный на основе названия (title
) произведения).
Пока приводил программу в порядок для выкладывания на github, добавил автоматическую детекцию текста: открыл пользователь текстовый файл, через detector.Detect()
(nuget пакет LanguageDetection.NETStandard) определяется язык произведения (rus, eng и т.д. по стандарту ISO 639-3), а уже при открытии менеджера моделей, пользователю автоматом отфильтрует нужную модель.
Менеджер моделей предлагает французский

Итого
Вся программа состоит из следующих компонентов:
frmMain - главное окно с текстом и элементами управления чтения: увеличить/уменьшить текст, выравнивание текста, включение автопрокрутки, включение статистики, выбор файла, выбор микрофона, и кнопка "Читать"
frmModelManager - менеджер моделей, в котором можно загрузить любую интересующую модель
frmReadStatistics - вывод статистики
VoiceReaderComponent - сам компонент для чтения текста
VoskModelManager, VoskModel - менеджер моделей и описание модели
VRLoader - компонент загрузки контейнера .vrbook
Проект можно забрать здесь: https://github.com/virex-84/VoiceReader
Релиз с двумя книжками в формате .vrbook на русском и английском: https://github.com/virex-84/VoiceReader/releases/tag/v1.0.
Github при заливке файла на русском языке переименовывает его в "default", поэтому "У лукоморья дуб зелёный.vrbook" переименовал в "On.seashore.far.a.green.oak.towers.vrbook".

Надеюсь этот проект был не бесполезным. И кому-нибудь он поможет в практике чтения вслух.
А на сегодня всё.
stdrone
Прикольно, я как-то делал подобное приложение для Android. Столкнулся с проблемой низкого качества распознавания голоса, доступными на тот момент библиотеками.
А потом купили ребёнку книжку-комикс по Gravity Falls и он менее чем за месяц начал бегло читать без какого-либо принуждения и контроля.
Как итог - проект был заброшен.