Leitura de arquivos binarios em Go. Um guia pratico em como ler arquivos wav
Código fonte: https://github.com/mateusfmcota/reading-wave-go
Versão em inglês do post: https://dev.to/mateusfmcota/reading-binary-files-with-go-a-pratical-example-using-wave-files-53gc
Introdução
Há umas semanas eu estava conversando com um colega sobre programação e um dos assuntos que apareceu era sobre a leitura e parsing de arquivos. Pensando nisso, decidi fazer um sistema simples de leitura e escrita de arquivos binários em Go.
O formato escolhido foram de arquivos WAV (PCM para ser mais exato).
Entendendo a estrutura do arquivo wav
O PCM WAV é um arquivo que segue a especificação RIFF da Microsoft para o armazenamento de arquivos multimidia. A forma canônica do arquivo é constituído por essas 3 seções:
A primeira estrutura em roxo é chamado de RIFF Header, que possui os 3 seguintes campos:
- ChunkID: É usado para especificar o tipo do chunk, por ser do tipo RIFF, o valor esperado nele é a string "RIFF".
- ChunkSize: Tamanho total do arquivo - 8. Como o ChunkId e o Chunk size tem 4 bytes cada, a maneira mais fácil de calcular esse campo é pegar o tamanho total do arquivo e tirar 8 dele.
- Format: O tipo do formato do arquivo, nesse caso é a string "WAVE".
A sessão a seguir, em verde, é chamada de fmt. Essa estrutura especifica o formato e os metadados do arquivo de som.
- SubChunk1Id: Contem a string "fmt ", que possui um espaço no final por causa dos campos de id são de 4 bytes e como "fmt" possui 3, adicionou-se um espaço.
- Subchunk1Size: É o tamanho total dos campos a seguir, no caso do WAV PCM esse valor é 16.
- AudioFormat: Para valores diferentes de 1(PCM), indica uma forma de compressão.
- NumChannels: Numero de canais, 1 = mono, 2 = stereo, ...
- SampleRate: Taxa de amostragem do som ex: 8000, 44100, ...
- ByteRate: SampleRate * NumChannels * BitsPerSample / 8, é a quantidade de bytes que tem em 1 segundo de som.
- BlockAlign: NumChannels * BitsPerSample / 8, é a quantidade de bytes por amostra incluindo todos os canais.
- BitsPerSample: Quantidade de bits por amostra, 8bits, 16 bits, ...
A terceira sessão, em laranja, é a estrutura de dados onde o som é armazenado em si, no qual possui os seguintes campos:
- Subchunk2ID: Contem a string "data".
- Subchunk2Size: NumSamples * NumChannels * BitsPerSample/8, também é a quantidade de bytes restantes no arquivo.
- data: Os dados do som.
LIST Chunk
Quando criei um som para fazer o teste do programa, usando o ffmpeg, eu percebi que ele tinha um header a mais, apesar desse header não estar na especificação canônica, eu acabei criando uma estrutura básica para ela.
Essa estrutura é do tipo LIST, que segue a seguinte especificação:
- ChunkId: Contem a string "LIST".
- Size: O tamanho da estrutura LIST - 8. Basicamente ele informa o tamanho em bytes restante na estrutura LIST.
- listType: Vários caracteres ASCII, eles dependem do tipo do arquivo, alguns exemplos são: WAVE, DLS, ...
- data: Depende do listType, mas nesse caso não se aplica a esse programa.
Detalhes de cada header:
Um detalhe que resolvi não explicar no ultimo tópico é o tamanho e a ordem dos bits, little-endian e big-endian, de cada campo para simplificar. Por isso criei essa tabela com todos esses campos, tamanho e ordem dos bits:
RIFF Header:
Offset | Campo | Tamanho | Ordem dos bits |
---|---|---|---|
0 | ChunkId | 4 | big |
4 | ChunkSize | 4 | little |
8 | Format | 4 | big |
FMT Header:
Offset | Campo | Tamanho | Ordem dos bits |
---|---|---|---|
12 | Subchunk1ID | 4 | big |
16 | Subchunk1Size | 4 | little |
20 | AudioFormat | 2 | little |
22 | NumChannels | 2 | little |
24 | SampleRate | 4 | little |
28 | ByteRate | 4 | little |
32 | BlockAlign | 2 | little |
34 | BitsPerSample | 2 | little |
LIST Header:
Offset | Campo | Tamanho | Ordem dos bits |
---|---|---|---|
* | chunkID | 4 | big |
* | size | 4 | big |
* | listType | 4 | big |
* | data | Variável | big |
* Como é especifico de cada plataforma e na criação não vou utilizar esse campo, vou ignorar o calculo de offset deles.
Data Header:
Offset | Campo | Tamanho | Ordem dos bits |
---|---|---|---|
36 | SubChunk2ID | 4 | big |
40 | SubChunk2Size | 4 | big |
44 | Data | Variável | big |
Criando o programa
Depois dessa grande explicação de como um arquivo WAVE funciona, agora é a parte de por a mão na massa e, para deixar o trabalho mais fácil, vou usar a biblioteca encoding/binary
que é nativa do Go para auxiliar.
Criando as estruturas:
A primeira coisa que eu fiz na aplicação foi criar 4 structs, um para cada header da seguinte maneira:
type RIFF struct {
ChunkID []byte
ChunkSize []byte
ChunkFormat []byte
}
type FMT struct {
SubChunk1ID []byte
SubChunk1Size []byte
AudioFormat []byte
NumChannels []byte
SampleRate []byte
ByteRate []byte
BlockAlign []byte
BitsPerSample []byte
}
type LIST struct {
ChunkID []byte
size []byte
listType []byte
data []byte
}
type DATA struct {
SubChunk2Id []byte
SubChunk2Size []byte
data []byte
}
Criação de uma função para auxiliar a leitura de bytes
Apesar da biblioteca encoding/binary
ajudar muito a leitura de arquivos binários, um dos problemas dela é não ter um método implementado para ler um numero N de bytes de um dado arquivo.
Para isso eu criei uma função que apenas lê os n bytes de um os.File
e retorna esses valores.
func readNBytes(file *os.File, n int) []byte {
temp := make([]byte, n)
_, err := file.Read(temp)
if err != nil {
panic(err)
}
return temp
}
Leitura e parsing de um arquivo wave
Agora iremos fazer a leitura do arquivo para isso utilizamos o os.Open
:
file, err := os.Open("audio.wav")
if err != nil {
panic(err)
}
Para fazer o parsing do arquivo, primeiro criamos uma variável para cada estrutura e utilizamos a função readNBytes
, para ler cada campo:
// RIFF Chunk
RIFFChunk := RIFF{}
RIFFChunk.ChunkID = readNBytes(file, 4)
RIFFChunk.ChunkSize = readNBytes(file, 4)
RIFFChunk.ChunkFormat = readNBytes(file, 4)
// FMT sub-chunk
FMTChunk := FMT{}
FMTChunk.SubChunk1ID = readNBytes(file, 4)
FMTChunk.SubChunk1Size = readNBytes(file, 4)
FMTChunk.AudioFormat = readNBytes(file, 2)
FMTChunk.NumChannels = readNBytes(file, 2)
FMTChunk.SampleRate = readNBytes(file, 4)
FMTChunk.ByteRate = readNBytes(file, 4)
FMTChunk.BlockAlign = readNBytes(file, 2)
FMTChunk.BitsPerSample = readNBytes(file, 2)
subChunk := readNBytes(file, 4)
var listChunk *LIST
if string(subChunk) == "LIST" {
listChunk = new(LIST)
listChunk.ChunkID = subChunk
listChunk.size = readNBytes(file, 4)
listChunk.listType = readNBytes(file, 4)
listChunk.data = readNBytes(file, int(binary.LittleEndian.Uint32(listChunk.size))-4)
}
// Data sub-chunk
data := DATA{}
data.SubChunk2Id = readNBytes(file, 4)
data.SubChunk2Size = readNBytes(file, 4)
data.data = readNBytes(file, int(binary.LittleEndian.Uint32(data.SubChunk2Size)))
Um detalhe que gostaria explicar é a a linha que contem o código:
if string(subChunk) == "LIST"
Essa linha foi colocada por causa que o header do tipo LIST não é uma header padrão da especificação canônica de um arquivo WAVE, por isso eu verifico se ela existe ou não, se existir eu crio o campo, senão eu ignoro.
Imprimindo os campos:
Apesar de não termos utilizado a biblioteca encoding/binary
para leitura, ela será muito utilizada para a impressão, na tabela que eu coloquei acima que explica o tamanho e a ordem de bits de cada arquivo, ela é bem útil para indicar qual campo é little-endian e qual campo é big-endian.
Para fazer a impressão dos campos da tela criei essas 4 funções, 1 para cada tipo de header, que imprime o campo de acordo com a sua ordem de bits :
func printRiff(rf RIFF) {
fmt.Println("ChunkId: ", string(rf.ChunkID))
fmt.Println("ChunkSize: ", binary.LittleEndian.Uint32(rf.ChunkSize)+8)
fmt.Println("ChunkFormat: ", string(rf.ChunkFormat))
}
func printFMT(fm FMT) {
fmt.Println("SubChunk1Id: ", string(fm.SubChunk1ID))
fmt.Println("SubChunk1Size: ", binary.LittleEndian.Uint32(fm.SubChunk1Size))
fmt.Println("AudioFormat: ", binary.LittleEndian.Uint16(fm.AudioFormat))
fmt.Println("NumChannels: ", binary.LittleEndian.Uint16(fm.NumChannels))
fmt.Println("SampleRate: ", binary.LittleEndian.Uint32(fm.SampleRate))
fmt.Println("ByteRate: ", binary.LittleEndian.Uint32(fm.ByteRate))
fmt.Println("BlockAlign: ", binary.LittleEndian.Uint16(fm.BlockAlign))
fmt.Println("BitsPerSample: ", binary.LittleEndian.Uint16(fm.BitsPerSample))
}
func printLIST(list LIST) {
fmt.Println("ChunkId: ", string(list.ChunkID))
fmt.Println("size: ", binary.LittleEndian.Uint32(list.size))
fmt.Println("listType: ", string(list.listType))
fmt.Println("data: ", string(list.data))
}
func printData(data DATA) {
fmt.Println("SubChunk2Id: ", string(data.SubChunk2Id))
fmt.Println("SubChunk2Size: ", binary.LittleEndian.Uint32(data.SubChunk2Size))
fmt.Println("data", data.data)
}
Como a gente está fazendo a leitura de um arquivo, no qual é lido da "esquerda para a direita", pode-se dizer que a ordem de bits padrão é a big-endian, isso faz com que não tenha a necessidade de converter esses valores de big para little-endian.
Otimização:
Apesar de não termos usado a biblioteca encoding/binary
para o exemplo acima, é possível utilizá-la para ler arquivos de maneira mais rápida e elegante, mas não tão intuitiva inicialmente.
Ela possui o método read que permite que você leia os valores de um io.Reader
diretamente para uma struct. Apesar de soar simples, binary.read()
possui 2 singularidades.
binary.read
exige que a struct esteja bem definida, com os tamanhos e tipos de cada campo já instanciados.binary.read
exige que você passe para ele a ordem de bytes(big ou little-endian).
Tendo isso em vista, podemos melhorar o código.
Refatorando as structs
Uma das primeiras coisas que precisamos de fazer é criar as structs com os campos com os seus tamanhos pré-definidos, quando possível. Como exigem campos de valores variáveis, vou deixa-los em branco.
type RIFF struct {
ChunkID [4]byte
ChunkSize [4]byte
ChunkFormat [4]byte
}
type FMT struct {
SubChunk1ID [4]byte
SubChunk1Size [4]byte
AudioFormat [2]byte
NumChannels [2]byte
SampleRate [4]byte
ByteRate [4]byte
BlockAlign [2]byte
BitsPerSample [2]byte
}
type LIST struct {
ChunkID [4]byte
size [4]byte
listType [4]byte
data []byte
}
type DATA struct {
SubChunk2Id [4]byte
SubChunk2Size [4]byte
data []byte
}
Como observado acima os campos de data das headers LIST e DATA ficaram vazias, para isso lidaremos de outra maneira mais a frente.
Fazendo com que as funções de impressão pertençam a struct e não ao pacote
O próximo passo vai ser acoplar as funções de impressão a sua respectiva struct, de maneira que fique mais fácil de chama-las futuramente:
func (r RIFF) print() {
fmt.Println("ChunkId: ", string(r.ChunkID[:]))
fmt.Println("ChunkSize: ", binary.LittleEndian.Uint32(r.ChunkSize[:])+8)
fmt.Println("ChunkFormat: ", string(r.ChunkFormat[:]))
fmt.Println()
}
func (fm FMT) print() {
fmt.Println("SubChunk1Id: ", string(fm.SubChunk1ID[:]))
fmt.Println("SubChunk1Size: ", binary.LittleEndian.Uint32(fm.SubChunk1Size[:]))
fmt.Println("AudioFormat: ", binary.LittleEndian.Uint16(fm.AudioFormat[:]))
fmt.Println("NumChannels: ", binary.LittleEndian.Uint16(fm.NumChannels[:]))
fmt.Println("SampleRate: ", binary.LittleEndian.Uint32(fm.SampleRate[:]))
fmt.Println("ByteRate: ", binary.LittleEndian.Uint32(fm.ByteRate[:]))
fmt.Println("BlockAlign: ", binary.LittleEndian.Uint16(fm.BlockAlign[:]))
fmt.Println("BitsPerSample: ", binary.LittleEndian.Uint16(fm.BitsPerSample[:]))
fmt.Println()
}
func (list LIST) print() {
fmt.Println("ChunkId: ", string(list.ChunkID[:]))
fmt.Println("size: ", binary.LittleEndian.Uint32(list.size[:]))
fmt.Println("listType: ", string(list.listType[:]))
fmt.Println("data: ", string(list.data))
fmt.Println()
}
func (data DATA) print() {
fmt.Println("SubChunk2Id: ", string(data.SubChunk2Id[:]))
fmt.Println("SubChunk2Size: ", binary.BigEndian.Uint32(data.SubChunk2Size[:]))
fmt.Println("first 100 samples", data.data[:100])
fmt.Println()
}
A partir de agora você vai conseguir chamar as funções de impressão apenas chamando o método print()
na struct.
Leitura das structs com campos de tamanhos definidos
Com as structs bem definidas, a sua leitura usando o pacote encoding/binary
é feita pela função Read.
func binary.Read(r io.Reader, order binary.ByteOrder, data any)
Essa função Read, espera que você passe para ela um stream de dados(como por exemplo um arquivo), a ordem dos bytes(big, little) e aonde será armazenado os dados.
Se esse lugar onde armazenara o dado for uma struct com tamanhos definidos, ela vai percorrer campo por campo e armazenar a quantidade de bytes lá.
// RIFF Chunk
RIFFChunk := RIFF{}
binary.Read(file, binary.BigEndian, &RIFFChunk)
FMTChunk := FMT{}
binary.Read(file, binary.BigEndian, &FMTChunk)
No caso de byte arrays não definidas, ele leria o resto do arquivo o que não seria o correto.
Leitura das structs com campos não definidos
Uma das maneiras mais simples de se fazer a leitura de campos com tamanho é dinâmico, é ler estes campos depois de descobrir o tamanho deles. Para isso eu criei dentro das structs de LIST e DATA, funções chamadas read()
que lida com essa leitura.
func (list *LIST) read(file *os.File) {
listCondition := make([]byte, 4)
file.Read(listCondition)
file.Seek(-4, 1)
if string(listCondition) != "LIST" {
return
}
binary.Read(file, binary.BigEndian, &list.ChunkID)
binary.Read(file, binary.BigEndian, &list.size)
binary.Read(file, binary.BigEndian, &list.listType)
list.data = make([]byte, binary.LittleEndian.Uint32(list.size[:])-4)
binary.Read(file, binary.BigEndian, &list.data)
}
func (data *DATA) read(file *os.File) {
binary.Read(file, binary.BigEndian, &data.SubChunk2Id)
binary.Read(file, binary.BigEndian, &data.SubChunk2Size)
data.data = make([]byte, binary.LittleEndian.Uint32(data.SubChunk2Size[:]))
binary.Read(file, binary.BigEndian, &data.data)
}
Na função read da LIST, eu checo primeiro os 4 primeiros bytes para ver se ele contem a string "LIST", que é o que identifica o header, se ele existir eu continuo a função, senão eu retorno. Após essa verificação eu leio os 3 primeiros campos separadamente utilizando binary.Read()
e então eu uso o campo de tamanho lido e declaro os campos de tamanho dinâmico com os seus respectivos tamanhos.
Feito tudo isso, você tem um simples programa que consegue ler e interpretar os dados de um arquivo .wav
.