This project is intended to be a polyphonic synthesizer for use in embedded microcontrollers. It features multi-voice synthesis for multiple channels.
The synthesis is inspired from the highly regarded MOS Technologies 6581 "SID" chip, which supported up to 3 voices each producing either a square wave, triangle wave or sawtooth wave output and hardware attack/decay/sustain/release envelope generation.
This tries to achieve the same thing in software.
The library runs as a state machine. Synthesis is performed completely using only integer artihmatic operations: specifically addition, subtraction, left and right shifts, and occasional multiplication. This makes it suitable for smaller CPU cores such as Atmel's TinyAVR, ARM's Cortex M0+, lower-end TI MSP430 and other minimalist CPU cores that lack hardware multipliers or floating-point hardware.
The data types and sizes are optimised for 8-bit microcontroller hardware.
The state is defined as an array of "voice" objects, all of the type
struct voice_ch_t
and a synthesizer state machine object of type
struct poly_synth_t
.
These voices combine an ADSR envelope generator and a waveform generator. A voice is configured by setting the waveform type and frequency in the waveform generator. This algorithmically provides a monophonic tone which is then amplitude-modulated using the ADSR envelope generator.
Under the control of the synthesizer state machine, the voices are
selectively computed and summed to produce a final sample value for the
output. The bit masks that enable and mute channels are defined by the
uintptr_t
data type, and so in most cases, 16 or 32 channels can be
accommodated depending on the underlying microcontroller.
ADSR stands for Attack-Decay-Sustain-Release, and forms a mechanism for modelling typical instrument sounds. The state machine for the ADSR waveform moves through the following states:
-
Delay phase: This is a programming convenience that allows for the state of multiple voices to be configured at some convenient point in the program in bulk whilst still providing flexibility on when a particular note is played. As there is no amplitude change, an "infinte" time delay may also be specified here, allowing a note to be configured then triggered "on cue".
-
Attack phase: The amplitude starts at 0, and using an approximated exponential function, rises rapidly up to the peak amplitude. The exponential function is approximated by left-shifting the peak amplitude value by varying amounts.
-
Decay phase: The amplitude drops from the peak, to the sustain amplitude. The decay is linear with time and is achieved by subtracting a fraction of the difference between peak and sustain amplitudes each cycle.
-
Sustain phase: The amplitude is held constant at the sustain amplitude. As there is no amplitude change, it is also possible to define this with an "infinite" duration, allowing the note to be released "on cue" (e.g. when the user releases a key).
-
Release phase: The amplitude dies off with a reverse-exponential function much like the attack phase. Again, it is approximated by left-shifting the sustain amplitude.
The typical usage scenario is to statically define an array of struct voice_ch_t
objects and a struct poly_synth_t
object. To set the
sample rate, create a header file with the content:
#define SYNTH_FREQ (16000)
… then in your project's Makefile
or C-preprocessor settings, define
SYNTH_CFG=\"synth-config.h\"
to tell the library where to find its
configuration.
The above example sets the sample rate to 16kHz… you can set any value here appropriate for your microcontroller.
The struct poly_synth_t
is initialised by clearing the enable
and
mute
members, and setting the voice
member to the address of the
array of struct voice_ch_t
objects.
Having initialised the data structures, you can then start reading your musical score. To play a note, you select a voice channel, then:
- Call
adsr_config
with the arguments:time_scale
: number of samples per "time unit"delay_time
: number oftime_scale
units before the "attack" phase. If set toADSR_INFINITE
, the note is delayed untiladsr_continue
is called.attack_time
: number oftime_scale
units taken for the note to reach peak amplitude (peak_amp
)decay_time
: number oftime_scale
units taken for the note to decay to the "sustain" amplitude (sustain_amp
)sustain_time
: number oftime_scale
units taken for the note to hold thesustain_amp
amplitude before the "release" phase.release_time
: number oftime_scale
units taken for the note to decay back to silence.peak_amp
: the peak amplitude of the note.sustain_amp
: the sustained amplitude of the note.
- Call one of the waveform generator set-up functions.
amplitude
sets the base amplitude for the waveform generator.freq
sets the frequency for the waveform generator.
- Set the corresponding bits in
struct poly_synth_t
:- set the corresponding
enable
bit to compute the output of that synth voice channel - clear the corresponding
mute
bit for the output of that synth channel to be added to the resultant output.
- set the corresponding
Then call poly_synth_next
repeatedly to read off each sample. The
samples are returned as signed 8-bit PCM. Each call will advance the
state machines and so successive calls will return consecutive samples.
As each channel finishes, the corresponding bit in the enable
member
of struct poly_synth_t
is cleared.
When all the machines have finished, the poly_synth_next
function will
return all zeros and the enable
field of struct poly_synth_t
will be
zero.
There are 5 waveform generator algorithms to choose from. The state machines have the following variables:
sample
: The latest waveform generator sample.amplitude
: The peak waveform amplitude (from 0 axis, so half. peak-to-peak).period
: The period of the internal state machine counterperiod_remain
: The internal state machine counter itself. This gets set to a value then decremented until it reaches zero.step
: The amplitude step size.
Configures the waveform generator to generate a "DC" waveform (constant amplitude). Not terribly useful at this time but may be handy if you wish to use the ADSR envelope generator only to modulate lights.
Configures the waveform generator to generate a square wave.
sample
is initialised as +amplitude
, and the half-period is
computed as period=SYNTH_FREQ/(2*freq)
. period_remain
is
initialised to period
.
Each sample, period_remain
is decremented. When period_remain
reaches zero:
sample
is algebraically negatedperiod_remain
is reset back toperiod
Configures the waveform generator to produce a sawtooth wave.
sample
is initialised as -amplitude
, and the time period is
computed as period=SYNTH_FREQ/freq
. The step
is computed as
step=(2*amplitude)/T
. period_remain
is initialised at period
.
Every sample, sample
is incremented by step
and period_remain
decremented. When period_remain
reaches zero:
sample
is reset to-amplitude
period_remain
reset toperiod
Configures the waveform generator to produce a triangle wave.
sample
is initialised as -amplitude
, and the time period is
computed as period=SYNTH_FREQ/(2*freq)
. The step
is computed as
step=(2*amplitude)/T
. period_remain
is initialised at period
.
Every sample, sample
is incremented by step
and period_remain
decremented. When period_remain
reaches zero:
- if
step
is negative,sample
is reset to-amplitude
, otherwise it is reset to+amplitude
. step
is algebraically negated.period_remain
is reset toperiod
This generates random samples at a given amplitude. The randomness
depends on the C library's random number generator (rand()
), so it may
help to periodically seed it, perhaps by taking the least-significant
bits of ADC readings and feeding those into srand
to give it some true
randomness.
The code is written with portability in mind. While it has only ever been compiled on GNU/Linux platforms, it has successfully worked on AVR and Linux/x86-64. The code should compile and work for other processor architectures too.
To build a port, run:
$ make PORT=port_name
The ATTiny85 port was the first microcontroller port of this synthesizer
library. The demonstration port tries to operate as a stand-alone
synthesizer. The PWM output is emitted out of PB4
and PB3
is used
as an ADC input.
Connected to PB3
is a voltage divider network, with the segments
connected to Vcc via pushbuttons. When a button is pressed, it shorts a
section of the resistor divider out, and a higher voltage is seen on the
ADC input.
The voltage is translated to a button press and used to trigger one of the voices, each of which have been configured with a different note.
The code is a work-in-progress, with some notable bugginess with the ADC-based keyboard implementation.
The remaining pins are available for other uses such as I²C/SPI or for additional GPIOs.
This code is forked from the ATTiny85 port when it was realised that the ATTiny85 with its 5 usable I/O pins would be incapable of driving lots of lights without a lot of I/O expansion hardware.
Here, four I/O pins on port B are allocated:
PB3
: PWM audio outputPB4
: audio enable pinPB5
: PWM light outputPB6
: GPIO enable pin
The audio amplifier used in the prototype is the NJR NJM2113D, and features a "chip disable" pin that powers down the amplifier when pulled high. The audio enable signal drives a N-channel MOSFET (e.g. 2N7000) that pulls the pin low against a pull-up resistor to +12V.
A logic high on the audio enable signal turns on the amplifier.
All of the pins on port A (PA0
through to PA7
) are used for
interfacing to MOSFETs and pushbuttons by way of a multiplexing circuit
driven by the GPIO enable pin. The multiplexing circuit consists of two
4066-style analogue switches (I am using Motorola MC14066Bs here because
I have dozens of them with 8241 date codes) and a 74374 D-latch.
The GPIO enable line connects the clock input of the 74374 and the switch enable pins on all switches in both 4066s. The 4066 and 74374 inputs are paralleled.
When GPIO enable is driven low, this turns off the 4066s allowing us to assert signals for the 74374. On the rising edge of GPIO enable, the 74374 latches those signals and the 4066s re-connect port A to the outside world. By doing this, port A is able to be used both for control of digital outputs hanging off the 74374 and for analogue + digital I/O through the 4066s.
The light PWM signal on PB5
connects to the 74374's "output enable"
line, and thus by using pull-down resistors on the outputs of the 74374,
it can drive N-channel MOSFETs to control the brightness of 8 lights.
Individual control of lights can be achieved with ⅛ maximum duty cycle by choosing a single output then driving that line with the desired PWM amplitude.
This uses libao
and a command line interface to simulate the output of
the synthesizer. It was used to debug the synthesizer.
The synthesizer commands are given as command-line arguments:
voice V
selects a given voice channelmute M
sets the synthesizermute
bit-masken M
sets the synthesizerenable
bit-maskdc A
sets the selected voice channel to produce a DC offset of amplitudeA
.noise A
sets the selected voice channel to produce pseudorandom noise at amplitudeA
.square F A
sets the selected voice channel to produce a square wave of frequencyF
Hz and amplitudeA
.sawtooth F A
sets the selected voice channel to produce a sawtooth wave of frequencyF
Hz and amplitudeA
.triangle F A
sets the selected voice channel to produce a triangle wave of frequencyF
Hz and amplitudeA
.scale N
sets the ADSR time unit scale for the selected channel toN
samples per "tick"delay N
sets the ADSR delay period for the selected channel toN
"ticks"attack N
sets the ADSR attack period for the selected channel toN
"ticks"decay N
sets the ADSR decay period for the selected channel toN
"ticks"sustain N
sets the ADSR sustain period for the selected channel toN
"ticks"release N
sets the ADSR release period for the selected channel toN
"ticks"peak A
sets the peak ADSR amplitude for the selected channel toA
samp A
sets the sustained ADSR amplitude for the selected channel toA
reset
resets the ADSR state machine for the selected channel.
Once the enable
bit-mask is set, the program loops, playing sound via
libao
and writing the samples to out.raw
for later analysis until
all bits in the enable
bit-mask are cleared by the ADSR state machines.
When the program runs out of command line arguments, it exits.