В этой заметке будет описана структура 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)  —     данные

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

Наиболее интересные части заголовка я подчеркнул красным. По смещению 0x 04 хранится размер файла за вычетом 8 байт, 0x a97e30 + 8 = 11107896 в точности соответствует размеру файла на диске. Далее по смещению 0x 16 мы видим, что имеется только один канал, то есть, звук в моно. Частота дискретизации в данном случае 0x ac44 или 44100. Байт на одну секунду воспроизведения 0x 15888 или 88200, по 16 бит на сэмпл. По смещению 0x 20 действительно видим, что сэмпл по всем каналам занимает 2 байта. Следом видим число бит в сэмпле для одного канала — 0x 10 или 16 бит. Наконец, по смещению 0x 28 видим длину следующих за заголовком данных, 0x a97e0c или 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 .

admin

Share
Published by
admin

Recent Posts

Консоль удаленного рабочего стола(rdp console)

Клиент удаленного рабочего стола (rdp) предоставляет нам возможность войти на сервер терминалов через консоль. Что…

1 месяц ago

Настройка сети в VMware Workstation

В VMware Workstation есть несколько способов настройки сети гостевой машины: 1) Bridged networking 2) Network…

1 месяц ago

Логи брандмауэра Windows

Встроенный брандмауэр Windows может не только остановить нежелательный трафик на вашем пороге, но и может…

1 месяц ago

Правильный способ отключения IPv6

Вопреки распространенному мнению, отключить IPv6 в Windows Vista и Server 2008 это не просто снять…

1 месяц ago

Ключи реестра Windows, отвечающие за параметры экранной заставки

Параметры экранной заставки для текущего пользователя можно править из системного реестра, для чего: Запустите редактор…

1 месяц ago

Как управлять журналами событий из командной строки

В этой статье расскажу про возможность просмотра журналов событий из командной строки. Эти возможности можно…

1 месяц ago