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

Это простой способ визуализации музыки для гитары, альтернативный нотам, при котором символами ASCII обозначаются струны и лады.

Например, вот первые четыре такта песни Smoke on the Water группы Deep Purple:

e|-----------------|-----------------|-----------------|-----------------|
B|-----------------|-----------------|-----------------|-----------------|
G|-----3---5-------|---3---6-5-------|-----3---5-----3-|-----------------|
D|-5---3---5-----5-|---3---6-5-------|-5---3---5-----3-|---5-------------|
A|-5-------------5-|-----------------|-5---------------|---5-------------|
E|-----------------|-----------------|-----------------|-----------------| <- верх

Эту песню играют в стандартном строе (EADGBe), обозначенном буквами слева, указывающими строй каждой струны. Цифры же означают, куда нужно ставить пальцы на грифе.

Кроме текстового описания стандартом де-факто стал формат, используемый в ПО Guitar Pro для рендеринга и синтезирования звука табулатуры.

Такие двоичные файлы в зависимости от версии ПО имеют расширение .gp3, .gp4, .gp5 или .gp6, их легко можно найти в Интернете на таких веб-сайтах, как Ultimate Guitar.

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

Вероятно, лучший опенсорсный плейер табулатур — это TuxGuitar, у него очень много функций, это потрясающий инструмент для обучения гитаре.

Так как TuxGuitar уже не поддерживается и написан на Java, я решил, что будет интересно написать собственный плейер табулатур на Rust.

Ruxguitar


Я назвал свой проект Ruxguitar, объединив слова Rust и Guitar.

Проект всё ещё находится на ранних этапах развития, но я считаю, что он достаточно функционален, чтобы заявить о нём миру. Для этого я и написал этот пост!

Я не буду описывать возможности проекта, а просто покажу видео, в котором плейер табулатур воспроизводит достаточно сложную композицию:


Разумеется, исходный код можно найти на GitHub; также там есть готовые двоичные файлы для Linux, macOS и Windows.

Парсинг табулатуры


Первый этап в создании плейера табулатур — это парсинг двоичного файла табулатуры.

В процессе моих исследований я нашёл на dguitar спецификацию формата файлов .gp4.

Файл имеет приблизительно следующую структуру:

  1. Версия файла, чтобы знать, какая версия формата файла используется
  2. Информация о композиции (то есть название, подзаголовок, исполнитель, альбом и так далее)
  3. Текст композиции
  4. Количество тактов и дорожек
  5. Количество тактов на дорожку в следующем формате:
    • Такт 1/дорожка 1
    • Такт 1/дорожка2
    • ...
    • Такт 1/дорожка m
    • Такт 2/дорожка 1
    • Такт 2/дорожка 2
    • ...
    • Такт 2/дорожка m
    • ...
    • Такт n/дорожка 1
    • Такт n/дорожка 2
    • ...
    • Такт n/дорожка m
  6. В каждом такте мы находим количество считываемых долей
  7. В каждой доле мы находим длительность доли и количество считываемых нот
  8. В каждой ноте мы находим струну, лад, длительность, эффект и так далее

Для парсинга табулатур я решил использовать крейт nom, потому я уже работал с ним при парсинге двоичного формата.

Вот краткое изложение кода, управляющего парсером, чтобы вы понимали, как он выглядит:

