Парсинг заголовка и проигрывание WAV-файла на Scala
6 апреля 2015
В этой заметке будет описана структура 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) - данные
Рассмотрим конкретный пример:
Наиболее интересные части заголовка я подчеркнул красным. По смещению 0x04 хранится размер файла за вычетом 8 байт, 0xa97e30 + 8 = 11107896 в точности соответствует размеру файла на диске. Далее по смещению 0x16 мы видим, что имеется только один канал, то есть, звук в моно. Частота дискретизации в данном случае 0xac44 или 44100. Байт на одну секунду воспроизведения 0x15888 или 88200, по 16 бит на сэмпл. По смещению 0x20 действительно видим, что сэмпл по всем каналам занимает 2 байта. Следом видим число бит в сэмпле для одного канала — 0x10 или 16 бит. Наконец, по смещению 0x28 видим длину следующих за заголовком данных, 0xa97e0c или 11107852 байт, что в точности соответствует длине файла минус 44 байта под заголовок.
Вы могли заметить, что в заголовке есть некоторая избыточность. Число байт на секунду воспроизведения вычисляется из частоты дискретизации и размера сэмпла. Число бит в сэмпле для одного канала всегда должно быть в точности (байт на сэмпл / число каналов) * 8. Честно говоря, я не знаю точно, зачем нужна эта избыточность, но подозреваю, что для WAV со сжатием.
Теперь напишем небольшую программу на Scala, которая парсит заголовок WAV файла, а затем воспроизводит его. Нам понадобятся кое-какие вспомогательные классы и методы:
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, сначала идут младшие байты, потом старшие.
Для удобства определим несколько констант:
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)
}
}
}
Ну и остальное — дело техники:
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 файла в память, что может быть очень много мегабайт, если файл у вас большой.
Наконец, вот так можно проиграть файл:
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, но может стать причиной непонятных багов, если вы захотите работать с данными напрямую.
Все написанное выше, безусловно, довольно примитивно, но делает нас на шаг ближе к пониманию того, как можно передавать звук по сети, написать свой аудиоредактор, или, например, подступиться к задачам вроде распознавания и синтеза голоса.
Ссылки по теме:
- https://ru.wikipedia.org/wiki/Звук;
- http://audiocoding.ru/article/2008/05/22/wav-file-structure.html;
- https://stackoverflow.com/questions/1932490/java-generating-sound;
Дополнение: В заметке Учимся передавать звук с использованием протокола I2S вы найдете код парсинга WAV-фалов на языке Си. Также вам может понравиться статья Рисуем waveform на Scala при помощи Java 2D.
Метки: Scala, Аудио, Функциональное программирование.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.