Home

Awesome

easyWAVE

Work in progress, not ready for public use yet!

Overview

easyWAVE is a native Nim library that supports the reading and writing of the most common subset of the WAVE audio file format. Only uncompressed PCM data is supported (which is used 99.99% of the time in the real world). The library does not abstract away the file format; you'll still need to have some understanding of how WAVE files are structured to use it.

The WAVE format is not complicated, but there are lots of little details that are quite easy to get wrong. This library gives you a toolkit to read and write WAVE files in a safe and easy manner—most of the error prone and tedious stuff is handled by the library (e.g. chunk size calculation when writing nested chunks, automatic padding of odd-sized chunks, transparent byte-order swapping in I/O methods etc.)

Features

Limitations

Installation

The best way to install the library is by using nimble:

nimble install easywave

Usage

Reading WAVE files

Reading WAVE files is accomplished through WaveReader objects. A WaveReaderError will be raised if an I/O error was encountered or if the WAVE file is invalid.

Basic usage

Just call parseWaveFile() with the filename of the WAVE file. You can set the readRegions option to true if you're interested in the markers/regions stored in the file as well.

This method will:

A simple example that illustrates all these points:

import strformat, tables
import easywave

var wr = parseWaveFile("example.wav", readRegions = true)

echo fmt"Endianness: {wr.endianness}"
echo fmt"Format:     {wr.format}"
echo fmt"Samplerate: {wr.sampleRate}"
echo fmt"Channels:   {wr.numChannels}"

for ci in wr.chunks:
  echo ci

if wr.regions.len > 0:
  for id, r in wr.regions.pairs:
    echo fmt"id: {id}, {r}"

var numBytes = wr.dataSize
echo fmt"Sample data size: {numBytes} bytes"

# File pointer is now at the start of the sample data

Reading single values or chunks of data from the file is accomplished through the various read* methods. See the API docs for the full list. It's your responsibility to ensure that you read the sample data with the appropriate read method; there's nothing stopping you from reading 16-bit integer data as 64-bit floats, for example, if that's what you really want :stuck_out_tongue_winking_eye:

# Single value read
let v3 = wr.readInt8()
let v1 = wr.readUInt16()
let v2 = wr.readFloat32()

# Buffered read
var buf16: array[4096, int16]
wr.readData(buf16)            # read until the buffer is full

var buf32float = newSeq[float32](1024)
wr.readData(buf32float, 50)   # read only 50 elements

Advanced usage

While the above basic usage pattern would be probably sufficient for most use cases, you can do the reading fully manually by calling the low-level read methods.

The below code is an example for that; it approximates what parseWaveFile() is doing, minus the error checking. Consult the API docs for the list of available functions.

var wr = openWaveFile("example.wav")

var cueChunk, listChunk, dataChunk: ChunkInfo

# Iterate through all chunks
while wr.hasNextChunk():
  var ci = wr.nextChunk()
  case ci.id
  of FOURCC_FORMAT: wr.readFormatChunk(ci)
  of FOURCC_CUE:    cueChunk = ci
  of FOURCC_LIST:   listChunk = ci
  of FOURCC_DATA:   dataChunk = ci
  else: discard

ww.readRegions(cueChunk, listChunk)

# Seek to the start of the sample data
setFilePos(wr.file, dataChunk.filePos + CHUNK_HEADER_SIZE)

Writing WAVE files

Similarly to reading, writing WAVE files is accomplished through WaveWriter objects. A WaveWriterError will be raised if an I/O error was encountered or if you tried to perform an invalid operation (e.g. writing to a closed file, attempting to write data between chunks etc.)

Creating a WAVE file

To create a new WAVE file, a WaveWriter object needs to be instantiated first:

import easywave

var ww = writeWaveFile(
  filename = "example.wav",
  format = wf16BitInteger,
  sampleRate = 44100,
  numChannels = 2
)

Note that this will only create the file and write the master RIFF chunk ("RIFF") header.

Writing the format chunk

You'll need to explicitly call writeFormatChunk() to write the actual format information to the file in the form of a format chunk ("fmt "). This gives you the flexibility to optionally insert some other chunks before the format chunk.

Writing markers and regions

To write markers and regions to the file, you'll need to descibe them as a table of values where the keys are the marker/region IDs (32-bit unsigned integers unique per marker/region) and the values Region objects. Markers are defined simply as regions with a length of zero.

ww.regions = {
  1'u32: Region(startFrame:     0, length:     0, label: "marker1"),
  2'u32: Region(startFrame:  1000, length:     0, label: "marker2"),
  3'u32: Region(startFrame: 30000, length: 10000, label: "region2")
}.toOrderedTable

ww.writeRegions()

Note that the start positions and lengths of the markers/regions need to be specified in sample frames—these are not byte offsets! (1 sample frame = N number of samples, where N is the number of channels)

writeRegions() will technically create two new chunks right next to each other:

The list chunks allows lots of other types of information to be stored in its various sub-chunks. If you need to store such extra data, you cannot use writeRegions(); you'll need to implement your own list chunk writing logic.

Writing the data chunk and other chunks

To write any other other chunks types, you'll need to do the following:

  1. Call startChunk("ABCD"), where "ABCD" is the 4-char chunk ID (FourCC). startDataChunk() is a shortcut for creating the data chunk ("data").

  2. Use the various write* methods to write the data (see the API docs for the full list). Byte-order swapping will be handled automatically depending on the CPU architecture and the endianness of the file. You need to ensure that you use the correct write method variant for the particular sample format you're using.

  3. When you're done, call endChunk() to close the chunk. This will pad the data automatically with an extra byte at the end if an odd number of bytes have been written so far, and it will update the chunk size field in the chunk header.

ww.startChunk("LIST")

# Write single values
ww.writeInt16(-442)
ww.writeUInt32(3)
ww.writeFloat64(1.12300934234)

# Write buffered data
var buf16 = array[4096, int16]
ww.writeData(buf16)           # writeData methods take an openArray argument

var buf64float: seq[float64]
ww.writeData(buf64float, 50)  # write the first 50 elements only

ww.endChunk()

Chunks can be nested; the library will make sure to calculate the correct chunk sizes for all parent chunks.

Bear in my mind that it is invalid to write data "between chunks"—an error will be raised if you tried to write some data after ending a chunk but before starting a new one.

Closing the file

Finally, the endFile() method must be called to update the master RIFF chunk with the correct master chunk size. This will also close the file.

Handling 24-bit data

The library provides two ways to deal with 24-bit data:

It is important to stress out that the data will be always written to the WAVE file in packed form—it's just sometimes more convenient to deal with 32-bit integers than with packed data, hence the two different methods.

Some general notes about WAVE files

License

Copyright © 2018-2014 John Novak <john@johnnovak.net>

This work is free. You can redistribute it and/or modify it under the terms of the Do What The Fuck You Want To Public License, Version 2, as published by Sam Hocevar. See the COPYING file for more details.