pub fn parse_gp_data(file_data: &[u8]) -> Result<Song, RuxError> {
    let (rest, base_song) = flat_map(parse_gp_version, |version| {
        map(
            tuple((
                parse_info(version),                                     
                cond(version < GpVersion::GP5, parse_bool),              
                cond(version >= GpVersion::GP4, parse_lyrics),           
                cond(version >= GpVersion::GP5_10, take(19usize)),       
                cond(version >= GpVersion::GP5, parse_page_setup),       
                cond(version >= GpVersion::GP5, parse_int_sized_string), 
                parse_int,                                               
                cond(version > GpVersion::GP5, parse_bool),              
                parse_signed_byte,                                       
                cond(version > GpVersion::GP3, parse_int),               
                parse_midi_channels,                                     
            )),
            move |(
                song_info,
                triplet_feel,
                lyrics,
                _master_effect,
                page_setup,
                tempo_name,
                tempo,
                hide_tempo,
                key_signature,
                octave,
                midi_channels,
            )| {
                // инициализация базовой композиции
                let tempo = Tempo::new(tempo, tempo_name);
                Song {
                    version,
                    song_info,
                    triplet_feel,
                    lyrics,
                    page_setup,
                    tempo,
                    hide_tempo,
                    key_signature,
                    octave,
                    midi_channels,
                    measure_headers: vec![],
                    tracks: vec![],
                }
            },
        )
    })(file_data)
    .map_err(|_err| {
        log::error!("Failed to parse GP data");
        RuxError::ParsingError("Failed to parse GP data".to_string())
    })?;
    // парсинг дорожек и тактов
    ...

Основную нагрузку по парсингу дорожек и тактов выполняет другая функция, которую я ради краткости показывать не буду.

В конце концов мне надоело заниматься обработкой разных версий формата файлов и я решил выбрать широко используемую версию .gp5.

Честно говоря, эта часть проекта оказалась довольно трудной, потому что формат файлов достаточно сложен, а документация не всегда понятна.

К счастью, я мог изучить парсеры из TuxGuitar и крейта guitarpro, чтобы лучше разобраться в формате файлов.

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

Такой подход полезен в начале работы, но он плохо масштабируется, так что я также валидирую некоторые высокоуровневые инварианты получившейся структуры Song для папки, содержащей несколько сотен табулатур.

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

Создание UI


Итак, теперь у нас есть описание табулатуры в памяти, но нет возможности отобразить её.

Пользователь не только должен иметь возможность видеть табулатуру, но и взаимодействовать с ней.

Я хотел использовать нативную GUI-библиотеку, чтобы приложение выглядело и ощущалось как нативное приложение для всех платформ.

Текущее состояние GUI-библиотек для Rust вынудило меня провести исследования.

Для обработки синхронизации при воспроизведении мне требовалась управляемая событиями библиотека, также способная настраиваемым образом отрисовывать табулатуру на некой абстракции canvas.

Исходя из этих условий, я решил попробовать Iced.

Спойлер: я очень доволен своим выбором и больше не пробовал никаких других библиотек.

Iced


Библиотека Iced написана очень качественно, но ей бы не помешало чуть больше документации.

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

Я начал с примера текстового редактора и постепенно подстраивал его под свои нужды.

В какой-то момент я столкнулся с багом в версии 0.12.0, вынудившим меня обновиться до версии 0.13.0, которая пока не вышла в релиз.

Из-за этого мне пришлось использовать ветвь main репозитория Iced, что немного пугало, но в результате всё закончилось хорошо.

Все найденные мной проблемы были связаны с тем, что Iced находится в процессе активной разработки; я очень благодарен мейнтейнерам за их упорный труд.

Архитектура библиотеки основана на сообщениях и подписках, вызывающих обновление UI.

Например, вот такие сообщения использовал я:

#[derive(Debug, Clone)]
pub enum Message {
    OpenFile, // диалоговое окно открытия файла
    FileOpened(Result<(Vec<u8>, String), PickerError>), // содержимое и имя файла
    TrackSelected(TrackSelection), // выбор дорожки
    FocusMeasure(usize), // используется при нажатии на такт в табулатуре
    FocusTick(usize), // фокус на конкретном такте в табулатуре
    PlayPause, // переключение воспроизведения/паузы
    StopPlayer, // остановка воспроизведения
    ToggleSolo, // включение соло-режима
}

А вот упрощённая точка входа приложения:

impl RuxApplication {
    pub fn start(args: ApplicationArgs) -> iced::Result {
        iced::application(
            RuxApplication::title,
            RuxApplication::update,
            RuxApplication::view,
        )
        .subscription(RuxApplication::subscription)
        .theme(RuxApplication::theme)
        .font(ICONS_FONT)
        .centered()
        .antialiasing(true)
        .run()
    }
}

Приложение создано на основе функций, оркестрируемых движком Iced.

Функция update имеет сигнатуру Fn(&mut State, Message) -> C, где:

  • State — состояние приложения, которое можно изменять (здесь RuxApplication)
  • Message — сообщение процессу
  • C — это выходное Task, потенциально создающее новое Message

Функция view имеет сигнатуру Fn(&'a State) -> Widget и рендерит Widget на основании текущего &State.

Отрисовка табулатуры


Я начал с создания кода, который аккуратно отрисовывает на Iced::Canvas отдельный такт.

То есть:

  • рисует каждую струну
  • для каждой доли рисует ноты на струнах и потенциальный эффект доли (например, глушение струн)
  • для каждой ноты добавляет потенциальный эффект ноты (например, слайд, восходящее легато, бенд)
  • аннотирует такт дополнительной информацией (например, номером такта, темпом, аннотацией партии, аккордом)

Для правильной реализации смещений потребовалась настройка, но конечный результат мне нравится.


Собрав коллекцию тактов на canvas, я собираю их в адаптивную сетку для отображения всей табулатуры при помощи виджета wrap из крейта iced-aw.


Такты могут иметь разную длину в зависимости от количества долей, из-за чего пустые такты становятся очень маленькими, а такты с безумными гитарными соло — длинными.

Издаём звуки


Итак, теперь у нас есть описание табулатуры в памяти и UI, пора начать издавать звуки!

Мы хотим превращать каждую ноту каждой доли каждого такта каждой дорожки в конкретный звук в нужное время.

Это можно реализовать при помощи MIDI-синтезатора, то есть ПО, генерирующего звуки на основании событий MIDI.

Синтезирование событий MIDI


Существуют разные типы событий MIDI, но для нас самыми важными будут NoteOn и NoteOff.

  • Note On: обозначает, что ноту нажимают. Включает в себя номер ноты (тон) и скорость (сила, с которой играют ноту).
  • Note Off: обозначает, что ноту отпускают.

Для каждой ноты табулатуры мы можем сгенерировать пару событий MIDI, аннотированных следующими данными:

  • меткой времени, также называемой tick, когда должно выполниться событие.
  • дорожкой, к которой относится событие.


pub enum MidiEventType {
    NoteOn(i32, i32, i16),  // канал midi, нота, скорость
    NoteOff(i32, i32),      // канал midi, нота
    ...
}

pub struct MidiEvent {
    pub tick: usize,
    pub event: MidiEventType,
    pub track: usize,
}

Все эти события записываются в единый массив, отсортированный по tick событий.

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

Эти MidiEvents перед отправкой на звуковой выход можно преобразовать в аудиосигнал при помощи синтезатора.

Для реализации синтезатора я выбрал крейт rustysynth, имеющий удобный MIDI-синтезатор.

Вот упрощённая версия кода для воспроизведения события MIDI:

let synthesizer_settings = SynthesizerSettings::new(SAMPLE_RATE as i32);
let mut synthesizer = Synthesizer::new(&sound_font, &synthesizer_settings);

let midi_event = // находим следующее событие для воспроизведения
match midi_event.event {
    MidiEventType::NoteOn(channel, key, velocity) => {
        synthesizer.note_on(channel, key, velocity as i32);
    }
    MidiEventType::NoteOff(channel, key) => {
        synthesizer.note_off(channel, key);
    }
    ...
}

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

Ради простоты я добавляю во время компиляции в двоичный файл файл звукового шрифта TimGM6mb.sf2.

const TIMIDITY_SOUND_FONT: &[u8] = include_bytes!("../../resources/TimGM6mb.sf2");

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

Однако можно задать файл и большего размера при помощи аргумента командной строки --soundfont.

Например, мне нравится использовать FluidR3_GM.sf2, который присутствует на большинстве систем и и который можно легко найти онлайн (здесь или здесь).

./ruxguitar --sound-font-file /usr/share/sounds/sf2/FluidR3_GM.sf2

Цикл аудио


Поток звукового выхода управляется отдельным потоком, создающим звук с регулярными интервалами.

Я выбрал крейт кроссплатформенной аудиобиблиотеки cpal.

Вот и упрощённая версия кода для настройки цикла аудио:

let host = cpal::default_host();
let device = host.default_output_device().unwrap();

let config = device.default_output_config().unwrap();
let stream_config: cpal::StreamConfig = config.into();

let channels_count = stream_config.channels as usize;
assert_eq!(channels_count, 2);

// 4410 сэмплов при 44100 Гц - это 0,1 секунды
let mono_sample_count = 4410;

let mut left: Vec<f32> = vec![0_f32; mono_sample_count];
let mut right: Vec<f32> = vec![0_f32; mono_sample_count];

// создаём цикл аудио
let stream = device.build_output_stream(
    &stream_config,
    move |output: &mut [f32], _: &cpal::OutputCallbackInfo| {
        let midi_events = // находим события для воспроизведения
        for event in midi_events {
            // синтезируем события
            synthetizer.process(event)
        }

        // Разделяем буфер на два канала (левый и правый)
        let channel_len = output.len() / channels_count;

        // Рендерим аудиосигнал.
        synthesizer.render(&mut left[..channel_len], &mut right[..channel_len]);
        
        // Перемежаем левый и правый каналы в выходном буфере.
        for (i, (l, r)) in left.iter().zip(right.iter()).take(channel_len).enumerate() {
            output[i * 2] = *l;
            output[i * 2 + 1] = *r;
        }
    }
)
// Запускаем поток.
let stream = stream.unwrap();
stream.play().unwrap();

На каждом прогоне цикла аудио можно вычислить следующее временное окно для обработки, учитывая следующие параметры:

  • текущую метку времени аудиоплейера
  • темп текущего такта
  • сколько времени прошло с предыдущего интервала

const QUARTER_TIME: i32 = 960; // 1 четверть ноты = 960 ticks

fn tick_increase(tempo_bpm: i32, elapsed_seconds: f64) -> usize {
    let tempo_bps = tempo_bpm as f64 / 60.0;
    let bump = QUARTER_TIME as f64 * tempo_bps * elapsed_seconds;
    bump as usize
}

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

let tick_increase = tick_increase(tempo, elapsed_seconds);
let next_tick = self.current_tick + tick_increase;
// предполагается, что у нас уже есть курсор начала событий (то есть индекс последнего воспроизведённого события)
let start_index = self.current_cursor;
let end_index = match sorted_events[start_index..].binary_search_by_key(start_index, |event| event.tick)
{
    Ok(next_position) => start_index + next_position,
    Err(next_position) => {
        if next_position == 0 {
            // нет совпадающих элементов
            return Some(&[]);
        }
        // возвращаем вырезку до последнего события
        start_index + next_position - 1
    }
};
// возвращаем вырезку воспроизводимых событий
return Some(&self.sorted_events[start_index..=end_index])

Теперь, когда у нас есть работающий цикл звука, мы можем сосредоточиться на интеграции аудиоплейера и UI.

Соединяем всё вместе


Наличие идеальной интеграции крайне важно для удобства работы пользователя:

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

Наверно, смысл понятен.

Самый важный мостик между аудиоплейером и UI был реализован при помощи механизма iced::Subscription.

Subscription — это способ слушания внешних событий и их публикации в виде сообщений приложению.

Например, вот как приложение реагирует на нажатие клавиши пробела для включения/отключения воспроизведения:

let keyboard_subscription = keyboard::on_key_press(|key, _modifiers| match key.as_ref() {
    keyboard::Key::Named(Space) => Some(Message::PlayPause),
    _ => None,
});

Функции update не важно, причиной чего стало сообщение, нажатия на клавиатуру или на кнопку Play.

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

Приложение поддерживает принимающий конец канала tokio::sync::watch, содержащий текущую метку времени, публикуемую потоком звука.

fn audio_player_beat_subscription(&self) -> impl Stream<Item = Message> {
    let beat_receiver = self.beat_receiver.clone();
    stream::channel(1, move |mut output| async move {
        let mut receiver = beat_receiver.lock().await;
        loop {
            // получаем tick от аудиоплейера
            let tick = *receiver.borrow_and_update();
            // публикуем для UI
            output
                .send(Message::FocusTick(tick))
                .await
                .expect("send failed");
            // ждём следующей доли
            receiver.changed().await.expect("receiver failed");
        }
    })
}
...
// подготавливаем subscription
Subscription::run_with_id("audio-player-beat", audio_player_beat_subscription));

Табулатура обрабатывает сообщение FocusTick для обновления текущей позиции в такте и подсветки нот.


Для поддержания иллюзии правильной синхронизации всего с действиями пользователя потребовалось множество разных деталей.

Работа на будущее


Текущая версия Ruxguitar — это практически MVP для начала разработки проекта.

Она ещё очень далека по функциям и удобству от TuxGuitar.

Вот несколько идей на будущее:

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

Заключение


Я работал над Ruxguitar в течение прошлого года, и очень доволен результатом.

В процессе разработки я не только узнал много нового, но и создал сложное ПО, которое вроде даже работает.

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

Я бы не мог создать Ruxguitar без примера реализации в виде TuxGuitar, и я очень благодарен за ту работу, которую проделала за эти годы команда разработчиков TuxGuitar.

Я потратил так много времени на проект, что теперь пришла пора снова начать играть на гитаре вместо того, чтобы писать для неё ПО!

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


  1. panzerfaust
    31.08.2024 09:21
    +1

    Так как TuxGuitar уже не поддерживается и написан на Java, я решил, что будет интересно написать собственный плейер табулатур на Rust.

    Нет, вполне поддерживается: https://github.com/helge17/tuxguitar
    Буквально сколько-то месяцев назад наконец-то добавили фичу массового перемещения нот по струнам.