DEV Community

mateusfmcota
mateusfmcota

Posted on

1

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

Um detalhe que gostaria explicar é a a linha que contem o código:

if string(subChunk) == "LIST"
Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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:

Qodo Takeover

Introducing Qodo Gen 1.0: Transform Your Workflow with Agentic AI

While many AI coding tools operate as simple command-response systems, Qodo Gen 1.0 represents the next generation: autonomous, multi-step problem-solving agents that work alongside you.

Read full post →

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay