Skip to content

Minimalist library for audio synthesis and processing on the ATmega328P and ATmega2560.

Notifications You must be signed in to change notification settings

JeffGregorio/LibAG

Repository files navigation

LibAG

C++ library for audio synthesis on AVR-based Arduino microcontrollers, developed and tested on the ATmega328P using Arduino Uno and Nano, and the ATmega2560 using the Arduino Mega.

This library should be an accessible entry point for Arduino hobbyists and engineering students wanting to explore real-time audio synthesis at a lower level of abstraction than similar libraries. It's meant to be more efficient, readable, and workable than general and feature-laden.

0 Installation

To use with the Arduino IDE, download this repository as a ZIP and place in your Arduino/libraries directory, likely ~/Documents/Arduino/libraries/ on Mac OS and \My Documents\Arduino\libraries\ on Windows.

1 Overview

LibAG offers a small set of peripheral drivers and example sketches that demonstrate configuration of timers, ADC, interrupt service routines, and SPI for use with external DACs.

It also includes a small set of digital signal processing (DSP) objects and utility functions that demonstrate code appropriate for 8-bit AVR limitations. This includes fixed point math and table-based approaches to sinusoidal synthesis, exponential parameter control, and filter coefficients.

Memory use is minimized by storing pre-computed, normalized tables in flash memory and rescaling outputs. Several exponential tables are provided alongside a python script for generating others.

2 Introduction

This section contains a brief motivation for the library's features. Readers for whom sampling, aliasing, reconstruction, timers, ADCs, and interrupts are familiar concepts might skip directly to examples in Section 3.

The Importance of Sample Rate

In discrete time, the Nyquist-Shannon sampling theorem shows that it is impossible to generate or reproduce frequencies higher than half the sample rate (often called the Nyquist rate). In fact, the stepped waveform produced by a digital to analog converter (DAC) will contain frequencies above this rate, but they are merely frequency-shifted copies of the baseband signal, or aliases.

The job of the reconstruction filter that follows a DAC is to significantly attenuate everything above the Nyquist rate. A high order analog low-pass filter can give good attenuation at Nyquist and a sharp transition band, i.e. a nice, wide passband that applies unity gain to frequencies approaching Nyquist before it starts a sharp decline. This of course entails lots of op amps, cost, and board space plus added sensitivity to component tolerances.

Higher sample rates lessen the requirements on reconstruction filters. They also improve the quality of harmonically-rich waveforms or harmonic distortion, where we could easily find ourselves naively trying to synthesize upper partials that want to be above Nyquist, only to find themselves folded back into the audio band as aliases--just as a consequence of discrete time itself, i.e. where even the steepest reconstruction filter won't save us.

The point is that higher sample rates are usually better.

Although standard sample rates like 44.1kHz and 48kHz permit only the most minimal computation on these AVRs, lower rates are emanently usable in modular synth applications like low frequency oscillators, envelope generators and followers, MIDI to CV converters, sequencers, etc., making these processors more than appropriate.

Limitations of Arduino-style I/O

To get the most out of the processor, LibAG replaces Arduino staples like delay(), millis(), analogWrite(), analogRead(). Their admirable generality is no free lunch, and the peripherals they use (timers and converters) can be much better configured for our task. On an Uno, analogRead() alone takes a whopping 110μs to convert a single sample, meaning we could forget about sample rates above 8-9kHz even with the most minimal processing.

What we need is a fuction that runs at a regular sample rate, and a way of converting from digital to analog and vice versa that takes up as little of the sample period as possible--leaving the rest for DSP code. LibAG's configuration of the ADC, for example, reduces the overhead for conversions to under 2μs by offloading most of the work to the ADC itself.

Timers are the workhorses of delay(), millis(), analogWrite(), and they are not terribly complicated. They have an 8- or 16-bit count value that increments on the edge of a clock. When the count gets to 255 or 65535, respectively, it overflows (resets to zero) and continues counting and overflowing periodically. Timers also have another 8- or 16-bit value we can compare with the count.

We can make one of a few things happen when the values match. In Pulse Width Modulation (PWM) mode, a compare match toggles a pin. In Clear Timer on Compare Match (CTC) mode, a compare match resets the count value and calls an Interrupt Service Routine (ISR)--a kind of function that gets called by a peripheral.

We can use timers as DACs in PWM mode, where we encode our signal on the width of the pulse, and use a low-pass reconstruction filter to take kind of "average" over the period of the pulse to recover the signal. The catch is that for audio, we have to configure much higher PWM rates than the 960Hz used by analogWrite()--you can likely guess why trying to encode, say, the amplitude of a 2000Hz sine wave on the width of a 960Hz pulse is a fool's errand.

Our timing needs (churning out samples at a regular rate) can be handled with essentially no overhead by timers in CTC mode, by external clock signals, or even by the ADC alone. To do this, we'll do our processing in ISRs instead of loop().

3 Peripheral Drivers: Basic Usage

3.1 Timers in PWM and CTC Modes

In this example, Timer 0 gives us a 16kHz sample rate to render sawtooth wave. Timer 2 provides a PWM output at 62.5kHz.

#include <Timer.h>

Timer0 timer0;    // Sample rate trigger
Timer2 timer2;    // PWM output

volatile uint16_t phase = 0;    // 16-bit phase in [0, 0xFFFF]

void setup() {
	timer2.set_prescaler(1);
	timer2.init_pwm();          // 8-bit PWM rate 16e6/1/256 = 62.5kHz
	timer0.set_prescaler(8);	
	timer0.init_ctc(124);       // Call ISR at 16e6/8/125 = 16kHz
}

...

ISR(TIMER0_COMPA_vect) {            // Called at 16kHz (every 62.5us)
	phase++;                        // 16-bit sawtooth, ~0.244 Hz
	timer2.pwm_write_a(phase >> 8); // Write 8-bit signal to pin OCR2A
}

Notice that sample rates and PWM rates are determined in part by prescalers, which are values used to divide the system clock (16MHz on the Arduino Uno, Nano, Mega) that drives the timer. In the above example, Timer 0 ticks forward at one eighth the rate of Timer 2.

Prescaler options for Timers 0 and 1 include {1, 8, 64, 256, 1024}. Timer 2 has additional prescaler options including {1, 8, 32 64, 128, 256, 1024}.

We're using 8-bit PWM so Timer 2's period (the PWM rate) is the time it takes the timer to count from 0 to 255 and overflow back to 0, or 256 ticks of the prescaled system clock. To output a PWM signal to pin OCR2A, we supply an 8-bit value to the timer's pwm_write_a() method. The timer compares this value to its count and toggles the OCR2A pin when they match.

Timer 0 controls sample timing using CTC mode and a larger prescaler. Recall that CTC mode calls a timer channel's interrupt service routine on compare match and resets the timer count, meaning the rate is a function of the compare value rather than the timer's full resolution.

Side note: we could have divided the 16-bit phase by 256 to obtain an 8-bit output value, but division is generally slow and to be avoided if possible. Divison by a power of two is much, much faster via bit shifting.

3.2 ADC Free Running Mode

In this example, we use the ADC's free running mode to automatically scan two channels at about 19kHz, multiply their values, and output the result. The effective input sample rate for each channel is the output sample rate (19kHz) divided by the number of ADC channels scanned.

#include <ADCAuto.h>
#include <Timer.h>

Timer2 timer2;              // PWM output
ADCFreeRunning adc(2);      // Analog inputs (A0, A1) and sample rate trigger
volatile uint16_t a0, a1;   // ADC samples

void setup() {
	timer2.set_prescaler(1);    // 8-bit PWM rate 16e6/1/256 = 62.5kHz
	timer2.init_pwm();
	adc.set_prescaler(64);      // Convert and retrigger at 16e6/64/13 = 19.2kHz
	adc.init();
}

...
	
ISR(ADC_vect) {                 // Called after ADC conversion completes
	adc.update();               // Stores the result; selects next channel
	a0 = adc.results[0] >> 2;   // Convert ch 0 result to 8-bit
	a1 = adc.results[1] >> 2;   // Convert ch 1 result to 8-bit
	a0 = (a0 * a1) >> 8;        // 8-bit UQ multiply (see sec 4.1)
	timer2.pwm_write_a(a0);     // Write to pin OCR2A
}

Notice the ADC has a prescaler as well, which determines the free running rate alongside the 13 clock cycles it takes the ADC to complete a conversion. Per the processor datasheet's recommendation, the ADC clock (i.e. the system clock divided by the ADC's prescaler) should be under 200kHz to guarantee full 10-bit resolution.

