Reading binary files with go. A pratical example using wave files
Tags: go, programming, codenewbie, tutorial
Published: 2023-01-07 17:54:30

**Source-code used here:** [https://github.com/mateusfmcota/reading-wave-go](https://github.com/mateusfmcota/reading-wave-go)
**Portuguese version:** [https://dev.to/mateusfmcota/leitura-de-arquivos-binarios-em-go-um-guia-pratico-em-como-ler-arquivos-wav-kk0](https://dev.to/mateusfmcota/leitura-de-arquivos-binarios-em-go-um-guia-pratico-em-como-ler-arquivos-wav-kk0)


## Introduction

A few weeks ago I was talking to a colleague about programming, and one of the subjects that came up was the reading and parsing of files. While thinking about this I decided to make a simple program for reading and writing binary files in Go.

The format chosen was WAV files (PCM to be exact).

## Understanding the structure of a wav file

PCM WAV is a file that follows Microsoft's RIFF specification for storing multimedia files. The canonical form of the file consists of these 3 sections: 

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

The first structure, in purple, is called the RIFF Header, which has the following 3 fields:

 * ChunkID: This is used to specify the type of the chunk, since it is of type RIFF, the expected value is the string "RIFF".
 * ChunkSize: Total size of the file - 8. Since ChunkId and Chunk size are 4 bytes each, the easiest way to calculate this field is to take the total size of the file and subtract 8 from it.
 * Format: The type of file format, in this case it is the string "WAVE".


The next section, in green, is called fmt. This structure specifies the format and the metadata of the sound file.

* SubChunk1Id: Contains the string "fmt ", which has a space at the end because the id fields are 4 bytes long and since "fmt" is 3, a space was added.
* Subchunk1Size: This is the total size of the following fields, in the case of WAV PCM this value is 16.
* AudioFormat: For values other than 1(PCM), indicates a compression form.
* NumChannels: Number of channels, 1 = mono, 2 = stereo, ...
* SampleRate: Sample rate of sound e.g. 8000, 44100, ... 
* ByteRate: SampleRate * NumChannels * BitsPerSample / 8, is the number of bytes in 1 second of sound
* BlockAlign: NumChannels * BitsPerSample / 8, is the amount of bytes per sample including all channels
* BitsPerSample: Amount of bits per sample, 8 bits, 16 bits, ...

 The third session, in orange, is the data structure where the sound is stored itself, in which it has the following fields:
 
* Subchunk2ID: Contains the string "data".
* Subchunk2Size: NumSamples * NumChannels * BitsPerSample/8, this is also the number of bytes left in the file.
* data: The sound data.

#### LIST Chunk

When I created a sound to test the program, using ffmpeg, I realized that it had an extra header, although this header is not in the canonical specification, I ended up creating a basic structure for it.

This structure is of type LIST, which follows the following specification:

* ChunkId: Contains the string "LIST"
* Size: The size of the LIST structure - 8. Basically it tells you the size in bytes remaining in the LIST structure.
* listType: Various ASCII characters, they depend on the type of the file, some examples are: WAVE, DLS, ...
* data: Depends on listType, but in this case does not apply to this program

### Details of each header:

One detail that I decided not to explain in the last topic is the size and bit order, little-endian and big-endian, of each field for simplicity. So I created this table with all these fields, size and byte-order:

##### RIFF Header:

|Offset| Field  | Size   | Byte-order   |
|:--|:---|:---|:---|
|0|ChunkId  |4   | big  |
|4|ChunkSize   |4   | little  |
|8|Format   | 4  | big  |

##### FMT Header:

|Offset| Field  | Size   | Byte-order   |
|:--|:---|:---|:---|
|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| Field  | Size   | Byte-order   |
|:--|:---|:---|:---|
|\*|chunkID  |4   | big  |
|\*|size     |4   | big  |
|\*|listType |4   | big  |
|\*|data     |Variable   | big  |

\* Because it's platform specific and I will not use this field in the creation, I will ignore their offset calculation.

##### Data Header:

|Offset| Field  | Size   | Byte-order   |
|:--|:---|:---|:---|
|36|SubChunk2ID  |4   | big  |
|40|SubChunk2Size     |4   | big  |
|44|Data |Variable   | big  |

## Creating the program

After this great explanation of how a WAVE file works, now it is time to get down to business and to make the job easier I will use Go's native `encoding/binary` library to help.

#### Creating the structs

The first thing I did in the application was to create 4 structs, one for each header as follows:


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

#### Creating a function to help reading bytes

Although the `encoding/binary` library is very helpful for reading binary files, one problem with it is that it doesn't have a method implemented to read a number N of bytes from a given file.

For this I created a function that just reads the n bytes from an `os.File` and returns these values.

```go
func readNBytes(file *os.File, n int) []byte {
	temp := make([]byte, n)

	_, err := file.Read(temp)
	if err != nil {
		panic(err)
	}

	return temp
}
```

#### Reading e parsing a wave file

Now we are going to read the file and for this we use `os.Open`:

```go
	file, err := os.Open("audio.wav")

	if err != nil {
		panic(err)
	}
```

To parse the file, we first create a variable for each structure and use the `readNBytes` function to read each field:

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

One detail I would like to explain is the line that contains the code: 

```go
if string(subChunk) == "LIST"
```

This line was put in because the LIST header is not a standard header in the canonical specification of a WAVE file, so I check to see if it exists or not, if it does I create the field, otherwise I ignore it.

#### Printing the fields:

Although we didn't use the `encoding/binary` library for reading, it will be very useful for printing, in the table I put above that explains the size and byte order type of each file, it is very useful to indicate which field is little-endian and which is big-endian.

To print the fields on the screen I created these 4 functions, 1 for each header type, that prints the field according to its byte-order:

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

```

Since we are reading a file, which is read from "left to right", we can say that the default byte order is big-endian, so there is no need to convert these values to big-endian.

#### Optimizations

Although we didn't use the `encoding/binary` library for the above example, it is possible to use it to read files in a faster, and more elegant, but not initially intuitive, way.

It has a read method that lets you read the values from an `io.Reader` directly into a struct. Although it sounds simple, `binary.read()` has 2 quirks. 

* `binary.read` requires that the struct be well defined, with the sizes and types of each field already instantiated
* `binary.read` requires you to pass it the byte order (big or little-endian)

With this in mind, we can improve the code this way.

#### Refactoring the structs

One of the first things we need to do is to create the structs with the fields at their predefined sizes when possible. Since some of them require variable size fields, I will leave them blank.

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

As noted above the date fields of the LIST and DATA headers were left empty, so we will deal with this in another way later on.

#### Making the print functions belong to struct and not to the package

The next step will be to couple the print functions to their respective structs, so that it will be easier to call them up in the future:

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

From now on you will be able to call the print functions just by calling the `print()` method in the struct.

#### Reading structs with defined field sizes

With the structs well defined, reading them using the `encoding/binary` package is done by the Read function. 

`func binary.Read(r io.Reader, order binary.ByteOrder, data any)`)

This Read function expects you to pass it a data stream (such as a file), the byte order (big or little), and where to store the data.

If this place to store the data is a struct with defined sizes, it will scroll through the data field by field and store the amount of bytes there.

```go
// RIFF Chunk
	RIFFChunk := RIFF{}
	binary.Read(file, binary.BigEndian, &RIFFChunk)

	FMTChunk := FMT{}
	binary.Read(file, binary.BigEndian, &FMTChunk)

```

In the case of undefined byte arrays, it would read the rest of the file, which is not correct for this application.

#### Reading structs with undefined fields

One of the simplest ways to read dynamic field sizes is to read them after figuring out their size. To do this, I have created functions inside the LIST and DATA structs called `read()` that handle this reading.

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

```

In the read function of LIST, I first check the first 4 bytes to see if it contains the string "LIST", which is what identifies the header, if it exists I continue the function, otherwise I return. After this check I read the first 3 fields separately using `binary.Read()` and then I use the read size field and declare the dynamic size fields with their respective sizes.

Having done all this, you have a simple program that can read and interpret the data from a `.wav` file.

## References:
* [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)