Перейти к содержанию

WAV PCM16: от семплов к файлу

В прошлом уроке получили готовый обработанный AudioBuffer. Теперь превратим его в файл, который можно открыть обычным аудиоплеером. Выберем простой WAV с несжатым PCM16, одним или двумя каналами. К концу урока вы сможете связать заголовок, количество кадров и размер файла, а также объяснить переход от чисел с плавающей точкой к знаковым шестнадцатибитным значениям.

Полный код находится в wav.mjs архива серии. Во время подготовки файл не формировался и не открывался. Приведённые числа — ожидаемые расчёты, записанные также в expected.json. Наш кодер не является универсальным декодером WAV и не поддерживает все возможные разновидности контейнера.

Контейнер и формат данных

WAV строится на RIFF: файл содержит идентификатор, размер и последовательность блоков. В нашем результате есть только заголовок RIFF/WAVE, блок fmt и блок data. Звуковые данные записываются как PCM16. Мы не переносим теги исходного MP3, изображения обложки, дополнительные RIFF-блоки или сведения об авторе.

Основу устройства блоков описывает документация RIFF Microsoft. Поля PCM-формата и отношения размера блока перечислены в WAVEFORMATEX. Для нашей ограниченной структуры заголовок занимает 44 байта. У произвольного WAV он не обязан иметь именно такую длину.

В fmt указываем тег формата 1, число каналов, частоту, число бит на значение и служебные размеры. Все многобайтные числа записываем little-endian. Для DataView это означает аргумент true у методов setUint16, setUint32 и setInt16.

Размер одного кадра

PCM16 использует два байта на значение одного канала. Поэтому blockAlign = channels * 2: для mono два байта, для stereo четыре. Скорость байтов равна sampleRate * blockAlign. Длина звуковых данных — frames * blockAlign, а всего файла — 44 плюс эта длина.

const dataBytes = frames * channels * 2;
const bytes = new ArrayBuffer(44 + dataBytes);
const view = new DataView(bytes);

Это начало кодера после проверки буфера. Нельзя использовать buffer.length * 2 для стерео: length обозначает кадры, а не сумму значений каналов. Ошибка уменьшит выделенный массив вдвое и испортит запись либо приведёт к выходу за границы DataView.

Для 24 000 кадров stereo при 48 кГц получаем blockAlign = 4, скорость 192 000 байт в секунду, данные 96 000 байт, файл 96 044. Поле размера RIFF содержит 36 + 96 000, поскольку первые восемь байт идентификатора и размера не включаются в это значение. Эти числа можно проверить арифметически до запуска браузера.

Заполняем заголовок

В начале записываем ASCII RIFF, затем размер, WAVE, идентификатор fmt и размер блока формата 16. За полями формата следует data и размер звуковой части. Полный порядок и смещения находятся в encodeWav(); фрагмент ниже показывает отношения полей, а не весь заголовок.

view.setUint16(22, channels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * channels * 2, true);
view.setUint16(32, channels * 2, true);
view.setUint16(34, 16, true);

Указанные смещения верны для нашего канонического заголовка. При добавлении новых блоков или расширенного формата нельзя считать, что данные всегда начинаются на 44-м байте. Мы создаём файл известного устройства, а не разбираем внешний контейнер через фиксированные позиции.

Из Float32 в PCM16

Канальные массивы Web Audio содержат числа с плавающей точкой. Для PCM16 используем диапазон от −32768 до 32767. Значения ограничиваем от −1 до 1, отрицательную сторону умножаем на 32768, неотрицательную — на 32767, затем округляем. Асимметрия связана с диапазоном знакового целого, у которого отрицательных значений на одно больше.

const sample = Math.max(-1, Math.min(1, value));
const pcm = Math.round(sample < 0
  ? sample * 32768
  : sample * 32767);
view.setInt16(offset, pcm, true);

Ожидаемые значения для −1, −0.5, 0, 0.5, 1 равны −32768, −16384, 0, 16384, 32767. Половина положительного диапазона заканчивается дробью и округляется вверх. Этот договор конкретного кодера следует учитывать при независимой проверке значений.

Перед преобразованием проверяем конечность числа. NaN или бесконечность не должны молча стать правдоподобным звуком. Если значение выходит за обычный диапазон, ограничиваем его и увеличиваем счётчик clipped. Счётчик относится к отдельным значениям каналов, а не к кадрам или секундам. В стерео один кадр может дать два таких события.

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

Порядок каналов в файле

AudioBuffer хранит каналы отдельно, а PCM WAV чередует их по кадрам. Поэтому внешний цикл идёт по frame, внутренний — по channel. Для stereo последовательность получается L0, R0, L1, R1. Запись сначала всех левых, а затем всех правых значений создаст другое содержимое при том же размере.

Смещение начинается с 44 и увеличивается на два после каждого значения. По окончании ожидается ровно 44 + dataBytes. Затем создаём Blob с типом audio/wav и временную ссылку fragment.wav. URL освобождается позднее, после передачи скачивания браузеру. Это не подтверждает фактическое сохранение пользователем.

Для независимой проверки порядка байтов можно взять значение −32768. В знаковом шестнадцатибитном представлении оно имеет код 0x8000, а little-endian-запись даёт байты 00 80. Положительное максимальное 32767 даёт FF 7F. Такой маленький пример обнаруживает ошибку порядка без прослушивания большого файла. В авторской подготовке это расчёт, а не прочитанный результат экспорта.

Размеры RIFF записываются в 32-битных полях. Наши пределы короткого mono/stereo-буфера удерживают файл далеко от этой границы. Для многочасовой записи или большого числа каналов понадобился бы другой договор размеров и, возможно, иной контейнер. Расширение допустимой длительности нельзя делать без пересмотра памяти и бинарного формата.

Проверяем договор файла

При самостоятельном запуске сравните размер Blob с формулой и откройте WAV в независимом плеере. Проверьте число каналов, частоту и длительность, а для контрольных значений — little-endian-запись PCM16. Наличие расширения .wav само по себе не доказывает правильность заголовка или порядка каналов.

Эти проверки пока не выполнены. Мы получили кодер ограниченного, объяснимого формата и конкретные инварианты для будущей проверки. В последнем уроке соберём жизненный цикл аудиомастерской, сообщения об ошибках, доступное управление и описание границ перед передачей проекта.

Оглавление серии