Работа с WAV-файлами с использованием PHP

в 8:56, , рубрики: composer, php, wav, waveform, ненормальное программирование

Всё началось с того, что я задумался о том, как отобразить на сайте информацию о загруженном аудио-файле. Для начала решил разобраться с самым простым форматом — 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 WavSampleBuilder(WavGeneratorPiano::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 WavBuilder())
    ->setAudioFormat(WavWaveFormat::PCM)
    ->setNumberOfChannels(1)
    ->setSampleRate(WavBuilder::DEFAULT_SAMPLE_RATE)
    ->setByteRate(WavBuilder::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.

Автор: Anexroid

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js