null

WhoAsked: Разбираемся в .wav формате и Rust™, попутно создавая музыку смерти

В первом выпуске рубрики WhoAsked с Джонни Кекером мы зададимся действительно важными вопросами

Что если бы мы захотели разобраться, что под капотом у формата .wav? Что если бы мы захотели сделать аудиофайл из ничего, и чтобы это отдаленно напоминало музыку? Что если бы мы решили писать на Rust'е? Who asked? Добро пожаловать на борт!

Проблема преобразования

 

Дисклеймер! Все, написанное ниже - страшное упрощение урощения!

Что такое звук? Звук это волна, уверен все так или иначе слышали об этом. Беда начинается тогда, когда мы хотим представить эту волну в цифровом виде, иными словами преобразовать аналоговую информацию в цифровой формат. Тут мы сталкиваемся с понятиями sample rate и bit depth.

Волну можно представить в виде синусоиды, и чтобы представить это в цифровом виде, мы можем разделить время на N частей и измерить значение функции в каждом моменте времени. Количество таких измерений в секунду называют частотой дискретизации. Это тот самый 44.1 kHz в метаинформации аудиофайла, если вы встречали такое. В переводе на человеческий, это означает, что в закодированном звуке 44100 измерений синусоиды в секунду. Другие популярные варианты частоты дискретизации можно узнать в невероятном пакете для JS. Что это обозначает на практике? Большая частота дискретизации - лучшая аппроксимация функции, что означает лучшее качество с меньшим количеством артефактов, ценой большего объема файла. Bit depth, или разрядность, или "битовая глубина" - по сути, то же самое, но по оси Y. Сколько уникальных значений функции мы способны представить, трейдофф такой же, как с частотой дискретизации.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

                    img credit

 

 

WAV is not my favourite file format.

 

тык

На самом деле, шутка притянута за уши, ибо в отличие от своего adobe-собрата с .wav все не так плохо. Я бы скорее сказал, что многие статьи, описывающие его, немного обфусцируют его суть. У меня тоже был соблазн просто украсть картинку и описать байтовые смещения до разной необходимой метаинформации, и я сделаю так дальше, но до этого давайте поговорим про RIFF.

Дисклеймер! Все, написанное ниже - страшное упрощение урощения!

RIFF это формат, который предполагает концепцию файлов-контейнеров. Попробуем разобраться, что это значит. По большому счету, о таком файле можно думать как о дереве, где узлы это чанки​​​​​​​, у которых есть уникальные идентификаторы и некоторые данные. Чанки могут содержать в себе другие чанки, отсюда дерево. Технически, RIFF предназначен не только для хранения аудио, но и для любых других мультимедиа данных (пример - AVI для видео).

Нужно понимать, что теоретически чанки могут идти в любом порядке, и задача написать парсер RIFF файла (к примеру .wav) правильно - менее тривиальна, чем кажется. Возможно, это тема для другого WhoAsked в будущем.

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

 

             img credit

 

Сразу странслируем эту структуру в Rust™:

pub fn get_wav_header(sample_rate: u32, num_samples: u32) -> Vec<u8> {
  use byteorder::{LittleEndian, WriteBytesExt};
  let mut buf: Vec<u8> = b"RIFF".to_vec();
  buf.extend_from_slice(&[0; 4]); // there will be size of RIFF
  buf.extend_from_slice(b"WAVEfmt ");
  buf.extend_from_slice(&[16, 0, 0, 0]); // constant 16, little endian
  buf.extend_from_slice(&[1, 0]); // constant 1 for no compression
  buf.extend_from_slice(&[1, 0]); // constant 1 for mono
  buf.write_u32::<LittleEndian>(sample_rate).unwrap(); // sample_rate
  buf.write_u32::<LittleEndian>(sample_rate).unwrap(); // byterate = samplerate * numchannels * bitspersample / 8
  buf.write_u16::<LittleEndian>(1).unwrap(); // blockalign = numchannels * bitspersample / 8
  buf.write_u16::<LittleEndian>(8).unwrap(); // bitspersample
  buf.extend_from_slice(b"data"); // subcuhk2id
  buf
    .write_u32::<LittleEndian>(num_samples * sample_rate)
    .unwrap(); // subchunk2size = num_samples * numchannels * bitspersample / 8
  return buf;
}

Пройдемся по основным моментам. RIFF использует Little Endian, это нужно иметь в виду. Певые 4 байта - волшебное слово RIFF, идентификатор того, что перед нами ни что иное, как RIFF-контейнер. Таким образом, мы объявляем первый чанк, родительский для всех остальных - RIFF. Далее указывается его размер в 4х байтах, на данном этапе пропускаем. Далее - ключевое слово WAVE - тип заголовка, для нас константа.

