Рисуем waveform на Scala при помощи Java 2D

13 апреля 2015

В прошлый раз мы научились работать с WAV-файлами. Точнее, мы с вами научились работать с заголовком, а сами данные за нас проиграл кто-то другой. Настало время поработать непосредственно с самими данными, а заодно и попрактиковаться в работе с двухмерной графикой при помощи Java 2D.

Как уже отмечалось, сэмплы в WAV файле идут вперемешку для всех каналов, а также могут быть закодированы по крайней мере двумя разными способами. Не факт, что нас интересуют все каналы, которые есть в файле, и уж совершенно точно нам не хотелось бы думать о разных способах кодирования. Намного удобнее было бы работать с каналами по отдельности, и чтобы сэмплы всегда представлялись, скажем, действительным числом от -1 до 1. Сказано — сделано:

type ChannelData = Array[Double]
type Channels = Array[ChannelData]

def rawDataToChannels(wavInfo: WavInfo,
                      rawData: Array[Byte]): Channels = {
  val samplesNumber = (wavInfo.dataSize / wavInfo.blockSize).toInt
  val wavChannels: Channels = {
    (
      for(_ <- 1L to wavInfo.channels)
      yield Array.ofDim[Double](samplesNumber)
    ).toArray
  }
  val sampleSizeInBytes = (wavInfo.blockSize / wavInfo.channels).toInt
  val maxAmplitude = Math.pow(2, 8 * sampleSizeInBytes - 1)

  for(i <- 0 until samplesNumber) {
    for(ch <- 0 until wavInfo.channels.toInt) {
      val from = (i * wavInfo.blockSize +
                  ch * (wavInfo.blockSize / wavInfo.channels)
                 ).toInt
      val sampleLong = arraySliceToLong(rawData, from,
                                        from + sampleSizeInBytes)
      val sampleDouble = { // (*)
        if(sampleSizeInBytes == 2) sampleLong.toShort.toLong
        else (Byte.MaxValue.toLong - sampleLong).toDouble
      }
      wavChannels(ch)(i) = sampleDouble / maxAmplitude
    }
  }

  wavChannels
}

Приведенный код, как и в прошлой заметке, не претендует на эффективность. Обратите внимание на строчку с пометкой (*). О разном представлении чисел в 8-и битовых и 16-и битовых WAV говорилось в прошлый раз.

Теперь мы можем с легкостью визуализировать содержимое файла:

def processFile(inputFile: String, outputFile: String,
                channelNumber: Int, width: Int,
                height: Int): Unit = {
  var optWavFile: Option[WavFile] = None
  try {
    optWavFile = Some(new WavFile(inputFile))
    optWavFile foreach { wavFile =>
      val info = wavFile.info()
      val rawData = wavFile.rawData()
      val channels = rawDataToChannels(info, rawData)

      val img = new BufferedImage(width, height,
                                  BufferedImage.TYPE_INT_ARGB)
      val graphics = img.createGraphics()

      graphics.setColor(new Color(255, 255, 255))
      graphics.fillRect(0, 0, width, height)

      graphics.setColor(new Color(0, 0, 255))

      val totalSamples = channels(channelNumber).length

      for(x <- 0 until width) {
        val fromSample = x * (totalSamples / width)
        val toSample = (x + 1) * (totalSamples / width)
        var min = 0.0
        var max = 0.0
        for(sn <- fromSample to toSample) {
          val sample = channels(channelNumber)(sn)
          min = Math.min(min, sample)
          max = Math.max(max, sample)
        }
        // (0;0) point is top left corner
        // be careful and don't draw an image upside down!
        graphics.draw(new Line2D.Double(
            x.toDouble, (height/2).toDouble - max*(height/2).toDouble,
            x.toDouble, (height/2).toDouble - min*(height/2).toDouble
          ))
      }

      ImageIO.write(img, "PNG", new File(outputFile))
    }
  } finally {
    optWavFile.foreach(_.close())
  }
}

Алгоритм, как видите, не сложный. Ширина картинки известна заранее. Для каждой координаты X определяется соответствующий интервал на дорожке. На этом интервале находятся максимальные и минимальные значения сэмплов, которые и отображаются для данного X.

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

А вот и примеры вывода программы:

Weveform, нарисованный программой на Scala

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

Исходники к этой заметке вы найдете в этом репозитории. На момент написания этих строк программа не была протестирована на 24-х битовых WAV-файлах, а также файлах, содержащих дополнительные секции помимо «data». Такие файлы, например, генерирует Reaper. Если хотите, можете считать доработку программы соответствующим образом своим домашним заданием.

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

Метки: , , .


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