DEV Community

mateusfmcota
mateusfmcota

Posted on

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:

Top comments (0)