Теперь мы начинаем описывать второй чанк, начиная с его имени - fmt. Заметьте, как добавляется вайтспейс справа, так как на имя чанка приходится 4 байта (В примере кода две константы "WAVE" и "fmt " объединены). Указываем размер чанка fmt, 16 байт в нашем случае. Далее, 2 байта - константа 1, которая обозначает что мы не используем никакие алгоритмы сжатия, и еще 2 байта с константой 1 - моно сигнал. Далее указываем частоту дискретизации, в примере кода это внешний параметр, затем байтрейт, сколько байт информации приходится на секунду трека. Так как у нас 1 канал, моно, и, забегая вперед, 8 бит на один семпл (та самая bit depth), по сути байтрейт равен частоте дискретизации. На эти параметры отводится по 4 байта. Далее указатель выравнивания блоков, считается как произведение количества каналов на разрядность в байтах, и, наконец, сама разрядность, уже в битах - 8 в нашем случае. По 2 байта на число.

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

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

 

Генератор синусоиды

 

Самые базовые генераторы звуковой волны, называемые осцилляторы, ​​​​​​​делятся на несколько типов по форме волны, которую они генерируют:

  • Синусоидальная:
  • Треугольная:
  • Пилообразная:
  • Прямоугольная:

 

Дисклеймер! Все, написанное ниже - страшное упрощение урощения!

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

 

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

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

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

Новое лирическое отступление. Со средних веков в европейской музыкальной традиции господствует равномерно темперированный строй. Именно эта концепция регулирует наши 7-нот-12-полутонов-в-октаве. Октава - это такой музыкальный интервал, в котором отношение двух частот равняется один к двум. В равномерном темперированном строю такой интервал делится на 12 равных частей, и мы получаем 7 нот до-ре-ми-фа-соль-ля-си и еще 5 диезов/бемолей между всеми нотами, кроме ми-фа и си-до.

Почему это хорошо? Благодаря такой концепции мы имеем возможность транспонировать, то есть, переносить музыкальные партии из одной тональности в другую. Помимо этого, почти все современные музыкальные инструменты работают именно в этом строю (за исключением некоторых не-европейских народных инструментов и чисто аналоговых синтезаторов звука, таких как терменвоксы). Что это означает для нас?

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

Так как мы используем Rust™, нам уже доступна прекрасная функция sin(), которая умеет высчитывать синусоидальную функцию от аргумента в радианах. Частота задает количество колебаний в единицу времени, поэтому наша базовая формула сводится к тому, чтобы отскалировать стандартную частоту 2PI на нужную нам частоту, добавить смещение по времени и учесть чаcтоту дискретизации:

 

let lead = (2.0 * PI * note_freq * (time_shift as f32) / (props.sample_rate as f32) as f32).sin();

 

Какие частоты выбирать?

 

Возвращаемся к равномерно темперированному строю.

 

Дисклеймер! Все, написанное ниже - страшное упрощение урощения!

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

Следовательно, нам нужна структура данных, которая будет категоризировать ноты в гаммы. Для начала, в интернете я нашел json с маппингом всех нот к частоте волны. Оставляю ссылку, автору большая благодарность. Затем был написан код на Python, который генерирует код на Rust™. Потому что этот блог не был бы полон без затрагивания темы метапрограммирования. Но все же, углубляться не будем, оставим на отдельный WhoAsked. Мы генерируем следующую структуру данных:

Единичные ноты объединяются в гаммы. Гамма ограничена одной октавой, соответственно определенная гамма по всем октавам объединяется в структуру Lead. Практически, эта структура хранит все возможные ноты, которые можно сыграть в определенной тональности. Но что такое тональность? Это новое лирическое отступление.

Дисклеймер! Все, написанное ниже - страшное упрощение урощения!

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

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

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

 

Длительности

 

Кроме "высоты" звуки в музыке характеризуются их продолжительностью по времени, или длительностью. Скорость воспроизведения классически измеряется в beats per minute - количестве четвертных долей такта в минуту. Такт это просто условная равная единица времени, которая делится на половины раз за разом, и на каждом этапе мы получаем длительности нот - половинные, четвертные, восьмые, шестнадцатые, тридцатьвторые (доли такта)...

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

 

Итог

 

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

Результат генерации, обещанная музыка смерти:

 

Зачем все это нужно? Программная генерация музыки это довольно интересная и не очень богатая на готовые решения ниша. Лучший пример, который я могу порекомендовать изучить - Sonic Pi, DSL на основе прекрасного языка Ruby с кастомным редактором и богатым инструментарием для генерации музыки программным методом. В будущих WhoAsked, надеюсь, нам удастся познакомиться с ним поближе, так же как и доработать текущее Rust™ решение.

Назад Вперед

Коротко о себе:

 


​​​​​​​Работаю кем-то в компании Tune-it. Занимаюсь какими-то проектами, связанными с чем-то.