Skip to main content

How to use Samplers and Fixed-Frequency DACs

Audio waveforms and the imp

All types of imp are well known for their powerful, flexible digital I/O, but it doesn’t stop there. Many imp types also include analog inputs and outputs, and impOS™ includes everything you need to store and transmit analog waveforms. This means your Internet-connected device can play and record audio.

The imp API includes two classes for handling fixed-frequency analog signals: the Sampler and the Fixed-Frequency DAC (FFD). Both of these systems work in essentially the same way: the hardware is configured to run in the background, so that Squirrel execution can continue. A ‘bucket chain’ model is used to move buffers of analog data along the line and out of the sampler, or into the FFD.

Note The imp004m module’s handling of the functionality covered in this article is different from other imps. If you are working with the imp004m, please see ‘Working With Audio On The imp004m’.

Because these mechanisms are essentially symmetrical, let’s take a closer look at the sampler first.

Buffers and Callbacks

To use the sampler, your application must configure it with one or more empty buffers. These are simply blobs. The size and number of these buffers can vary: your application can set the buffer size to whatever you like, and you can use between one and eight of them.

In code, you set up the buffers, add them to an array and then pass the array as one of the parameters of sampler.configure(). This method also takes the ADC-enabled pin you’ll be using to feed the signal into the imp, your sample rate (as a float or an integer), the name of the function that will be called when a buffer is filled, and an optional constant to specify how the analog-to-digital conversion will operate.

Don't forget that not all imp pins are enabled for ADC (Analog-to-Digital Converter) use. All pins on an imp001 are so enabled, but of the extra pins provided by the imp002 module only two, A and B, support ADC. The imp003 module has ten ADC-enabled pins: A-C, E, F, H-K, N and W; the imp004m has seven: A-D, K, L and W. The imp005 does not provide an ADC.

Compatible imps instantiate a single sampler object at start-up, as hardware.sampler, and it’s this that is configured using the above API call. You use it like this:

function bufferFull(buffer) {
    // Code to process buffer...
}

local buffers = [...];
hardware.sampler.configure(hardware.pin1, 48000, buffers, bufferFull);

To begin sampling, call hardware.sampler.start(). Now the sampler begins to fill the first buffer with samples. The rate at which the buffer is filled depends on both the sample rate and the compression scheme used — set using the constant mentioned above. By default, the sampler produces 16-bit, little-endian, unsigned linear PCM samples, with every sample taking up two bytes. If the optional A-law compression is used, however, the sampler will produce one-byte samples.

To see how fast the buffers will fill, we can easily compute the throughput of the sampler:

Throughput (bytes per second) = sample size (bytes) × sample rate (Hz)

Therefore:

Time to fill buffer (seconds) = buffer size (bytes) / throughput (bytes per second)

For example, when sampling at 16kHz, without A-law compression, an 8192-byte buffer will fill in 256ms:

8192 bytes / (2 bytes per sample × 16,000 samples per second) = 0.256 seconds


Starting the sampler: the sampler begins to fill the first buffer with data

Every time a buffer is filled, the sampler will pass the full buffer to the nominated callback function and automatically begin using the next one. If you are not familiar with how callbacks work in Squirrel, or the event-driven architecture of imp applications, it will be helpful to take a look at our introduction to event-driven programming. The callback must include two parameters: the blob holding the buffer, and an integer holding the number of valid bytes in the blob.


Buffer full: the Buffer Full Callback is called to handle the recorded data

While a buffer is being handled by your callback function, the sampler can’t use that buffer to store incoming data. So if you need to record more than one buffer length of continuous signal, you must use multiple buffers. Using more buffers provides a longer time for the callback to handle a buffer when it is filled, and using larger buffers means that the callback will be called less frequently, as the buffers will each take longer to fill. However, using more or larger buffers will use more of the device’s available memory.


The callback has finished handling the data; the buffer can now be re-used

The sampler automatically cycles through the buffers provided at configuration. When the Buffer Full Callback finishes handling the full buffer, the buffer automatically goes to the back of the queue of buffers into which the sampler records new data.

Buffer Over-runs

If the sampler attempts to collect new samples and discovers it has nowhere to put them, a ‘buffer over-run’ has occurred. This can happen when the sampler outruns the Buffer Full Callback; it needs buffer space before the callback has finished processing the data stored there.


A Buffer Over-run: the sampler has no empty buffers to fill

When this happens, any samples recorded between the beginning of the over-run condition and the time when one of the buffers becomes available again will be lost. The sampler signals to your code that it is in an over-run condition by calling the callback with a null buffer. This will only happen once per over-run condition.

