Algorithmic Waveform or AW Synth is kind of a code-it-yourself synthesizer. That may sound complex, but in the simplest case you can just use one of the built-in waveform generators and adjust amplitude and pitch envelope. On the other hand you could implement a fully custom synth using FM and other synthesis methods.
Here you can listen to three sound effects that use just square wave plus amplitude and pitch envelope, and at the end some FM synthesized laser gun shooting:
I have also made a sound effect editor, AW Patch Editor, that works both in FemtoIDE and in a web browser. The zip file below contains the AW Patch Editor plus AWSynthSource and SimpleTuneAW classes together with example main.cpp and sound effects.
Source code: AWSynth.zip (40.7 KB)
You can also get it from GitHub: https://github.com/jpfli/awsynth
Or try the editor online: https://jpfli.github.io/awsynth
The core idea of the AW Synth is similar to bytebeat: You define a callback function that is evaluated for each time frame and the returned values describe the waveform. The most notable difference is the additional phase input variable p
. Unlike the t
variable, itās increase rate isnāt fixed, but depends on the note being played, which allows you to generate sounds at the desired pitch. Also, instead of unsigned 8-bit value, the callback function returns a signed 8-bit value (actually 32-bit, but clipped to range -128ā¦127).
AWSynth and SimpleTuneAW basic usage example:
#include <Pokitto.h>
#include "AWSynthSource.h"
#include "SimpleTuneAW.h"
int main() {
using Pokitto::Core;
Core::begin();
using AWSynth = Audio::AWSynthSource;
using AWPatch = Audio::AWPatch;
// Create a patch
auto arp_patch = AWPatch([](std::uint32_t t, std::uint32_t p)->std::int32_t { // Lambda callback function
return AWSynth::sqr(p); // Square wave generator
})
.volume(80).step(6).release(12) // Volume 80%, step duration 6*8.33ms, release 12*step
.amplitudes(AWPatch::Envelope(100,100,100,100).loop(32,4)) // Length 4 steps, then loop (jump) to end
.semitones(AWPatch::Envelope( 0, 12, 4, 7).smooth(false).loop(0,4)); // Use discrete pitches to create an arpeggio
AWSynth& snd = AWSynth::play<0>(arp_patch, 52); // Play arp_patch on channel 0, midikey 52 (E-3)
// If the amplitude envelope doesn't loop, release is automatically triggered at the end. Otherwise manually release it:
//snd.release();
// Create a SimpleTuneAW tune
constexpr auto tune = SIMPLE_TUNE_AW(A-3,A-3,G-3,E-4,E-4,D-4,D-4,A-3,A-3).tempo(120*8); // Tempo 120, use 8th notes
Audio::playTuneAW<1>(tune).patch(arp_patch); // Play the tune on channel 1 using arp_patch
while(Core::isRunning()) {
if(!Core::update()) {
continue;
}
}
}
Amplitude and Pitch Envelope
There are no ADRS envelopes. Instead, AW Synth uses arrays of max 32 values. Each value specifies the amplitude (0 to 100 %) or relative pitch (-128 to +127 semitones) at the corresponding step. Step duration is defined with the AWPatch parameter step
in 8.33 ms increments. This technique allows you to create quite versatile envelopes and even arpeggios or simple sequences.
auto patch1 = Audio::AWPatch([](std::uint32_t t, std::uint32_t p)->std::int32_t { return AWSynth::sin(p); })
.step(5) // Step duration 5*8.33ms
// Start from 100, decay to 80 and sustain the note until released
.amplitudes(Audio::AWPatch::Envelope(100,98,96,94,92,90,88,86,84,82,80).smooth(true).loop(10,11))
// Pitch slide down and vibrato
.semitones(Audio::AWPatch::Envelope(12,10, 8, 6, 4, 2, 0, 0, 0, 0, 0, 0, 0, 1, 0, -1).smooth(true).loop(12,16));
smooth
option lets you choose whether the values are discrete (=false) or linearly
interpolated (=true, default) from one step to next.
loop
option takes two parameters: loop start index and length of the envelope. For example loop(12,16)
means that after index 15 the envelope loops back to index 12. Looping can be disabled by setting length parameter to 0 or >32.
If the end of the envelope is reached, the last value is sustained, and in case of amplitude envelope, release is automatically triggered. You can trigger release earlier by setting loop start parameter to 32, for example loop(32,11)
.
Waveform Generators
AW Synth has built-in functions that take input phase variable p
and turn it into one of the basic waveforms.
// Waveform generators turn input variable 'p' (phase, 256 is full period) into a waveform.
// Output is in the range -128..128
// Sine wave, triangluar wave and square wave generators
static constexpr std::int32_t sin(std::uint32_t p) { std::int32_t o = ((p&127) * (-p&127)) >> 5; return p&128 ? -o : o; }
static constexpr std::int32_t tri(std::uint32_t p) { std::int32_t o = (2*(p+64)) & 255; return (p+64)&128 ? 128-o : o-128; }
static constexpr std::int32_t sqr(std::uint32_t p) { return (p&128) ? -128 : 128; }
// Pulse wave generator with variable pulse width 'w'
static constexpr std::int32_t sqr(std::uint32_t p, std::uint32_t w) { return (p&255) < w ? 128 : -128; }
// Sawtooth or modified triangle wave generator. Optional template parameter 'XPeak' defines the x location (0 to 256) of the peak.
// XPeak values 0 (default) and 256 give a sawtooth wave. XPeak=128 gives a symmetric triangle wave, similar to 'tri()'.
template<signed XPeak=0>
static constexpr std::int32_t saw(std::uint32_t p) {
static_assert(XPeak>=0 && XPeak<=256);
constexpr std::int32_t RATE_A = XPeak>1 ? ((1<<20)/XPeak) : (1<<20);
constexpr std::int32_t RATE_B = XPeak<255 ? ((1<<20)/(256-XPeak)) : (1<<20);
std::int32_t o = (p+XPeak/2) & 255;
return ((o<XPeak ? o*RATE_A : (256-o)*RATE_B) >> 12) - 128;
}
// Noise generator
static inline std::int32_t noise() {
static std::uint32_t state = 0x5DEECE66;
state ^= state << 17;
state ^= state >> 13;
state ^= state << 5;
return ((state>>24) & 255) - 128;
}
Full period is 256. When e.g. note A-4 (440 Hz) is played, p
increases by 256 every 1/440 seconds or 2.273 milliseconds. In the callback function, p
can be multiplied and divided. sin(p/2)
generates a tone one octave lower. You can even write something like sin(p + sin(2*p))
for basic two operator FM, where one oscillator is modulated by another.
Ramp Generator and LFO
In addition to the waveform generators, there is ramp
and lfo
function. Ramp generator is used to create amplitude attack and decay envelopes and pitch slides. LFO is a higher resolution, custom period length sine wave generator and it can be used for vibrato and tremolo effects. Usually, though, it is easier to use the built-in amplitude and pitch envelopes.
// Envelope generator takes input variable 't' (ticks) and template parameter 'T' (duration).
// Returned value is 0 when t<=0, 1<<14 when t>=T and increases linearly when 0<t<T
template<signed T>
static constexpr std::int32_t ramp(std::int32_t t) {
static_assert(T>0 && T<=(1<<30));
return t<=0 ? 0 : (t>=T ? (1<<14) : (t * ((1<<(14+16))/T) >> 16));
}
// LFO is a sine wave generator that takes input variable 't' (ticks) and template parameter 'T' (full period).
// Output is in the range -1<<14..1<<14 for greater resolution
template<signed T>
static constexpr std::int32_t lfo(std::uint32_t t) {
t = t*(((1<<(14+16))+T-1)/T) >> 16;
std::uint32_t o = ((t&8191)*(-t&8191)) >> 10;
return t&8192 ? -o : o;
}
SineBeat and Parametric ByteBeat
Naturally AW Synth can also do bytebeat. and the following examples demonstrate two possible ways it can be used. First example uses bytebeat equation as phase input for sine generator. This way you get the same bytebeat tune, but with smoother, almost sine waveform instead of the typical sawtooth.
auto sinebeat_patch = AWPatch([](std::uint32_t t, std::uint32_t p)->std::int32_t {
std::uint32_t b = t*((t>>9|t>>13)&25&t>>6); // Evaluate bytebeat equation
return AWSynth::sin(b); // Use as phase input for sine wave generator
})
.amplitudes(AWPatch::Envelope(0,100).loop(1,2));
Second example uses a callback function with a variable passed as user data to interactively change the bytebeat equation while it is playing. Different values of parameter p1
give you different tunes that still have some similar characteristics. This could be used for example as game background music that changes when there are more enemies around or when health bar gets low.
std::uint32_t parambeat_p1 = 1; // Variable passed to the callback function as user data
auto parambeat_patch = AWPatch([](std::uint32_t t, std::uint32_t p, void* data)->std::int32_t { // Lambda callback function with user data
std::uint32_t& p1 = *reinterpret_cast<std::uint32_t*>(data);
std::int32_t o = ((p1*t)>>4)|(t>>5)|t; // Evaluate bytebeat equation using current value of p1
return (o&255) - 128; // Bytebeat result must be truncated to 8-bits and converted to signed value
}, parambeat_p1)
.amplitudes(AWPatch::Envelope(0,100).loop(1,2));