With prescaler options {2, 4, 8, 16, 32, 64, 128}, this means only 128 provides full resolution. As in this example, ADC clocks up to 1MHz can be used if we don't mind trading effective resolution for speed (note we still retrieve results at 10-bit values).

Notice our only free parameter for determining ADC free running rate is the ADC prescaler. With a 16MHz system clock, the usable options 128, 64, and 32 give approximate sample rates 9.6kHz, 19.2kHz, and 38.5kHz.

3.3 ADC Triggered by Timer 0

A more flexible solution for triggering conversions is to use Timer 0's CTC mode. Here, we modify the previous example for use with sample rates up to the ADC's free running rate. Note Timer 0's ISR can be empty, but must be included.

#include <ADCAuto.h>
#include <Timer.h>

Timer0 timer0;              // ADC trigger source 
Timer2 timer2;              // PWM output
ADCTimer0 adc(2);           // Analog inputs (A0, A1)
volatile uint16_t a0, a1;   // ADC samples

void setup() {
	timer2.set_prescaler(1);    // 8-bit PWM rate 16e6/1/256 = 62.5kHz
	timer2.init_pwm();
	adc.set_prescaler(64);      // Maximum conversion rate 16e6/64/13 = 19.2kHz
	adc.init();
	timer0.set_prescaler(8);	
	timer0.init_ctc(124);       // Call ISR at 16e6/8/125 = 16kHz
}

ISR(TIMER0_COMPA_vect) {.       // Called on channel A compare match
	;           // Implicitly triggers ADC conversion; nothing to do
}

ISR(ADC_vect) {.    // Called after ADC conversion completes
	adc.update();   // Stores the result; selects next channel
	a0 = adc.results[0] >> 2;   // Convert ch 0 result to 8-bit
	a1 = adc.results[1] >> 2;   // Convert ch 1 result to 8-bit
	a0 = (a0 * a1) >> 8;        // 8-bit multiply
	timer2.pwm_write_a(a0);     // Write to pin OCR2A
}

3.4 ADC Triggered by External Signal

This simple sample and hold CV processor triggers ADC conversions on the rising edge of an external digital signal on the INT0 pin. Like the previous example, the trigger source's ISR can be empty, but must be included.

#include <ADCAuto.h>
#include <Timer.h>

ADCInt0 adc(1);     // Analog input A0 and sample rate trigger
Timer2 timer2;      // PWM output

volatile uint16_t sample;       // CV sample

void setup() {
	timer2.set_prescaler(1);    // 8-bit PWM rate 16e6/1/256 = 62.5kHz
	timer2.init_pwm();
	adc.set_prescaler(64);      // Maximum conversion rate 16e6/64/13 = 19.2kHz
	adc.init();
}

ISR(INT0_vect) {   // Called on INT0 rising edge; triggers ADC conversion
	;  // Nothing to do
}

ISR(ADC_vect) {     // Called after ADC conversion completes
	adc.update();   // Stores the result; selects next channel
	sample = adc.results[0] >> 2;   // Scale 10- to 8-bit
	timer2.pwm_write_a(sample);     // Write to pin OCR2A
}

Here, we should not expect our external signal to successfully trigger conversions faster than the ADC's free running rate of 19.2kHz.

3.5 Up to 16-bit PWM with Timer 1

Timer 1 has the same prescaler options as Timer 0 {1, 8, 64, 256, 1024}, but has 16-bit count and compare values where Timers 0 and 2 have 8 bits for each. LibAG's Timer1 class uses a PWM mode that allows a variable resolution.

Here, we modify the previous example for 10-bit output as well as input.

#include <ADCAuto.h>
#include <Timer.h>

ADCInt0 adc(1);     // Analog input A0 and sample rate trigger
Timer1 timer1;      // PWM output

void setup() {
	timer1.set_prescaler(1);	
	timer1.init_pwm(1023);      // 10-bit PWM rate 16e6/1/1024 = 15.625kHz
	adc.set_prescaler(64);      // Maximum conversion rate 16e6/64/13 = 19.2kHz
	adc.init();
}

ISR(INT0_vect) {     // Clalled on INT0 rising edge; triggers ADC conversion
	;    // Nothing to do
}

ISR(ADC_vect) {      // Called after ADC conversion completes
	adc.update();    // Stores the result; selects next channel
	timer1.pwm_write_a(adc.results[0]); // Write to pin OCR1A
}

