Парсинг заголовка и проигрывание 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)  -     данные

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

Заголовок 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.

Метки: , , .


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