Картинка для привлечения внимания
Картинка для привлечения внимания

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

Зная о негативном отношении многих технарей к религиозным вопросам, я рискую открыть ящик Пандоры, однако, в то же время рассчитываю, что этот материал будет интересен широкому кругу читателей. Под катом мы погрузимся в волшебный мир Android-разработки, а также слегка коснёмся тем баз данных, теории музыки и литургических песнопений. Приятного чтения!

Предыстория

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

Первый рабочий прототип для ОС Android был «слеплен» довольно быстро, однако, чем-то серьёзным его назвать нельзя: откровенно нехорошие или просто довольно старые практики и отсутствие какой-либо архитектуры, например, размещение всей логики в Activity, совсем его не красили. Однако это дело легко поправимо, в отличие от другой проблемы: где брать исходный материал, который должна отображать программа? Я при всём желании чисто физически не смогу перепечатать около четырёхсот партитур – и проект был отложен на неопределённый срок до лучших времён… Которые внезапно настали примерно год спустя – мне показали сайт литургической комиссии при конференции епископов, где вожделенные ноты и тексты были заботливо представлены в свободном доступе в формате PDF. Отвертеться теперь точно не получится :)

Формулируем ТЗ

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

  1. Полнотекстовый поиск. Не все помнят номер или название, как оно записано в сборнике, поиск по тексту здесь бы очень помог.

  2. Возможность прослушать мелодию, т.к. далеко не все владеют нотной грамотой, а тем более читают с листа.

  3. Адекватное потребление памяти. Меньше – лучше.

Сбор данных – часть первая

Здесь меня так и распирало вставить что-нибудь вроде переделанной цитаты из «Страха и ненависти в Лас-Вегасе», т.к. в процессе было перепробовано множество разных технологий, но обо всём по порядку:

Первое, что было нужно сделать – загрузить все нотные листы к себе. К сожалению, на сайте не предусмотрена возможность выгрузить всё одним архивом; делать всё руками через браузер – не наш метод. На помощь приходят Python и библиотека Beautiful Soup для парсинга веб-страниц. Главная сложность, которая возникла в процессе, была связана с тем, что сайту не нравился мой user-agent, и соединение разрывалось. Сделав user-agent более похожим на те, которые отправляют реальные браузеры, всё получилось.

Произведения сгруппированы по разделам, URL-ы которых были скормлены скрипту. Далее необходимые ссылки на загрузку файлов были вытащены с помощью фильтрации по CSS-селектору и имеющимся ключевым словам (меня интересовали одноголосые варианты).

О разработке на ранних этапах

Так получилось, что первый серьёзный опыт разработки я получил благодаря Java и Android, но по иронии судьбы на работе мне приходится иметь дело с вебом и .NET. Во время моего длительного перерыва в мире Android многое поменялось. В Google окончательно отказались от связки Eclipse + ADT в пользу Android Studio, появились архитектурные компоненты, а приоритет сместился на Kotlin и Jetpack Compose. Также стоит отметить один неприятный личный момент: моя квалификация не очень высокая, и исправляется это достаточно медленно – учиться самостоятельно действительно сложно. Поначалу всё это меня немного смутило, и я принял, как мне тогда казалось, рациональное решение – использовать более знакомые технологии и попробовать начать с .NET и C#. Я ещё никогда так не ошибался…

Наверное, многие слышали про такую технологию как Xamarin. Уверен, что слышали. В 2022 году владеющая Xamarin Microsoft выпускает в качестве её замены фреймворк-наследник .NET Multi-platform App UI. В теории всё красиво – используя общую кодовую базу на C# и разметку на XAML, можно разрабатывать сразу для четырёх платформ (Windows, Mac OS, Android и iOS, а силами энтузиастов была добавлена неофициальная поддержка GNU/Linux) с доступом к нативным API и ориентацией на использование MVVM-архитектуры.

К сожалению, на том этапе развития, когда я с ним познакомился (конец 2023 года), MAUI был очень сырым, и проблемы не заставили себя ждать:

  1. Несмотря на богатую библиотеку элементов интерфейса, это собственные компоненты фреймворка, которые довольно сильно отличаются от «родных» для целевой платформы. Их стилизация порой превращается в нетривиальную задачу с кучей обходных путей и костыльных решений.

  2. Нет адекватных средств для работы с базами данных. Microsoft предлагает простенькую стороннюю ORM-библиотеку. Для моих целей этого бы хватило, но используемая этой библиотекой обёртка над SQLite толком не работает с юникодом, в т.ч. с кириллицей. Например, нельзя просто так взять и сравнить строки без учёта регистра. В теории SQLite позволяет написать собственный collation, но любые попытки сделать что-то подобное тут же приводили к вылету.

  3. На сладенькое: получившаяся программа тащит за собой .NET runtime, из-за чего пакет неплохо так разбухает.