Note that raising the timer's resolution lowers the PWM rate. Although we can still expect the ADC to convert as fast as 19.2kHz, the lower PWM rate constrains the output sample rate, meaning frequencies above half the PWM rate will alias.

3.6 External Digital to Analog Converter (DAC)

The rate/resolution trade-off can be circumvented (at least at audio rates) by using an external DAC like the MCP4921/4922, which are one- and two-channel 12-bit DACs that use a simple, but relatively high-speed, Serial Peripheral Interface (SPI) protocol. From the DAC's datasheet, we learn that the DAC takes the following 16-bit control word:

15 14 13 12 11 10 ... 1 0
A'/B BUF GA' SHDN' D11 D10 ... D1 D0

From most significant to least significant bits, we have the channel selection (0 = A, 1 = B), voltage reference buffer enable, 2x gain setting (active low), and shutdown (active low), followed by the 12 bits comprising an audio sample.

The AVR processors have an SPI peripheral which, at its maximum SPI clock rate of 4MHz can shift the control word's sixteen bits out at rates up to 4MHz/16 = 250kHz, ensuring 12-bit resolution at any sample rate we could achieve on an AVR.

The peripheral generates the 4MHz SPI clock and data out signals on the SCK and MOSI pins, respectively. Since we're not receiving any data from the DAC, we don't need to use the MISO pin. LibAG's SPIMaster class configures the SPI and shifts out 8- or 16-bit control words.

The user must configure a CS (chip select) pin, which tells the DAC when to read data on MOSI line. CS is normally active low, so clear it before transmission, and set it after. Note that you can have multiple DACs sharing connections to SCK and MOSI as long as you have a separate CS pin for each device.

Revisiting the first example, we could now synthesize a sawtooth at 12-bit amplitude resolution without PWM lowering the effective Nyquist rate.

#include <Timer.h>
#include <SPIMaster.h>

Timer0 timer0;      // Sample rate trigger
SPIMaster spi;      // SPI to DAC

volatile uint16_t phase = 0;    // 16-bit phase in [0, 0xFFFF]

void setup() {
	spi.init();                 // Start SPI with 4MHz clock
	DDRB |= (1 << PB0);         // Configure pin PB0 as output (use as CS pin)
	timer0.set_prescaler(8);	
	timer0.init_ctc(124);       // Call ISR at 16e6/8/125 = 16kHz
}

...

// MCP4922's control word:
// A'/B BUF GA' SHDN' D11 D10 D9 D8 D7 D6 D5 D4 D3 D2 D1 D0
ISR(TIMER0_COMPA_vect) {		
	phase++;                    // Sawtooth, ~0.244 Hz
    PORTB &= ~(1 << PB0);       // Clear PB0 (select DAC chip by outputing LOW)
	spi.write_u16(0b0111000000000000 | (phase >> 4));		
	PORTB |= (1 << PB0);        // Set PB0 (deselect DAC chip by outputing HIGH)
}

Note we replace even Arduino's digitalWrite() with slightly faster, low-level alternatives to clearing and setting pins on (in this case) PORTB's pin 0. That's after configuring the pin as an output by setting the corresponding bit in the port's data direction register DDRB.

4 Fixed Point Formats

Due to the AVR processors' lack of dedicated hardware for floating point math, we use fixed point math whenever efficiency is critical, as in our sampling ISRs or any processing or parameter setting operation that's called from an ISR. A 32-bit floating point addition, for example, costs about 7μs on an ATmega328P compared to a 32-bit integer addition's 2μs.

Fixed point uses integer types, only we treat them as representing non-integer values over fixed intervals, and the processor's integer arithmetic logic unit (ALU) is none the wiser. We just have to take a few extra precautions concerning precision and integer overflow.

Here I'll use the 8-bit fixed point types to illustrate, but FixedPoint.h contains analogous functions for 16- and 32-bit fixed point types.

4.1 Unsigned (UQ8, UQ16, UQ32)

An unsigned 8-bit integer normally represents [0, 255] as

MSbit LSbit
27 26 25 24 23 22 21 20

But we can think of it as [0, 1), i.e.

MSbit LSbit
2-1 2-2 2-3 2-4 2-5 2-6 2-7 2-8

