Leitura de arquivos binarios em Go. Um guia pratico em como ler arquivos wav
Tags: go, programming, tutorial, codenewbie
Published: 2023-01-07 17:54:41


**Código fonte:** [https://github.com/mateusfmcota/reading-wave-go](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](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: 

<img src="http://soundfile.sapp.org/doc/WaveFormat/wav-sound-format.gif" />
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:

```go
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.

```go
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`:

```go
	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:

```go
// 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: 
```go
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 :

```go
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.

```go
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:

```go
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á.

```go
// 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.

```go
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`.

## Referências:

* [WAVE PCM soundfile format - sapp.org](http://soundfile.sapp.org/doc/WaveFormat/)
* [List chunk (of a RIFF file) - recordingblogs.com](https://www.recordingblogs.com/wiki/list-chunk-of-a-wave-file)
* [go encoding/library documentation](https://pkg.go.dev/encoding/binary)