Что-то из этого получилось, например, более-менее адекватно выглядящая и работающая Windows-версия, но вариант для Android не выдерживал никакой критики.
Что-то из этого получилось, например, более-менее адекватно выглядящая и работающая Windows-версия, но вариант для Android не выдерживал никакой критики.

Наступила стадия принятия – у страха велики глаза, а Android не кусается. Будем писать «нативный» вариант, о чём дальше пойдёт речь в статье.

Сбор данных – часть вторая

Перед тем, как прейти к написанию кода, нужно подготовить данные. Кому-то эта секция может показаться малоинтересной, и вы можете захотеть перейти сразу к следующему разделу, но имеем то, что имеем. Мало просто загрузить нотные листы с сайта, для реализации поиска по содержимому нужно как-то извлечь из них текст. Также было бы неплохо иметь ноты в машиночитаемом формате – хранить в памяти почти 400, пусть и коротких записей, было бы перебором.

Мне повезло в PDF-файлах есть текстовый слой, но при попытке скопировать текст на выходе получалась нечитаемая каша. Перепечатывать вручную, как уже было сказано выше – не вариант. Тогда я попробовал прогнать их через Tesseract (открытый движок для распознавания текста с поддержкой кучи языков, в том числе русского и латинского). Распознавание прошло успешно, но с одним «но»: ноты сводили программу с ума, и части текста, что были под нотами, распознаны не были. Не отчаиваемся; я ещё раз внимательно посмотрел на кракозябры, полученные на первом этапе, и подумал: что если это просто сбитая кодировка? Интуиция подсказывала, что это Windows-1251, прочитанная как Windows-1252. Предположение оказалось верным, и очень быстро у меня был полный каталог с текстовыми файлами:

static void Main(string[] args)
        {
            string InputPath = @"Каталог с исходниками";
            string OutputPath = @"Каталог для результатов";

            string[] files = Directory.GetFiles(InputPath, "*.txt");

            Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

            Encoding win1251 = Encoding.GetEncoding(1251);
            Encoding win1252 = Encoding.GetEncoding(1252);
            Encoding utf8 = Encoding.UTF8;

            foreach(var path in files)
            {
                byte[] utf8buf = ReadFile(path);
                byte[] win1252buf = Encoding.Convert(utf8, win1252, utf8buf);
                string decoded = win1251.GetString(win1252buf);
                string filtered = Regex.Replace(decoded, "[^А-ЯЁа-яёA-Za-z0-9\\s\\.\\,\\!\\-]", "");
                filtered = Regex.Replace(filtered, "(?<=\\p{L})(\\s*-\\s*){1,}(?=\\p{L})", "");
                filtered = Regex.Replace(filtered, "\\s", " ");
                Console.WriteLine(filtered);
                filtered = Regex.Replace(filtered.Trim(), "\\s{2,}", " ");
                Console.WriteLine(filtered);

                var fileInfo = new FileInfo(path);
                string outPath = OutputPath + fileInfo.Name;

                using (FileStream fstream = new FileStream(outPath, FileMode.OpenOrCreate))
                {
                    byte[] buffer = win1251.GetBytes(filtered);
                    fstream.Write(buffer, 0, buffer.Length);
                }
            }        
        }

        public static byte[] ReadFile(string path)
        {
            byte[] buffer;
            FileStream fileStream = new FileStream(path, FileMode.Open, FileAccess.Read);
            try
            {
                int length = (int)fileStream.Length;
                buffer = new byte[length];    
                int count;   
                int sum = 0;  
  
                while ((count = fileStream.Read(buffer, sum, length - sum)) > 0)
                    sum += count;
            }
            finally
            {
                fileStream.Close();
            }
            return buffer;
        }

Наверное, писать на C# такие вещи немного перебор, но я пользовался тем, что уже было под рукой.

С нотами всё немного сложнее, хотя автоматизированные средства для распознавания тоже существуют. Например, я воспользовался Audiveris. Забавно, но если движок для распознавания текста сбивали ноты, то движок для распознавания нот сбивал текст. Буквы принимались за знаки пауз, модификаторы длительности вроде мультиолей, стаккато, акценты и прочий мусор, которого не было в оригинале. Если попробовать оставить в исходном файле только графику, то быстро обнаруживаем, что от всего содержимого остались только линейки и штили нот. Сами ноты и символы вроде ключей были также сделаны с помощью текста. Заставило ли меня это корпеть над нотным редактором? Ага, как же! Самое время вспомнить о том, что PDF – очень даже текстовый формат, пусть и с вкраплениями бинарных данных. В этих нотных листах весь текст и графика сжаты алгоритмом deflate, но нам и не нужно их распаковывать.