and nothing changes from the processor's perspective. This is known as UQ8 format. We can also think of UQ8 as its integer value multiplied by an implicit scaling factor of 2-8, making the maximum value 255/256 = 0.9961.

We can add or subtract UQ8 values normally as long as the result isn't greater than 255 or less than 0, in which case we should use the saturating arithmetic functions defined in LibAG's FixedPoint.h. E.g. if a UQ8 addition might excede 255, use addsat8(), and if a UQ8 subtraction might be less than zero, use subsat8().

Multiplication of UQ8 values must be handled by casting one operand to a 16-bit type capable of holding the product of unsigned 8-bit integers, then multiplying by the UQ8 scaling factor 2-8 as follows

uint8_t a = /* value in [0, 255] */;
uint8_t b = /* value in [0, 255] */;
uint8_t c = (uint16_t)a * b >> 8;

Note that we could have divided by 256 to scale the product, but division via right bit shifting is much faster. The LibAG function qmul8(), defined in FixedPoint.h does this operation with an extra addition for rounding.

4.2 Signed (Q8, Q16, Q32)

Likewise a signed 8-bit integer normally represents [-128, 127] in two's complement as

MSbit LSbit
-27 26 25 24 23 22 21 20

But we can think of it as [-1, 1), i.e.

MSbit LSbit
-20 2-1 2-2 2-3 2-4 2-5 2-6 2-7

And again, nothing changes from the processor's perspective. This is known as Q8 format. We can also think of Q8 as its integer value multiplied by an implicit scaling factor of 2-7, making the maximum value 127/128 = 0.9922.

See FixedPoint.h for additional saturating arithmetic and multiply functions for signed types.

4.3 Other formats

Parts of LibAG treat uint8_t, uint16_t, and uint32_t, respectively as UQ8, UQ16, and UQ32 types, and likewise int8_t, int16_t, and int32_t as Q8, Q16, and Q32. Each of these types uses all of their bits for fractional precision.

In general, fixed point values can use M bits for their integer part, and N bits for their fractional part. As such, UQM.N and QM.N formats are not implemented by LibAG.

5 DSP Classes

LibAG includes classes for basic oscillators like sawtooths and wavetables (Phasor16 and Wavetable16 defined in Oscillator.h), linear envelope generators (ASR16 in Envelope.h), and one pole IIR filters (OnePole16 and OnePole16_LF in IIR.h).

These classes use normalized frequencies specified as integer values rather than Hz. The following sections are essential to using them effectively.

5.1 Frequency Normalization

Recall that the sample rate fs limits the frequencies we can capture and synthesize in discrete time, i.e. we are limited to frequencies below fs/2. Note in some cases we can use the negative frequency band down to -fs/2.

If you're coming from an engineering background, you might be accustomed to seeing this interval normalized to (-π, π), a natural choice given the domain of periodic functions like sin, cos, and the complex exponential. The choice is arbitrary, however. For example, if we had hardware floating point we might use (-0.5, 0.5) as a convenience and scale these normalized frequencies to the former range by multiplying with 2π if needed.

Perhaps it shouldn't come as a surprise that when all we have is integer arithmetic, we can normalize to an integer range, or equivalently, a fixed point range. While UQ8 or Q8 fixed point give poor frequency resolution, UQ16 and Q16 are satisfactory for most purposes, with an effective resolution (in Hz) of fs/216.

Frequencies in Hz are normalized to Q16 as such

float f_s = /* system sample rate in Hz */
int16_t freq = 20.0 / f_s * 0xFFFF;

And here we revisit the sawtooth example with a frequency specified in Hz.

#include <Timer.h>

const float f_s = 16e3;     // Sample rate

Timer0 timer0;      // Sample rate trigger
Timer2 timer2;      // PWM output

volatile uint16_t phase = 0;                  // 16-bit phase in [0, 0xFFFF]
volatile int16_t freq = 20.0f / f_s * 0xFFFF; // 16-bit freq in [-0x8000, 0x7FFF]

void setup() {
	timer2.set_prescaler(1);
	timer2.init_pwm();          // 8-bit PWM rate 16e6/1/256 = 62.5kHz
	timer0.set_prescaler(8);	
	timer0.init_ctc(124);       // Call ISR at 16e6/8/125 = 16kHz
}

...

