Всё началось с того, что я задумался о том, как отобразить на сайте информацию о загруженном аудио-файле. Для начала решил разобраться с самым простым форматом — wav. Как оказалось, ничего сложного в этом нет и писать именно об этом, в общем-то, не было бы никакого смысла, благо, информации о том, как устроен wav-файл «изнутри» в Интернете полно.

И тут Остапа понесло И тут в голову пришла светлая мысль о том, что было бы прикольно не просто отображать информацию о файле, но и иметь возможность генерировать такой файл «на лету». Думаю, все видели в сети всевозможные «онлайн-пианино» и прочее, верно?

Итак, что мне удалось сделать за 2 вечера — под катом.

Итак, для начала всё-таки вернемся к структуре WAV-файла, как такового. Для простоты берем самый просто одноканальный wav-файл без сжатия.

Любой wav-файл состоит из нескольких секций (чанков, chunks). Подробно обо всех секциях можно почитать, например, по ссылке, я же остановлюсь на трёх основных:

  • секция типа —
    "RIFF"
  • секция формата —
    "fmt "
  • секция данных —
    "data"

У каждой секции есть её ID, размер секции и, собственно, какие-то данные, специфичные для данной секции.

Секция RIFF проста до безобразия: «RIFF<размер файла-8>WAVE»

<размер файла-8> потому что это значение характеризует «сколько байт содержится далее». Соответственно, 4 байта на само значение «сколько» и еще 4 на «RIFF» который был в начале.

В секции формата хранится основная интересующая обычного человека информация о файле: Sample Rate (частота дискретизации, например 44100 Гц), количество каналов (1 = моно, 2 = стерео и так далее).

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

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

Заголовок (Header.php)
class Header
{
    ...
    /**
     * @var string
     */
    protected $id;

    /**
     * @var int
     */
    protected $size;

    /**
     * @var string
     */
    protected $format;

   ...


Секция формата (FormatSection.php)
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;
    
    ...


Секция данных (DataSection.php)
class DataSection
{
    ...
    /**
     * @var string
     */
    protected $id;

    /**
     * @var int
     */
    protected $size;

    /**
     * @var int[]
     */
    protected $raw;

    ...


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

Собственно, для их чтения сделаем небольшую обёртку-helper для fread для более удобного чтения именно бинарных данных.

Helper.php
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-файла:

Чтение данных из 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-данных) задавая ноту, октаву и длительность звучания.

Код более подробно — по спойлером.

SampleBuilder
Генератор звучания фортепиано
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);
            },
            ...
        ];
    }
}


SampleBuilder
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()));
    }
}



Ну и небольшой пример кода, который проигрывает начало всем известного «К Элизе» Л. Бетховена.

К Элизе на PHP
$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)


  1. chilic
    04.05.2016 12:45
    -1

    Спасибо за статью. Век живи — Век учись.


  1. aezhko
    04.05.2016 13:57

    А почему вы решили захардкодить значения частот нот, не проще ли получать эти значения простым делением заданной ля (в вашем случае 440) на соответствующее искомой ноте n?


    1. Anexroid
      04.05.2016 14:08

      Простым делением тут работать не будет, т.к. изменение частоты между нотами растет экспоненциально. То есть, условно, между A4 и A3 разность будет 220, а между A3 и A2, уже 110. Ну и как бы зачем вводить функцию для вычисления, если захардкодить реально проще?

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


      1. aezhko
        04.05.2016 14:26

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


        1. Anexroid
          04.05.2016 18:07

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


  1. 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.


    1. Alexufo
      04.05.2016 15:55
      +1

      Вот пока как то так image


    1. 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, получаемом на лету, по нескольким причинам.

      В целом да, интересный вариант, можно подумать в эту сторону, спасибо.


      1. Alexufo
        05.05.2016 01:55

        Хочется сделать, во-первых, без использования lame для пережатия, да.

        Хм. если на входе mp3 вам и так и так придеться распаковывать чем то в wav. Я пробовал способ ресемплирования json на клиенте
        но потом отказался

        http://stackoverflow.com/a/35880655/2497351


    1. BaronAleks
      05.05.2016 08:21

      То есть Вы сначала с помощью ffmpeg получаете png, а делее интерпретируете его в json?


      1. Alexufo
        05.05.2016 10:03

        Да. Через ffmpeg получаю waveform с черным фоном и красной волной. Png уходит в папку с картинками, а JSON я генерю на лету из PNG через GD либу, ей же и делаю ресемлинг ( рейсайз тот который без интерполяции, самый простой, иначе полутона получаются и подсчет конкретно красных пикселей становится затруднительным)


  1. smarteq
    05.05.2016 05:48

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

    В любом случае круто =)


    1. Anexroid
      05.05.2016 08:23

      Ну, прям регулярно-регулярно ничего не обещаю, делалось за 2 вечера с перерывом в неделю. Как правило, жду более менее свободных выходных, но делать что-нибудь более-менее часто :)