8 0 obj
<</BaseFont/FPDJPS+LatinX#20Book/DescendantFonts[33 0 R]/Encoding/Identity-H/Subtype/Type0/ToUnicode 32 0 R/Type/Font>>
endobj

10 0 obj
<</Filter/FlateDecode/Length 8928>>
stream
*Каша из бинарных данных*

Быстрый просмотр в текстовом редакторе даёт понять, что в документе используются четыре шрифта: Officina, Times New Roman, LatinX Book (удивительно антипоисковое название) и Maestro. Нам нужно оставить только последний, это шрифт с нужными символами, используемый программой Finale. Открываем скриптом наши листы и уже знакомым образом с помощью магии регулярных выражений избавляемся от всего лишнего.

string[] files = Directory.GetFiles(InputPath, "*.pdf");

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Encoding win1252 = Encoding.GetEncoding(1252);

string pattern = @"^[\d\s]+obj[\w|\W]*?endobj";
Regex regex = new Regex(pattern, RegexOptions.Multiline);

foreach (string path in files)
{
    byte[] buffer = ReadFile(path);
    string content = win1252.GetString(buffer);

    List<string> objects = new List<string>();

    foreach (Match match in regex.Matches(content))
    {
        objects.Add(match.Value);
    }

    for (int i = 0; i < objects.Count - 1; i++)
    {
        if (objects[i].Contains("LatinX") || objects[i].Contains("OfficinaSerif"))
        {
            content = content.Replace(objects[i], "");
            content = content.Replace(objects[i + 1], "");
        }
    }

    FileInfo fileInfo = new FileInfo(path);

    using (FileStream fstream = new FileStream(OutputPath + fileInfo.Name, FileMode.OpenOrCreate))
    {
        byte[] wBuffer = win1252.GetBytes(content);
        fstream.Write(wBuffer, 0, wBuffer.Length);
    }
}

Ради справедливости стоит сказать, что, естественно, всё неидеально, и мне повезло со структурой документов. В текстах несмотря на общую адекватность и удаление всех небуквенных символов всё равно кое-где остаётся мусор, где-то перепутан порядок слов. В случае с нотами успешно распозналось 207 из 371 листа – где-то участие человека требовалось изначально. Например, в этом фрагменте:

Поясню: здесь нет деления на такты и указания длительности нот, только высота звука, сравните с адаптированным фрагментом справа. Машина, к сожалению, такого не понимает, и нужен кто-то более сведущий в музыке, чем я, чтобы грамотно адаптировать это. Так или иначе, объём необходимого монотонного ручного труда сильно сокращён, что не может не радовать.

Кто-то наверняка задался вопросом: а нельзя ли было просто попросить исходники у авторов, объяснив свою цель, вместо этого преодоления трудностей ради преодоления трудностей? К сожалению, нет. Я говорил с человеком, который принимал непосредственное участие в создании сборника, но с его слов, след наборщика затерялся во времени как слёзы в дожде, как-никак прошло уже более двадцати лет.

Приручаем зелёного робота и заполняем базу данных

Как ни странно, самым сложным этапом была именно подготовка данных, непосредственно разработка оказалась намного проще несмотря на то, что в некоторых моментах мне по сути пришлось учиться заново.

Предлагаю для начала «добить» базу данных. Для работы с базой будет использоваться библиотека Room. Структура проста – в базе всего одна таблица, которая кроме ключа содержит номер, название и текст песни.

@Entity(tableName = "songs")
@Fts4(tokenizer = "unicode61")
data class Song (
    @PrimaryKey
    @ColumnInfo(name = "rowid")
    val rowId: Int,
    val num: String,
    val title: String,
    val lyrics: String?
)

Возможно, это не очень хороший вариант, и имеет смысл добавить вторую таблицу, содержащую отдельно прописанные пути к ассетам (в текущем варианте доступ осуществляется за счёт совпадения имени файла и номера). Обращаю внимание на аннотацию @Fts4, говорящую СУБД, что это виртуальная таблица, которая будет использоваться для быстрого полнотекстового поиска – разработчики SQLite о нас позаботились. Параметр tokenizer определяет правила, согласно которым из текста будут извлекаться токены для поиска. Токенизатор, используемый по умолчанию чувствителен к регистру не ASCII-символов и знакам препинания, поэтому он нам не подходит.