ISR(TIMER0_COMPA_vect) {		
	phase += freq;                  // Sawtooth, ~20 Hz
	timer2.pwm_write_a(phase >> 8); // Write 8-bit signal to pin OCR2A
}

The classes defined in Oscillator.h use Q16 frquency, as do the attack and release rates of the envelope generator defined in Envelope.h. In many cases, a Q16 frequency can be used as a filter coefficient for the filters in IIR.h. See Section 6.3 for a full explanation.

Since floating point operations like this normalization step will be slow, it is advantageous to pre-compute values where possible.

5.2 Frequency parameter curves

Prior to normalizing the frequency, we typically want to specify an exponential mapping from an integer value, say a MIDI note number or an ADC conversion, to frequency. For example, MIDI note to frequency conversion accomplishes doubling and halving of a (typically) 440Hz reference frequency for every increase or decrease of 12 semitones, centered so that note 69 corresponds to the reference.

uint8_t note = /* MIDI note number in [0, 127] */
float freq = 440.0f * powf(2.0f, (note-69)/12.0f);

Alternatively, we can compute the base of an exponential sweep for specified nonzero endpoints to map a 10-bit ADC conversion result.

float base = powf((2000.0f/20.0f), 1.0f/1023);
float freq = 20.0f * powf(base, adc.results[0]);

Both involve expensive floating point operations and function calls. More efficient fixed point approximations are possible, but with integer control values, it can be useful to pre-compute an entire normalized frequency range in a table, say, of length 128 (for MIDI note lookup), or 1024 (or 10-bit ADC conversion lookup). LibAG takes this approach to exponetial frequency control. See Section 6.2 for a full explanation.

6 Table Generation

Though tables can be computed at startup and stored in SRAM, space is very limited (2kB on the Atmega328 and 8kB on the Atmega2560). Rather, pre-computed tables can be stored in flash memory (up to 32kB on the Atmega328 and 256kB on the Atmega2560) and read using macros defined in the standard avr-gcc library <avr/pgmspace.h>. LibAG classes Wavetable16 and PgmTable16 take pointers to these table addresses and handle lookup and output scaling.

The library provides a python script tablegen.py which can be used to generate C header files containing tables declared in flash memory. The script has no dependencies for generating tables and writing files, but if the user has Matplotlib installed, the script will generate plots of the resulting table functions.

6.1 Sine

Sine wave tables can be generated at the command line using

> python tablegen.py sine

Which generates an unsigned 16-bit integer wave table of length 1024. Unsigned 16-bit types are required for use with the library's wavetable oscillator class Wavetable16.

Other data types and lengths can be generated using optional arguments as follows

> python tablegen.py --dtype <type> --length <length> sine

Where type is one of {u8, s8, u16, s16, u32, s32} indicating signed/unsigned integer types of the specified size.

The resulting tables are written to files with unique names given their parameters. For example, an unsigned 16-bit sine wave table of length 1024 will be written to tables/sine_u16x1024.h.

The user must include the table and pass its address to the wavetable oscillator alongside an integer number used to scale the 16-bit phase oscillator to the table length via right shift before lookup. For example, the length-1024 sine wave table can be used with Wavetable16 as follows

...
#include <Oscillator.h>
#include <tables/sine_u16x1024.h>

...

// Unsigned 16-bit integer sine table of length 1024
Wavetable16 lfo(sine_u16x1024, 6);	
// 16-bit phase is right-shifted 6 bits to use as a 10-bit lookup index

...

ISR(/* interrupt vector */) {
	lfo.freq = /* frequency in [-0x8000, 7FFFF] */;			
	sine_val = lfo.render();     // Render a sine wave sample
}

Note that due to the division via right shift, table lengths must be a power of two for use with Wavetable16.

6.2 Exponential

Exponential tables of length N and index n in [0, N-1] can be generated over a nonzero range (e0, e1) using

where

Rather than generate separate tables over specific Q16 frequency ranges, it is useful to normalize the table such that the maximum value is 0xFFFF, and dynamically re-scale the output using a UQ16 multiply. This places the maximum value at the UQ16 scaling factor, and the minimum value at scale/ratio, where the ratio is e1/e0.

ExpTable

