← На главную

Парсинг заголовка и проигрывание WAV-файла на Scala

В этой заметке будет описана структура WAV-файла, где у него заголовок, где данные, и что они из себя вообще представляют. Кроме того, мы узнаем, как можно воспроизвести WAV-файл на Scala, притом данные для воспроизведения могут браться не только из файлов на диске, но и передаваться по сети или даже генерироваться программой на лету. Но сначала вспомним немного теории.

Грубо говоря, можно думать о звуке, как о колеблющейся функции, чем-то вроде синуса, только амплитуда и частота колебаний меняются. В зависимости от частоты колебаний мы слышим разные звуки. Чем больше амплитуда, чем громче кажется звук. Обычный звук, который мы слышим, имеет частоту от 20 Гц до 20 кГц. Звук с меньшей частотой называется инфразвуком, а с большей – ультразвуком.

Довольно очевидный способ кодировать звук – мерить значение функции раз в какой-то промежуток времени и записывать полученный результат. Это называется импульсно-кодовой модуляцией (Pulse Code Modulation, PCM), вот наглядная картинка. Число раз, которое мы записываем значение в секунду, называется частотой дискретизации. По теореме Котельникова, чтобы восстановить записанный таким образом сигнал с произвольной точностью, частота дискретизации должна быть по крайней мере в два раза больше максимальной частоты сигнала. Для звука довольно часто используется частота дискретизации 44100 Гц. Значение сигнала при сохранении также нужно как-то округлять. Часто используются 16-и битовые значения.

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

Смещение Байт Описание ------------------------------------------------------------------ 0x00 (00) 4 "RIFF", сигнатура 0x04 (04) 4 размер фала в байтах минус 8 0x08 (08) 8 "WAVEfmt " 0x10 (16) 4 16 для PCM, оставшийся размер заголовка 0x14 (20) 2 1 для PCM, иначе есть какое-то сжатие 0x16 (22) 2 число каналов - 1, 2, 3... 0x18 (24) 4 частота дискретизации 0x1c (28) 4 байт на одну секунду воспроизведения 0x20 (32) 2 байт для одного сэпла включая все каналы 0x22 (34) 2 бит в сэмпле на один канал 0x24 (36) 4 "data" (id сабчанка) 0x28 (40) 4 сколько байт данных идет далее (размер сабчанка) 0x2c (44) - данные

Рассмотрим конкретный пример:

Заголовок WAV-файла

Наиболее интересные части заголовка я подчеркнул красным. По смещению 0x04 хранится размер файла за вычетом 8 байт, 0xa97e30 + 8 = 11107896 в точности соответствует размеру файла на диске. Далее по смещению 0x16 мы видим, что имеется только один канал, то есть, звук в моно. Частота дискретизации в данном случае 0xac44 или 44100. Байт на одну секунду воспроизведения 0x15888 или 88200, по 16 бит на сэмпл. По смещению 0x20 действительно видим, что сэмпл по всем каналам занимает 2 байта. Следом видим число бит в сэмпле для одного канала – 0x10 или 16 бит. Наконец, по смещению 0x28 видим длину следующих за заголовком данных, 0xa97e0c или 11107852 байт, что в точности соответствует длине файла минус 44 байта под заголовок.

Вы могли заметить, что в заголовке есть некоторая избыточность. Число байт на секунду воспроизведения вычисляется из частоты дискретизации и размера сэмпла. Число бит в сэмпле для одного канала всегда должно быть в точности (байт на сэмпл / число каналов) * 8. Честно говоря, я не знаю точно, зачем нужна эта избыточность, но подозреваю, что для WAV со сжатием.

Теперь напишем небольшую программу на Scala, которая парсит заголовок WAV файла, а затем воспроизводит его. Нам понадобятся кое-какие вспомогательные классы и методы:

case class WavInfo(channels: Long, sampleRate: Long, blockSize: Long, dataSize: Long) def arraySliceToLong(array: Array[Byte], from: Int, until: Int): Long = { val slice = array.slice(from, until) if(slice.size > 8) throw new RuntimeException(s"Invalid array slice length: $slice") slice.reverse.foldLeft(0L) { case(acc, x) => (acc << 8) | (x.toLong & 0xFF) } }

Класс WavInfo представляет собой декодированный заголовок. Этот класс хранит основную интересующую нас информацию – число каналов и так далее. Функция arraySliceToLong принимает на вход массив байт, делает срез в указанном месте и преобразует его в Long. Как вы уже могли обратить внимание, в WAV всегда используется little endian, сначала идут младшие байты, потом старшие.

Для удобства определим несколько констант:

object WavFile { val headerSize: Int = 44 val riffSignature = signatureToLong("RIFF") val waveFmtSignature = signatureToLong("WAVEfmt ") val dataSignature = signatureToLong("data") private def signatureToLong(sign: String): Long = { if(sign.length > 8) throw new RuntimeException(s"Signature is too long: $sign") sign.reverse.foldLeft(0L) { case(acc, x) => (acc << 8) | (x.toLong & 0xFF) } } }