Также нам нужно определить интерфейс Data Access Object (или просто DAO), где содержатся методы доступа к данным, и непосредственно описать экземпляр базы данных:

@Dao
interface SongDao {
    @Query("select rowid, num, title from songs")
    fun getAllSongs(): List<Song>

    @Query("select rowid, num, title, snippet(songs) as lyrics from songs where lyrics match :query || '*'")
    fun performLyricsSearch(query: String): List<Song>

    @Query("select rowid, num, title from songs where num match :query || '*' or title match :query || '*'")
    fun performSearch(query: String): List<Song>
}

Теперь необходимо создать репозиторий, который будет обращаться к DAO и манипулировать данными (ну или просто делать выборку, как в нашем случае). После вышеописанных манипуляций Room сгенерирует реализацию DAO, а также необходимые запросы для создания таблиц. В нашем случае запрос можно ввести вручную, но при более сложной структуре базы это будет полезно:

@Override
public void createAllTables(@NonNull final SupportSQLiteDatabase db) {
  db.execSQL("CREATE VIRTUAL TABLE IF NOT EXISTS `songs` USING FTS4(`num` TEXT NOT NULL, `title` TEXT NOT NULL, `lyrics` TEXT, tokenize=unicode61)");
// ...
}

Теперь наступает самая неприятная часть: нужно заполнить базу, и на этот раз часть данных всё равно придётся вводить вручную. Благо, что работать можно в удобном табличном редакторе вроде Excel или LibreOffice Calc, после чего экспортировать кропотливо заполненную табличку в CSV и уже автоматизированными средствами сгенерировать скрипт вставки:

using (StreamWriter writer = new StreamWriter(@"C:\Work\songs2db.txt", true))
{
    using (StreamReader reader = new StreamReader(@"C:\Work\title.csv", encoding: win1251))
    {
        string? line;
        while ((line = reader.ReadLine()) != null)
        {
            string[] parsed = line.Split(";");
            string path = @"C:\Work\Output\Decoded\" + parsed[0] + ".txt";
            byte[] win1251buf = ReadFile(path);
            byte[] utf8buf = Encoding.Convert(win1251, utf8, win1251buf);
            string lyrics = utf8.GetString(utf8buf);

            writer.WriteLine("insert into songs(num, title, lyrics) values('{0}', '{1}', '{2}');", parsed[0], parsed[1], lyrics);
        }
    }
}

Немного про интерфейс

Как и база, интерфейс предельно прост и по сути состоит из двух экранов: на первом осуществляется поиск, результаты отображаются в RecyclerView (мой уровень кунг-фу пока не дорос до Compose), в содержащемся в адаптере ViewHolder реализуется простенький OnClickListener, вызывающий переход на экран с нотными листами:

inner class SongHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
    val textViewNum = itemView.findViewById<TextView>(R.id.text_view_num)
    val textViewTitle = itemView.findViewById<TextView>(R.id.text_view_title)
    val textViewSnippet = itemView.findViewById<TextView>(R.id.text_view_snippet)
    val context = itemView.context

    init { itemView.setOnClickListener(this) }

    override fun onClick(v: View) {
        val current: Song = songs[bindingAdapterPosition]
        val intent = Intent(context, SongActivity::class.java)
        intent.putExtra("NUM", current.num)
        context.startActivity(intent)
    }
}

С отображением нотных листов всё интереснее: поначалу я использовал нативную библиотеку, но она была в ныне закрытом репозитории jCenter. Естественно, никто не держал локального кэша зависимостей – сам себе злобный Буратино.

С другой стороны, в Android 5 был добавлен системный класс, позволяющий постранично отрисовывать PDF в Bitmap. Ничто не мешает написать нам пару функций-расширений вроде таких:

fun PdfRenderer.Page.createBitmap(density: Int): Bitmap {
    // Размеры страницы - типографские точки (1/72 дюйма)
    val scaleFactor = density / 72
    val bitmap =
        Bitmap.createBitmap(width * scaleFactor, height * scaleFactor, Bitmap.Config.ARGB_8888)

    val canvas = Canvas(bitmap)
    canvas.drawColor(Color.WHITE)
    canvas.drawBitmap(bitmap, 0f, 0f, null)

    return bitmap
}

fun PdfRenderer.Page.renderAndClose(density: Int): Bitmap = use {
    val bitmap = createBitmap(density)
    render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
    bitmap
}