Stopping the Sampler

When your application is ready to stop recording analog data, it calls hardware.sampler.stop() to halt the sampler. The sampler will stop recording new data immediately, but some data will still be waiting in line to be processed by the Buffer Full Callback. The sampler will pass its current — and likely only partially filled — buffer to the Buffer Full Callback; any remaining buffers will be handled in the order they were received.


Sampler Stopped: the last, partially-filled buffer is waiting to be processed by the callback

The Fixed-frequency DAC

The fixed-frequency DAC works much the same as the sampler. This time, you call hardware.fixedfrequencydac.configure(). Its configuration uses the same parameters and options: an array of buffers is used to pass data into the FFD, and a callback is executed when each buffer is emptied.

Because the FFD works by emptying full buffers, rather than filling empty buffers, the first step is to pre-load the buffers before starting the FFD.


Providing a queue of full buffers to the Fixed-Frequency DAC before starting playback

When hardware.fixedfrequencydac.start() is called, the FFD will immediately begin working on the queued buffers:


The FFD begins to process the queue, buffer by buffer

When the FFD empties a buffer, the buffer will be sent to the Buffer Empty Callback:


An empty Buffer is sent to the Buffer Empty Callback

The Buffer Empty Callback can then re-fill the empty buffer and load it back into the FFD. While the sampler automatically cycles through the buffers it is provided at configuration, the FFD removes empty buffers from its queue when they’re used up.


The Buffer Empty Callback reloads a buffer with new data

New (full) buffers are pushed into the queue with hardware.fixedfrequencydac.addbuffer():


The Buffer Empty Callback queues up a refilled buffer

Buffer Under-runs

Just as the sampler will over-run if it has nowhere to write new data while running, the FFD will under-run if it runs out of buffers.


Buffer under-run: there are no full buffers to send to the DAC

The FFD signals an under-run by calling the Buffer Empty Callback with a null buffer. The FFD will output nothing until more data is provided, at which point playback will resume immediately.

Network Throughput and Memory

Since data passes in and out of the sampler or FFD in chunks, and because the callbacks processing these buffers have limited time to prevent an over-run or under-run, some careful planning is required to ensure the process runs smoothly. Earlier, we calculated the throughput of the sampler given the sample rate and number of bytes per sample. The calculation is the same for the FFD. The throughput of a full system is limited by whatever part of the system has the lowest throughput.

Let’s consider a case which uses the Fixed Frequency DAC. In this example, every time a buffer is emptied by the FFD, the device will ask the agent for a new buffer full of data. The agent must be able to move data into the device at least as fast as the FFD uses the data up, or the agent will fall behind and an under-run will occur. Conversely, the data can’t be delivered all at once if the total size of the data exceeds the device’s available memory. In this case, the network throughput is the limiting factor on the system.

If the device generates uncompressed samples (two bytes per sample) at a rate of 8kHz (8,000 samples per second), the network throughput will need to be:

8,000 samples/second × 2 bytes/sample = 16,000 bytes/second

While network throughput will vary greatly depending on many factors, 16kB/second is very high throughput; this example is likely to cause buffer over-runs. In practice, 8kB/second is a more typical maximum expected throughput. To avoid this problem, options include:

  • Use compression — this reduces the size of each sample from two bytes to one, halving the required throughput.
  • Reduce the sample rate — required throughput is proportional to sample rate.
  • Store data locally on the device — by taking the network transaction to the agent out of the system, this bottleneck no longer limits the throughput of the sampler while it is running.

For applications that require high throughput — such as high sample rates or guaranteed performance across many networks — local storage on the device is recommended. By storing data in a device-side SPI flash or SRAM, throughput can be increased to whatever rate the storage medium will allow.

Let’s consider the sampler case again, but this time with a SPI Flash on the device. The device will write the buffers to the flash as the sampler records them. The device can run the SPI at up to 15MHz. While there is some overhead associated with writing the data to flash over SPI, let’s simplify the example by assuming this overhead is zero. In this case, the maximum throughput is:

15,000,000 bits/second / 8 bits/byte = 1,875,000 bytes/second

With uncompressed samples, this would allow sample rates up to:

1,875,000 bytes/second / 2 bytes/sample = 937,500Hz (937.5kHz)

This sample rate is more than enough for 192kHz rate used by the Blu-ray based High Fidelity Pure Audio format, for instance, even if the resolution were increased to four bytes per sample (24-bit).

This architecture is very effective in dealing with the throughput limitation, and can also allow recording or playback while the device is offline, opening the door to applications where the device leaves its home network or has limited battery life.