Ну и остальное – дело техники:

class WavFile(fileName: String) { private val fileStream = { new BufferedInputStream(new FileInputStream(fileName)) } private val wavInfo = { val fileSize = new File(fileName).length() val rawData = Array.ofDim[Byte](WavFile.headerSize) val bytesRead = fileStream.read(rawData, 0, WavFile.headerSize) if (bytesRead != rawData.size) throw new RuntimeException("Failed to read wav header") decodeWavHeader(rawData, fileSize) } private val wavRawData = { val rawData = Array.ofDim[Byte](wavInfo.dataSize.toInt) val bytesRead = fileStream.read(rawData, 0, wavInfo.dataSize.toInt) if(bytesRead != wavInfo.dataSize.toInt) throw new RuntimeException("bytesRead != wavInfo.dataSize.toInt") rawData } def info(): WavInfo = wavInfo def rawData(): Array[Byte] = wavRawData def close(): Unit = Option(fileStream).foreach(_.close()) private def decodeWavHeader(rawData: Array[Byte], fileSize: Long): WavInfo = { if(rawData.size < WavFile.headerSize) { val err = s"Invalid header size ${rawData.size}}" throw new RuntimeException(err) } checkSignature(rawData, 0, 4, WavFile.riffSignature) checkSignature(rawData, 4, 8, fileSize - 8L) checkSignature(rawData, 8, 16, WavFile.waveFmtSignature) checkSignature(rawData, 16, 20, 16L) checkSignature(rawData, 20, 22, 1L) val channels = arraySliceToLong(rawData, 22, 24) val sampleRate = arraySliceToLong(rawData, 24, 28) val bytesPerSecond = arraySliceToLong(rawData, 28, 32) val blockSize = arraySliceToLong(rawData, 32, 34) val bitsPerSample = arraySliceToLong(rawData, 34, 36) checkSignature(rawData, 36, 40, WavFile.dataSignature) val expectedDataSize = fileSize - WavFile.headerSize checkSignature(rawData, 40, 44, expectedDataSize) if(sampleRate * blockSize != bytesPerSecond) throw new RuntimeException( "Invalid header: sampleRate * blockSize != bytesPerSecond "+ s"($sampleRate * $blockSize != $bytesPerSecond)" ) if((bytesPerSecond / sampleRate / channels) * 8 != bitsPerSample) throw new RuntimeException( "Invalid header: (bytesPerSecond / sampleRate) * 8 != " + s"bitsPerSample ($bytesPerSecond / $sampleRate) * 8 != " + s"$bitsPerSample" ) WavInfo(channels, sampleRate, blockSize, expectedDataSize) } private def checkSignature(rawData: Array[Byte], from: Int, until: Int, expected: Long): Unit = { val actual = arraySliceToLong(rawData, from, until) if(actual != expected) { val err = ("Wrong signature from %d until %d: 0x%08X expected," + " but 0x%08X found") format (from, until, expected, actual) throw new RuntimeException(err) } } }

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

Наконец, вот так можно проиграть файл:

def processFile(fileName: String): Unit = { var optWavFile: Option[WavFile] = None try { optWavFile = Some(new WavFile(fileName)) optWavFile foreach { wavFile => val info = wavFile.info() val rawData = wavFile.rawData() import javax.sound.sampled._ val sampleSizeInBytes = (info.blockSize / info.channels).toInt val sampleSizeInBits = sampleSizeInBytes * 8 val signed = { sampleSizeInBytes == 2 /* 16 bit */ } val af = new AudioFormat(info.sampleRate.toFloat, sampleSizeInBits, info.channels.toInt, signed, false) val sdl = AudioSystem.getSourceDataLine(af) sdl.open() sdl.start() sdl.write(rawData, 0, rawData.length) sdl.drain() sdl.stop() } } finally { optWavFile.foreach(_.close()) } }

Странно, но по каким-то причинам данные в 16-и битовом WAV и 8-и битовом хранятся немного по-разному. В первом случае идет просто последовательность 16-и битовых чисел со знаком (тип Short в Scala) – (сэмпл 1 для канала 1, сэмпл 1 для канала 2), (сэмпл 2 для канала 1, сэмпл 2 для канала 2), и так далее. Однако во втором случае идет последовательность беззнаковых чисел, которые переводятся в знаковые по формуле (Byte.MaxValue.toLong – valueLong). В приведенном выше коде это проявляется только при вычислении аргумента signed, но может стать причиной непонятных багов, если вы захотите работать с данными напрямую.

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

Ссылки по теме:

Дополнение: В заметке Учимся передавать звук с использованием протокола I2S вы найдете код парсинга WAV-фалов на языке Си. Также вам может понравиться статья Рисуем waveform на Scala при помощи Java 2D.