Executando verificação de segurança...
5

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:
OffsetCampoTamanhoOrdem dos bits
0ChunkId4big
4ChunkSize4little
8Format4big
FMT Header:
OffsetCampoTamanhoOrdem dos bits
12Subchunk1ID4big
16Subchunk1Size4little
20AudioFormat2little
22NumChannels2little
24SampleRate4little
28ByteRate4little
32BlockAlign2little
34BitsPerSample2little
LIST Header:
OffsetCampoTamanhoOrdem dos bits
*chunkID4big
*size4big
*listType4big
*dataVariávelbig

* Como é especifico de cada plataforma e na criação não vou utilizar esse campo, vou ignorar o calculo de offset deles.

Data Header:
OffsetCampoTamanhoOrdem dos bits
36SubChunk2ID4big
40SubChunk2Size4big
44DataVariávelbig

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.

Referências:

2

Muito legal! Uma outra forma de imprimir os resultados é fazendo com que as structs satisfaçam a interface fmt.Stringer, dessa forma você pode utilizar fmt.Println para imprimir:

func (r RIFF) String() string {
	var buf bytes.Buffer
	fmt.Fprintln(&buf, "ChunkId: ", string(r.ChunkID[:]))
	fmt.Fprintln(&buf, "ChunkSize: ", binary.LittleEndian.Uint32(r.ChunkSize[:])+8)
	fmt.Fprintln(&buf, "ChunkFormat: ", string(r.ChunkFormat[:]))
	return buf.String()
}

func main() {
	var r RIFF
	fmt.Println(r)
}
2
1

To comentando muito pra poder dar "up" no post e pra dizer que me relembrou de um desejo antigo meu de parsear e entender melhor arquivos .mp3, e inclusive em Golang, que estou utilizando agora.

Parabéns pela postagem, ótimo ter referências!!

E me deu bons insights e motivação pra trabalhar nesse meu desejo. Quem sabe vira um post aqui no TabNews também!

1

Parabéns pelo artigo! Conteúdo muito bom.

Um questionamento sobre o código: por que você decidiu criar um estrutura para cada parte do wave? Da forma que fez, para representar o wave tenho que instânciar 4 estruturas diferentes.

Não seria mais legível ter apenas um estrutura que representa o wave completo? Dentro desta estrutura poderia ter todos os campos, inclusive outras estruturas, se necessário.

1

Obrigado. Até que faz sentido botar tudo dentro de uma estrutura, eu criei separado por causa que fica mais fácil de explicar aí acabou que depois na parte de otimizar eu nem pensei em colocar tudo junto.