Итак, что мне удалось сделать за 2 вечера — под катом.
Итак, для начала всё-таки вернемся к структуре WAV-файла, как такового. Для простоты берем самый просто одноканальный wav-файл без сжатия.
Любой wav-файл состоит из нескольких секций (чанков, chunks). Подробно обо всех секциях можно почитать, например, по ссылке, я же остановлюсь на трёх основных:
- секция типа —
"RIFF"
- секция формата —
"fmt "
- секция данных —
"data"
У каждой секции есть её ID, размер секции и, собственно, какие-то данные, специфичные для данной секции.
Секция RIFF проста до безобразия: «RIFF<размер файла-8>WAVE»
<размер файла-8> потому что это значение характеризует «сколько байт содержится далее». Соответственно, 4 байта на само значение «сколько» и еще 4 на «RIFF» который был в начале.
В секции формата хранится основная интересующая обычного человека информация о файле: Sample Rate (частота дискретизации, например 44100 Гц), количество каналов (1 = моно, 2 = стерео и так далее).
В секции данных, собственно, и лежат нужные нам для проигрывания аудио-данные. По сути, они из себя представляют амплитуду волны в момент времени.
Исходя из всего вышесказанного и исходя из спецификации самого формата, ничего нам не мешает написать простейшие классы, описывающие каждую нужную нам секцию и простейший парсер, который будет считывать wav-файл и создавать необходимые нам объекты.
class Header
{
...
/**
* @var string
*/
protected $id;
/**
* @var int
*/
protected $size;
/**
* @var string
*/
protected $format;
...
class FormatSection
{
...
/**
* @var string
*/
protected $id;
/**
* @var int
*/
protected $size;
/**
* @var int
*/
protected $audioFormat;
/**
* @var int
*/
protected $numberOfChannels;
/**
* @var int
*/
protected $sampleRate;
/**
* @var int
*/
protected $byteRate;
/**
* @var int
*/
protected $blockAlign;
/**
* @var int
*/
protected $bitsPerSample;
...
class DataSection
{
...
/**
* @var string
*/
protected $id;
/**
* @var int
*/
protected $size;
/**
* @var int[]
*/
protected $raw;
...
В коде выше убрана вся логика, нам сейчас интересна только структура самих данных.
Собственно, для их чтения сделаем небольшую обёртку-helper для fread для более удобного чтения именно бинарных данных.
class Helper
{
...
public static function readString($handle, $length)
{
return self::readUnpacked($handle, 'a*', $length);
}
public static function readLong($handle)
{
return self::readUnpacked($handle, 'V', 4);
}
public static function readWord($handle)
{
return self::readUnpacked($handle, 'v', 2);
}
protected function readUnpacked($handle, $type, $length)
{
$data = unpack($type, fread($handle, $length));
return array_pop($data);
}
...
}
Осталось дело за малым, взять и прочитать содержимое wav-файла:
class Parser
{
...
public static function fromFile($filename)
{
...
$handle = fopen($filename, 'rb');
try {
$header = Header::createFromArray(self::parseHeader($handle));
$formatSection = FormatSection::createFromArray(self::parseFormatSection($handle));
$dataSection = DataSection::createFromArray(self::parseDataSection($handle));
} finally {
fclose($handle);
}
return new AudioFile($header, $formatSection, $dataSection);
}
protected static function parseHeader($handle)
{
return [
'id' => Helper::readString($handle, 4),
'size' => Helper::readLong($handle),
'format' => Helper::readString($handle, 4),
];
}
protected static function parseFormatSection($handle)
{
return [
'id' => Helper::readString($handle, 4),
'size' => Helper::readLong($handle),
'audioFormat' => Helper::readWord($handle),
'numberOfChannels' => Helper::readWord($handle),
'sampleRate' => Helper::readLong($handle),
'byteRate' => Helper::readLong($handle),
'blockAlign' => Helper::readWord($handle),
'bitsPerSample' => Helper::readWord($handle),
];
}
protected static function parseDataSection($handle)
{
$data = [
'id' => Helper::readString($handle, 4),
'size' => Helper::readLong($handle),
];
if ($data['size'] > 0) {
$data['raw'] = fread($handle, $data['size']);
}
return $data;
}
Итак, данные получены, мы их можем вывести в нужном на месте простым исполнением чего-то в духе:
echo $audio->getSampleRate();
Создание wav-файлов
Итак, меня, как человека, окончившего музыкальную школу когда-то, интересовало именно генерация мелодии на основе нот. Осталось только переложить знание музыкальной грамоты и физики на код.
Самым простым этапом в этом деле стало превратить ноту в код. По сути, любая нота характеризуется в первую очередь частотой звучания. Например, нота «ля» — это частота 440 Гц (стандартная частота камертона для настройки музыкальных инструментов).
По сути, нам остается только сопоставить каждой ноте её частоту. Всего нот (тонов) в октаве 7, а полутонов — 12. И у некоторых полутонов имеется несколько вариантов написания. Например, «фа-бемоль» это тоже самое, что и «ми». Или «соль-диез» это тоже самое, что и «ля-бемоль».
Итак, превратим эти знания в код:
class Note
{
const C = 261.63;
const C_SHARP = 277.18;
const D = 293.66;
const D_FLAT = self::C_SHARP;
const D_SHARP = 311.13;
const E = 329.63;
const E_FLAT = self::D_SHARP;
const E_SHARP = self::F;
const F = 346.23;
const F_FLAT = self::E;
const F_SHARP = 369.99;
const G = 392.00;
const G_FLAT = self::F_SHARP;
const G_SHARP = 415.30;
const A = 440.00;
const A_FLAT = self::G_SHARP;
const A_SHARP = 466.16;
const H = 493.88;
const H_FLAT = self::A_SHARP;
public static function get($note)
{
switch ($note) {
case 'C':
return self::C;
case 'C#':
return self::C_SHARP;
case 'D':
return self::D;
case 'D#':
return self::D_SHARP;
case 'E':
return self::E;
case 'E#':
return self::E_SHARP;
case 'F':
return self::F;
case 'F#':
return self::F_SHARP;
case 'G':
return self::G;
case 'G#':
return self::G_SHARP;
case 'A':
return self::A;
case 'A#':
return self::A_SHARP;
case 'B':
return self::H_FLAT;
case 'H':
return self::H;
}
}
}
Вообще, музыка достаточно точная наука. В нашем случае, это, в первую очередь, означает, что все возможные звучания различных инструментов уже давно описаны физиками и математиками, что, собственно, и позволяет производить, к примеру, синтезаторы. Подробно о синтезировании звуковых волн написано, например, здесь.
Ну а поскольку я еще и ленивый, подробно разбираться во всём этом деле у меня не было желания, поэтому я принялся яростно гуглить. Информацию об эмуляции звуков различных музыкальных инструментов на русском языке не нашлось ровным счетом ничего (может, конечно, я плохо искал, но не суть). Но в итоге мне удалось найти аудио-синтезатор, правда, на JavaScript (GitHub). В целом, оставалось только транслировать JS-код в PHP, чем я и занялся.
По итогу, получаем SampleBuilder, при помощи которого можем создавать сэмплы (куски wav-данных) задавая ноту, октаву и длительность звучания.
Код более подробно — по спойлером.
class Piano extends Generator
{
...
public function getDampen($sampleRate = null, $frequency = null, $volume = null)
{
return pow(0.5 * log(($frequency * $volume) / $sampleRate), 2);
}
...
public function getWave($sampleRate, $frequency, $volume, $i)
{
$base = $this->getModulations()[0];
return call_user_func_array($base, [
$i,
$sampleRate,
$frequency,
pow(call_user_func_array($base, [$i, $sampleRate, $frequency, 0]), 2) +
0.75 * call_user_func_array($base, [$i, $sampleRate, $frequency, 0.25]) +
0.1 * call_user_func_array($base, [$i, $sampleRate, $frequency, 0.5])
]);
}
...
protected function getModulations()
{
return [
function($i, $sampleRate, $frequency, $x) {
return 1 * sin(2 * M_PI * (($i / $sampleRate) * $frequency) + $x);
},
...
];
}
}
class SampleBuilder
{
/**
* @var Generator
*/
protected $generator;
...
public function note($note, $octave, $duration)
{
$result = new \SplFixedArray((int) ceil($this->getSampleRate() * $duration * 2));
$octave = min(8, max(1, $octave));
$frequency = Note::get($note) * pow(2, $octave - 4);
$attack = $this->generator->getAttack($this->getSampleRate(), $frequency, $this->getVolume());
$dampen = $this->generator->getDampen($this->getSampleRate(), $frequency, $this->getVolume());
$attackLength = (int) ($this->getSampleRate() * $attack);
$decayLength = (int) ($this->getSampleRate() * $duration);
for ($i = 0; $i < $attackLength; $i++) {
$value = $this->getVolume()
* ($i / ($this->getSampleRate() * $attack))
* $this->getGenerator()->getWave(
$this->getSampleRate(),
$frequency,
$this->getVolume(),
$i
);
$result[$i << 1] = Helper::packChar($value);
$result[($i << 1) + 1] = Helper::packChar($value >> 8);
}
for (; $i < $decayLength; $i++) {
$value = $this->getVolume()
* pow((1 - (($i - ($this->getSampleRate() * $attack)) / ($this->getSampleRate() * ($duration - $attack)))), $dampen)
* $this->getGenerator()->getWave(
$this->getSampleRate(),
$frequency,
$this->getVolume(),
$i
);
$result[$i << 1] = Helper::packChar($value);
$result[($i << 1) + 1] = Helper::packChar($value >> 8);
}
return new Sample($result->getSize(), implode('', $result->toArray()));
}
}
Ну и небольшой пример кода, который проигрывает начало всем известного «К Элизе» Л. Бетховена.
$sampleBuilder = new \Wav\SampleBuilder(\Wav\Generator\Piano::NAME);
$samples = [
$sampleBuilder->note('E', 5, 0.3),
$sampleBuilder->note('D#', 5, 0.3),
$sampleBuilder->note('E', 5, 0.3),
$sampleBuilder->note('D#', 5, 0.3),
$sampleBuilder->note('E', 5, 0.3),
$sampleBuilder->note('H', 4, 0.3),
$sampleBuilder->note('D', 5, 0.3),
$sampleBuilder->note('C', 5, 0.3),
$sampleBuilder->note('A', 4, 1),
];
$builder = (new Wav\Builder())
->setAudioFormat(\Wav\WaveFormat::PCM)
->setNumberOfChannels(1)
->setSampleRate(\Wav\Builder::DEFAULT_SAMPLE_RATE)
->setByteRate(\Wav\Builder::DEFAULT_SAMPLE_RATE * 1 * 16 / 8)
->setBlockAlign(1 * 16 / 8)
->setBitsPerSample(16)
->setSamples($samples);
$audio = $builder->build();
$audio->returnContent();
Ссылки
Код полностью размещен на github: https://github.com/nkolosov/wav
Если кого-то заинтересовало, подключить к своему проекту можно при помощи composer:
composer require nkolosov/wav
Дальнейшие планы
Ну, во-первых, хотелось бы реализовать полную поддержку wav-файлов (обработку всех секций), реализовать поддержку многоканальных файлов, возможно — поддержку различных форматов wav (со сжатием и т.п), реализовать графическое отображение волны (на Хабре была статья о том, как это сделать на Python, мне же интересно сделать это на PHP).
В плане генерации, добавить еще некоторые инструменты, попытаться сделать звучание более плавным, чтобы получилась реальная возможность копировать целые музыкальные произведения в код, реализовать возможность проигрывать аккорды и т. д.
Если есть желающие присоединиться — welcome на GitHub.
Комментарии (13)
aezhko
04.05.2016 13:57А почему вы решили захардкодить значения частот нот, не проще ли получать эти значения простым делением заданной ля (в вашем случае 440) на соответствующее искомой ноте n?
Anexroid
04.05.2016 14:08Простым делением тут работать не будет, т.к. изменение частоты между нотами растет экспоненциально. То есть, условно, между A4 и A3 разность будет 220, а между A3 и A2, уже 110. Ну и как бы зачем вводить функцию для вычисления, если захардкодить реально проще?
В дальнейшем, возможно, сделаю именно динамический расчет, для более точного соответствия реальным звукам.aezhko
04.05.2016 14:26Ну просто захардкоженная функция являет собой огромный свич, чем уже глаза «режет». Плюс как только вы захотите сделать частоту ля задаваемой, что часто важно, вам придется все равно придется все это дело переписывать.
Anexroid
04.05.2016 18:07Как уже писал выше, это пока самый минимальный работающий функционал. Несомненно, по мере доработок придется переписывать многое. В идеале, хотелось бы получить некоторый формат перевода обычной нотной записи в, например, строку, и её парсинг и «компиляцию» в wav-файл.
Alexufo
04.05.2016 15:51+2Графическое изображение волны уже делали на php https://github.com/afreiday/php-waveform-png
Однако, когда мне понадобилась волна из mp3, я никак не мог понять зачем применять php для этого, если все равно в любом случае требуется приложение для получения wav файла.
Куда проще воспользоваться ffmpeg для генерации волны ( правда попробовать настроить цвета этой волны — какой то лютый капец )
Когда я реализовывал плеер потипу как на soundcloud вот тут http://serebniti.ru/airs/2016-03-15/ ( анимация у меня не на канвасе, а css) я решил отказаться от волны на png в сторону json файла из png, получаемом на лету, по нескольким причинам.
1) Адаптивность к экранам. Я могу грамотно ресемплировать волну независимо от экрана из png в любую ширину.
Вот так http://cdn.serebniti.ru/getjson.php?id=5718&w=286 json для ширины экрана 3*286px (ширина просто 3 пикселя одного столбика)
2) Гибкость в дизайну. Уж из json можно на конве нарисовать что угодно.
3) Размер. Немножечко, но меньше, в json только Y координата + gzip.Anexroid
04.05.2016 18:11Графическое изображение волны уже делали на php https://github.com/afreiday/php-waveform-png
Хочется сделать, во-первых, без использования lame для пережатия, да. А во-вторых, более человеко-понятно.
Когда я реализовывал плеер потипу как на soundcloud вот тут http://serebniti.ru/airs/2016-03-15/ ( анимация у меня не на канвасе, а css) я решил отказаться от волны на png в сторону json файла из png, получаемом на лету, по нескольким причинам.
В целом да, интересный вариант, можно подумать в эту сторону, спасибо.Alexufo
05.05.2016 01:55Хочется сделать, во-первых, без использования lame для пережатия, да.
Хм. если на входе mp3 вам и так и так придеться распаковывать чем то в wav. Я пробовал способ ресемплирования json на клиенте
но потом отказался
http://stackoverflow.com/a/35880655/2497351
BaronAleks
05.05.2016 08:21То есть Вы сначала с помощью ffmpeg получаете png, а делее интерпретируете его в json?
Alexufo
05.05.2016 10:03Да. Через ffmpeg получаю waveform с черным фоном и красной волной. Png уходит в папку с картинками, а JSON я генерю на лету из PNG через GD либу, ей же и делаю ресемлинг ( рейсайз тот который без интерполяции, самый простой, иначе полутона получаются и подсчет конкретно красных пикселей становится затруднительным)
smarteq
05.05.2016 05:48Спасибо )
Буду следить за вашим проектом, возможно не только следить, пока трудно со временем, но мне эта тема прям очень интересна, только в контексте работы с подобными вещами на кубиборд/расбери-подобных железках, впрочем это не так уж и имеет значение.
В любом случае круто =)Anexroid
05.05.2016 08:23Ну, прям регулярно-регулярно ничего не обещаю, делалось за 2 вечера с перерывом в неделю. Как правило, жду более менее свободных выходных, но делать что-нибудь более-менее часто :)
chilic
Спасибо за статью. Век живи — Век учись.