Therefore tablegen generates exponential table`s with specified ratios

> python tablegen.py exp <ratio>

Other data types and lengths can be generated using optional arguments as follows

> python tablegen.py --dtype <type> --length <length> exp <ratio>

Where type is one of {u8, u16, u32} indicating the size of an unsigned integer type.

The table can be used with a PgmTable16 instance's lookup_scale() method. The scaling factor can be computed at startup and/or changed dynamically.

For example, a 10-bit ADC reading can be used to control the previous example's Wavetable16 oscillator's frequency with an exponential sweep over [0.2, 200]Hz using

...

#include <Oscillator.h>
#include <PgmTable.h>
#include <tables/sine_u16x1024.h>
#include <tables/exp1000_u16x1024.h>

const float fs = 16e3;

...

Wavetable16 lfo(sine_u16x1024, 6);	

uint16_t scale = 200.0/fs * 0xFFFF;
PgmTable16 freq_table(exp1000_u16x1024, scale);

...

ISR(ADC_vect) {
	adc.update();
	lfo.freq = freq_table.lookup_scale(adc.results[0]);
	sine_val = lfo.render();	// Render a sine sample
}

6.3 Filter Coefficients

The OnePole16 and OnePole16_LF (low-frequency) objects declared in IIR.h implement a first-order low pass filter (see Wikipedia for a derivation). The filter's difference equation is

The coefficient α is some function of the desired normalized cutoff frequency ωn. One can obtain a coefficient such that the -3dB cutoff frequency corresponds exactly to the desired frequency ωn by equating the magnitude of the difference equation's Z-transform to (or -3dB), giving a value

with

which is expensive to compute. Common approximations are derived from discretizing the filter's differential equation using the finite differences method, yielding

or by solving the differential equation and discretizing its transient response, yielding

which are also expensive to compute. We can plot each of the coefficient approximations as the normalized radian frequency varies up to ωn = π. We can see that these curves are not self-similar, thus cannot be normalized and rescaled for different frequency ranges and sample rates as the exponential curves can.

Coefficients

Unsigned 16-bit integer lookup tables of length 1024 for these three variants (scaled to Q16) can be generated using

> python tablegen.py coeff <method> <fmin> <fmax>

where method is one of {z, diff, trans}, and fmin and fmax are the frequency bounds, normalized such that (0, 0.5) corresponds to the range (0, π), or (0, fs/2).

For example, at fs = 16kHz, an exact filter coefficient table mapping a 10-bit ADC reading to the range [16, 1600]Hz can be generated with

> python tablegen.py coeff z 0.001 0.1

Therefore the sample rate must be known at the time the table is generated to obtain accurate cutoff frequencies.

A more flexible option for cutoff frequencies much less than fs/2 is to note that each coefficient table is approximately exponential for low frequencies. The following plots show how the magnitude and phase responses (second and third plots) vary with the three coefficient curves across a range of cutoff frequencies (vertical dashed line), compared with the equivalent analog filter response.

The red curves, represent (top to bottom), the normalized frequency stored in the exponential table, and the magnitude and phase response of the filter when using the frequency as an approximation for the coefficient, which not only requires no additional computation, but allows for dynamic scaling, demonstrated below in Options 2 and 3.

Coefficients and Frequency Response

Note that this approximation ωn ≈ α yields a stable filter for ωn < 1, or .

This means that we can often use normalized exponential tables with PgmTable16 as filter coefficients without significant inaccuracy in the magnitude response. For example, the following three coefficient tables will result in filters with (for musical purposes) functionally equivalent frequency responses over a cutoff frequency range of (0.2, 2000)Hz.

Option 1

The following yields accurate cutoff frequencies over its range as long as fs = 16kHz.

> python tablegen.py coeff z 1.25e-5 0.125
...

#include <IIR.h>
#include <PgmTable.h>
#include <tables/coeff_z_u16x1024.h>

...

OnePole16 filter;
PgmTable16 coeff_table(coeff_z_u16x1024);	

...

ISR(ADC_vect) {
	...
	filter.coeff = coeff_table.lookup(adc.results[0]); 
	...
}

Option 2

The following yields less accurate cutoff frequencies toward its maximum value, but sample rates can be changed without regenerating the table.

> python tablegen.py exp 10000
...

#include <IIR.h>
#include <PgmTable.h>
#include "tables/exp10000_u16x1024.h"

const float f_s = 16e3;

...

OnePole16 filter;

uint16_t scale = 2000.0/fs * 0xFFFF;
PgmTable16 coeff_table(exp10000_u16x1024, scale);	

...

ISR(ADC_vect) {
	...
	filter.coeff = coeff_table.lookup_scale(adc.results[0]); 
	...
}

Option 3

The following yields less accurate cutoff frequencies toward its minimum value, but sample rates can be changed without regenerating the table.

> python tablegen.py exp 10000
...

#include <IIR.h>
#include <PgmTable.h>
#include "tables/exp10000_u16x1024.h"

const float f_s = 16e3;

...

OnePole16 filter;

float b = 1 - cosf(2000.0/f_s);
float alpha = -b + sqrtf(b*b + 2*b);
uint16_t scale = alpha * 0xFFFF;
PgmTable16 coeff_table(exp10000_u16x1024, scale);	

...

ISR(ADC_vect) {
	...
	filter.coeff = coeff_table.lookup_scale(adc.results[0]); 
	...
}

7 LibAG Examples

The library's examples 0-3 use Timer1 in PWM mode for 10-bit digital to analog conversion, and example 4 uses the MCP4922 external DAC for 12-bit resolution. In examples 1-4, samples are processed at sample rate 10kHz using Timer0 in CTC mode and an ADCTimer0 instance configured to convert two control voltages on pins A0 and A1 in sequence for parameter control, giving a control rate of half the sample rate.

For reconstruction of PWM or DAC outputs, a good starting point is to use the following Sallen-Key low pass filter, which has two poles, giving -12dB attenuation per octave.

Sallen-Key

Using R = R1 = R2, and C = C1 = C2 gives a Q of 0.5 and a cutoff frequency .

For the examples, R = 4.7k and C = 0.1u yields a cutoff frequency of 339Hz and about -50dB of attenuation at 5kHz. A similar filter should be used if the ADC is used to process periodic signals that may contain frequency components above the control rate. In either case, higher order filters can extend the passband while maintaining the same attenuation at the Nyquist rate.

The op amp can be powered with a single supply if a bipolar supply is unavailable. If the op amp is supplied from the Arduino's 5V output, a rail-to-rail amplifier such as the TLV2372 can be used to allow use of the full [0, 5V] range.

0_MIDI

This example implements a basic MIDI to CV/Gate converter using Timer1 configured for 12-bit PWM. The example illustrates the use of MIDIDispatcher, a minimal state machine for parsing MIDI message bytes and delegating control to user-defined message handlers. Only Note On and Note Off messages are handled in the example, but the dispatcher includes handlers for pitch bends, mod wheel, etc.

This example is the only one included which uses Arduino's loop(), as it does not require signals to be generated at a fixed rate. SoftwareSerial is used to receive raw MIDI bytes and relay them to MIDIDispatcher. I recommend the following MIDI input circuit.

MIDI In

1_LFO

This example uses Wavetable16 to implement a low-frequency sinusoidal oscillator, an exponential table with PgmTable16 for variable frequency over [0.2, 200]Hz, and a Q16 multiply for variable amplitude.

2_ASR

This example uses ASR16 to implement a linear, 16-bit envelope generator with Attack, Sustain, and Release states. Attack and release rates are set using an exponential table with PgmTable16. Lookup is reversed so CCW potentiometer rotation corresponds to short attacks and releases. A rising edge on PD4 attacks and a falling edge releases.

3_LPF

This example uses OnePole16 to filter a square wave, writing the filter's low-pass output to OCR1A and the filter's high-pass output to OCR1B. The square wave is produced by thresholding a sawtooth waveform produced by Phasor16.

4_DAC

This example uses MCP4922, a subclass of SPIMaster, to control the external dual 12-bit SPI DAC of the same name. Its two channels are used to output sinusoidal waveforms offset by 90 degrees using Quad16, a subclass of Wavetable16.

A note on sample timing

Examples 1-4 use pins PD3 and PD2 to monitor the timing of samples. PD3 is toggled at the beginning of the sample processing interrupt, which if monitored on an oscilloscope should display a square wave at half the sample rate. PD2 is set high at the beginning of the sample processing interrupt, and cleared at the end, which should display a pulse whose width scales with the runtime of the processing code.

These examples provide ample headroom for expansion, and these timing pins should be monitored to ensure samples are generated on time as processing code is added.

About

Minimalist library for audio synthesis and processing on the ATmega328P and ATmega2560.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published