Страницы в свою очередь можно отображать во всё том же RecyclerView. Выглядит не так красиво, как с использованием нативной библиотеки, но получившийся пакет худеет примерно на 16 мегабайт. Основная проблема данного подхода – прописать адекватные прокрутку и масштабирование, благо, что есть библиотека под лицензией MIT, использующая под капотом этот же механизм, и реализующая обработку жестов.

Играем по нотам

Выше было сформулировано требование возможности прослушать мелодию, и, как уже было сказано, хранить несколько сотен записей не наш выбор. Наш выбор – получить ноты в машиночитаемом формате и скормить их синтезатору, благо класс MediaPlayer из системного API позволяет слушать мелодии в формате MIDI. Набор инструкций для синтезатора не занимает много места в памяти – успешно распознанные листы уложились в 114 килобайт, правда, за это придётся поплатиться качеством звучания, ибо системный банк инструментов не отличается адекватностью. С другой стороны – с задачей сыграть мелодию чисто для того, чтобы понять, на какой мотив это поётся, такой вариант более чем справляется.

В запуске проигрывателя нет ничего сверхъестественного, единственное, о чём нужно помнить – не стоит делать это в главном потоке; я воспользовался корутинами и viewModelScope, хотя, возможно, есть способы получше.

Рефлексируем

Мой маленький недобитый недоперфекционист всё ещё недоволен качеством звучания – это одна из тех вещей, которые однозначно можно улучшить, но скорее всего попытка сделать это принесёт больше проблем, чем пользы. Например, можно воспользоваться синтезатором вроде fluidsynth, которому можно скормить собственный звуковой банк в формате SoundFont 2, или вовсе заменить MIDI на трекерные модули – почему бы и нет? Сложность в том, что такие подходы требуют использования нативных библиотек, с чем у меня возникли проблемы. Мало просто собрать бинарник средствами NDK, скорее всего также придётся написать JNI-обёртку и молиться, чтобы всё это заработало. Особенно обидно, когда проект собирается, студия видит нативные реализации методов, вызываемых из Java или Kotlin, но при запуске всё громко падает с UnsatisfiedLinkError.

Как обычно со мной бывает, работа скорее представляет собой что-то вроде proof of concept. Несмотря на достаточное количество автоматизации, здесь по-прежнему требуется много ручного вмешательства (краудсорсинг?), а также в источнике представлены далеко не все страницы оригинальной книги, например, фрагменты литургии на латинском языке. Так или иначе, теперь это хотя бы можно скачать и пощупать, а также покопаться в исходниках при желании. Конструктивная критика горячо приветствуется.

P.S. Огромное спасибо Диме Рослякову за код-ревью. Исходникам предстоит далеко не одна итерация правок, но т.к. это процесс потенциально бесконечный, а статья долго лежала в ящике, решил опубликовать текущий срез.

Комментарии (2)


  1. aboyev
    06.11.2024 18:24

    Спасибо, что поделились увлекательным опытом! Оказывается тема более актуальная, чем я думал. Несколько лет назад нужно было найти приложение для хориста слегка под другие требования:

    • Чтобы можно было делать нотные подборки/сеты (без копирования файлов),

    • Чтобы во время пения можно было листать (без лагов и прогрузки),

    • Чтобы можно было делать пометки пальцем прямо на экране,

    • В идеале с интерфейсом для ПК

    Почти собрался писать свое приложение, даже расчехлил Android Studio, но в последний момент нашел неплохой аналог Song-Book Pro. Там кажется нет воспроизведения, но все остальное присутствует - возможно вам будет интересно посмотреть.


  1. VanishingPoint
    06.11.2024 18:24

    Я для себя (хотел найти одну книгу по ключевым словам, гугл не мог помочь), делал полнотекстовый поиск по всей флибусте (400 гигабайт).

    Просто тут БД используется у вас для этого, но я сделал иначе.

    Сначала просканировал вообще все книги, составил словарь всех токенов, то есть слов. Убрал очевидно ошибочные, или не нужные, типа длинных рядов цифр и тд.

    Провел стемминг.

    Записал в бинарный файл стеммы и последовательно для каждой книги в которых они встречаются, убрал конечно самые распространенные типа предлогов.

    Получилось, насколько я помню 40 мегабайт примерно. Совсем не много.

    Бинарный файл был организован так, чтобы по нему можно было вести бинарный поиск, т.е. стеммы были отсоротрованы.

    Потом залил все это на сервер. Ради интереса протестировал скорость - при одновременном доступе в 20 потоков, по 5 слов в каждом, 1 поиск выполнялся всего за 100 миллисекунд. Хотя там был ssd, но сейчас это не проблема.

    Потребление памяти практически нулевое, скорость - огромная. Никакая БД такого не даст.