diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9bca313 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +BOARD = --board adafruit:samd:adafruit_trellis_m4 + +verify: uilleann.ino + arduino --verify $(BOARD) $< + +install: uilleann.ino + arduino --upload $(BOARD) $< + diff --git a/dx9.h b/dx9.h new file mode 100644 index 0000000..f52434e --- /dev/null +++ b/dx9.h @@ -0,0 +1,91 @@ +// FM Algorithms used by the DX9 +// Excellent write-up: +// https://gist.github.com/bryc/e997954473940ad97a825da4e7a496fa + +#pragma once + +// Each operator has 4 input gains and one output gain: +// { 1→, 2→, 3→, 4→, →out} + +// ⮎4→3→2→1→ +#define DX9_ALG_1(feedback) \ + { \ + {0, 1, 0, 0, 1}, \ + {0, 0, 1, 0, 0}, \ + {0, 0, 0, 1, 0}, \ + {0, 0, 0, feedback, 0}, \ + } + +// ⮎4⬎ +// 3→2→1→ +#define DX9_ALG_2(feedback) \ + { \ + {0, 1, 0, 0, 1}, \ + {0, 0, 1, 1, 0}, \ + {0, 0, 0, 0, 0}, \ + {0, 0, 0, feedback, 0}, \ + } + +// ⮎4⬎ +// 3→2→1→ +#define DX9_ALG_3(feedback) \ + { \ + {0, 1, 0, 1, 1}, \ + {0, 0, 1, 0, 0}, \ + {0, 0, 0, 0, 0}, \ + {0, 0, 0, feedback, 0}, \ + } + +// ⮎4→3⬎ +// 2→1→ +#define DX9_ALG_4(feedback) \ + { \ + {0, 1, 0, 1, 1}, \ + {0, 0, 1, 0, 0}, \ + {0, 0, 0, 0, 0}, \ + {0, 0, 0, feedback, 0}, \ + } + +// ⮎4→3→ +// 2→1→ +#define DX9_ALG_5(feedback) \ + { \ + {0, 1, 0, 0, 1}, \ + {0, 0, 0, 0, 0}, \ + {0, 0, 0, 1, 1}, \ + {0, 0, 0, feedback, 0}, \ + } + +// 1→ +// ⮎4→2→ +// 3→ +#define DX9_ALG_6(feedback) \ + { \ + {0, 0, 0, 0, 1}, \ + {0, 0, 0, 1, 1}, \ + {0, 0, 0, 0, 1}, \ + {0, 0, 0, feedback, 0}, \ + } + +// 1→ +// 2→ +// ⮎4→3→ +#define DX9_ALG_7(feedback) \ + { \ + {0, 0, 0, 0, 1}, \ + {0, 0, 0, 0, 1}, \ + {0, 0, 1, 0, 1}, \ + {0, 0, 0, feedback, 0}, \ + } + +// 1→ +// 2→ +// 3→ +// ⮎4→ +#define DX9_ALG_8(feedback) \ + { \ + {0, 0, 0, 0, 1}, \ + {0, 0, 0, 0, 1}, \ + {0, 0, 0, 0, 1}, \ + {0, 0, 0, feedback, 1}, \ + } diff --git a/notes.cpp b/notes.cpp new file mode 100644 index 0000000..6732035 --- /dev/null +++ b/notes.cpp @@ -0,0 +1,44 @@ +#include +#include "notes.h" + +const char *NoteNames[] { + "C ", "C#", "D ", "Eb", "E ", "F ", "F#", "G ", "Ab", "A ", "Bb", "B ", +}; +float JustPitches[MaxNote + 1]; + +// Hat tip to Kyle Gann +// https://www.kylegann.com/tuning.html +void setupJustPitches(uint8_t baseNote, float basePitch) { + JustPitches[baseNote + 0] = basePitch * 1 / 1; // D + JustPitches[baseNote + 1] = basePitch * 16 / 15; // Eb + JustPitches[baseNote + 2] = basePitch * 9 / 8; // E + JustPitches[baseNote + 3] = basePitch * 6 / 5; // F + JustPitches[baseNote + 4] = basePitch * 5 / 4; // F# + JustPitches[baseNote + 5] = basePitch * 4 / 3; // G + JustPitches[baseNote + 6] = basePitch * 45 / 32; // Ab + JustPitches[baseNote + 7] = basePitch * 3 / 2; // A + JustPitches[baseNote + 8] = basePitch * 8 / 5; // Bb + JustPitches[baseNote + 9] = basePitch * 5 / 3; // B + JustPitches[baseNote + 10] = basePitch * 9 / 5; // C + JustPitches[baseNote + 11] = basePitch * 15 / 8; // C# + + // Two fourths up from the base pitch, so G major scale sounds right + JustPitches[baseNote + 11] = basePitch * 8 / 3; // C# + + // Octaves + for (int note = baseNote; note < baseNote + 12; note++) { + for (int i = 1; i < 9; i++) { + int multiplier = 1<= 0) { + JustPitches[dnNote] = JustPitches[note] / multiplier; + } + } + } +} diff --git a/notes.h b/notes.h index 6a0b0fe..d8b4435 100644 --- a/notes.h +++ b/notes.h @@ -1,3 +1,7 @@ +#pragma once + +#define PITCH_D4 293.66 + enum Notes { NOTE_C0, NOTE_CS0, NOTE_D0, NOTE_DS0, NOTE_E0, NOTE_F0, NOTE_FS0, NOTE_G0, NOTE_GS0, NOTE_A0, NOTE_AS0, NOTE_B0, NOTE_C1, NOTE_CS1, NOTE_D1, NOTE_DS1, NOTE_E1, NOTE_F1, NOTE_FS1, NOTE_G1, NOTE_GS1, NOTE_A1, NOTE_AS1, NOTE_B1, @@ -11,8 +15,7 @@ enum Notes { }; const uint8_t MaxNote = NOTE_B8; -const char *NoteNames[] { - "C ", "C#", "D ", "Eb", "E ", "F ", "F#", "G ", "Ab", "A ", "Bb", "B ", -}; +extern const char *NoteNames[]; +extern float JustPitches[MaxNote + 1]; -#define PITCH_D4 293.66 +void setupJustPitches(uint8_t baseNote, float basePitch); diff --git a/patches.h b/patches.h index 27f5a8b..1c80285 100644 --- a/patches.h +++ b/patches.h @@ -1,50 +1,39 @@ -typedef struct Operator { - float gain; - float delay; - float attack; - float hold; - float decay; - float sustain; - float release; - float baseFrequency; - float multiplier; -} Operator; +// "Factory" patches -typedef struct Patch { - char *name; - Operator operators[4]; - float feedback; -} Patch; +#pragma once +#include "dx9.h" -Patch Bank[] = { +// Waveform, offset, multiplier, delay, attack, holdAmp, hold, decay, sustainAmp, release +FMPatch Bank[] = { { "Venus Oboe", + DX9_ALG_5(0), { - {1.0, 0, 10.5, 0, 5000, 0.75, 5.0, 0, 1.00}, - {1.0, 0, 10.5, 0, 2000, 0.80, 5.0, 0, 4.00}, - {0.0, 0, 10.5, 0, 2000, 0.50, 5.0, 0, 8.00}, - {0.0, 0, 50.0, 0, 800, 0.75, 5.0, 0, 16.00}, + // Waveform off mult del att hldA hld dec susA rel + {WAVEFORM_SINE, 0, 1.00, 0, 10.5, 1.0, 10.5, 0, 0.75, 5}, + {WAVEFORM_SINE, 0, 4.00, 0, 10.5, 1.0, 10.5, 0, 0.80, 5}, + {WAVEFORM_SINE, 0, 8.00, 0, 10.5, 1.0, 10.5, 0, 0.50, 5}, + {WAVEFORM_SINE, 0, 16.00, 0, 10.5, 1.0, 50.0, 0, 0.75, 5}, }, - 0.0, }, { "IWantPizza", + DX9_ALG_1(0), { - {1.0, 0, 10.5, 0, 5000, 0.35, 100, 0, 4.00}, - {1.0, 0, 10.5, 0, 2000, 0.30, 100, 0, 1.00}, - {1.0, 0, 10.5, 0, 2000, 0.50, 100, 0, 8.00}, - {1.0, 0, 200, 0, 800, 0.25, 100, 0, 16.00}, + {WAVEFORM_SINE, 0, 4.00, 0, 10.5, 1.0, 10.5, 0, 0.35, 20}, + {WAVEFORM_SINE, 0, 1.00, 0, 10.5, 1.0, 10.5, 0, 0.30, 20}, + {WAVEFORM_SINE, 0, 8.00, 0, 10.5, 1.0, 10.5, 0, 0.50, 20}, + {WAVEFORM_SINE, 0, 16.00, 0, 10.5, 1.0, 50, 0, 0.25, 20}, }, - 0.0, }, { "Ray Gun", + DX9_ALG_1(0), { - {1.0, 0, 10.5, 0, 5000, 0.35, 2000, 0, 1.00}, - {1.0, 0, 10.5, 0, 2000, 0.30, 2000, 0, 1.00}, - {1.0, 0, 10.5, 0, 2000, 0.00, 2000, 0, 9.00}, - {1.0, 0, 200, 0, 800, 0.25, 800, 0, 1.00}, + {WAVEFORM_SINE, 0, 1.00, 0, 10.5, 1.0, 10.5, 0, 0.35, 20}, + {WAVEFORM_SINE, 0, 1.00, 0, 10.5, 1.0, 10.5, 0, 0.30, 20}, + {WAVEFORM_SINE, 0, 9.00, 0, 10.5, 1.0, 10.5, 0, 0.00, 20}, + {WAVEFORM_SINE, 0, 1.00, 0, 10.5, 1.0, 50, 0, 0.25, 8}, }, - 0.0, }, }; diff --git a/synth.cpp b/synth.cpp new file mode 100644 index 0000000..639dcd8 --- /dev/null +++ b/synth.cpp @@ -0,0 +1,42 @@ +#include "synth.h" + +void FMVoiceLoadPatch(FMVoice *v, FMPatch *p) { + for (int i=0; ioperators[i]; + + v->oscillators[i].begin(op.waveform); + v->envelopes[i].delay(op.delayTime); + v->envelopes[i].attack(op.attackTime); + v->oscillators[i].amplitude(op.holdAmplitude); + v->envelopes[i].hold(op.holdTime); + v->envelopes[i].decay(op.decayTime); + v->envelopes[i].sustain(op.sustainAmplitude / op.holdAmplitude); + v->envelopes[i].release(op.releaseTime); + + // This feels wasteful 🙁 + for (int j=0; jmixers[i].gain(j, p->gains[i][j]); + } + v->outputMixer.gain(i, p->gains[i][NUM_OPERATORS]); + } +} + +void FMVoiceSetPitch(FMVoice *v, float freq) { + for (int i=0; i<4; i++) { + FMOperator op = v->patch->operators[i]; + v->oscillators[i].frequency(op.offset + (freq * op.multiplier)); + } +} + +void FMVoiceNoteOn(FMVoice *v, float freq) { + FMVoiceSetPitch(v, freq); + for (int i=0; i<4; i++) { + v->envelopes[i].noteOn(); + } +} + +void FMVoiceNoteOff(FMVoice *v) { + for (int i=0; i<4; i++) { + v->envelopes[i].noteOff(); + } +} diff --git a/synth.h b/synth.h index 0ac7068..76ce63f 100644 --- a/synth.h +++ b/synth.h @@ -1,38 +1,135 @@ +#pragma once #include #include #include #include -// GUItool: begin automatically generated code -AudioMixer4 feedback; //xy=110,37 -AudioSynthWaveformSineModulated osc4; //xy=112,98 -AudioSynthWaveformSineModulated osc2; //xy=112,194 -AudioSynthWaveformSineModulated osc1; //xy=112,245 -AudioSynthWaveformSineModulated osc3; //xy=113,146 -AudioMixer4 mixOp; //xy=114,418 -AudioEffectEnvelope env4; //xy=251,97 -AudioEffectEnvelope env3; //xy=251,146 -AudioEffectEnvelope env2; //xy=252,194 -AudioEffectEnvelope env1; //xy=252,245 -AudioFilterBiquad biquad1; //xy=257,418 -AudioMixer4 mixL; //xy=472,402 -AudioMixer4 mixR; //xy=473,498 -AudioOutputAnalogStereo dacs1; //xy=724,452 -AudioConnection patchCord1(feedback, osc4); -AudioConnection patchCord2(osc4, env4); -AudioConnection patchCord3(osc4, 0, feedback, 0); -AudioConnection patchCord4(osc2, env2); -AudioConnection patchCord5(osc1, env1); -AudioConnection patchCord6(osc3, env3); -AudioConnection patchCord7(mixOp, biquad1); -AudioConnection patchCord8(env4, osc3); -AudioConnection patchCord9(env4, 0, mixOp, 3); -AudioConnection patchCord10(env3, 0, mixOp, 2); -AudioConnection patchCord11(env2, osc1); -AudioConnection patchCord12(env2, 0, mixOp, 1); -AudioConnection patchCord13(env1, 0, mixOp, 0); -AudioConnection patchCord14(biquad1, 0, mixL, 0); -AudioConnection patchCord15(biquad1, 0, mixR, 0); -AudioConnection patchCord17(mixL, 0, dacs1, 0); -AudioConnection patchCord18(mixR, 0, dacs1, 1); -// GUItool: end automatically generated code +#define NUM_OPERATORS 4 + +/** FMOperator defines all settable paramaters to an operator. + * + * An FM operator consists of: + * - An input + * - An oscillator + * - An envelope generator + * + * Frequency Modulation happens by chaining oscillators together, + * using the output of one to modulate the frequency of the next. + * + * Oscillators generate waveforms in a shape defined by + * `synth_waveform.h`. WAVEFORM_SINE is a good one to start with. + * Other sensible options are SAWTOOTH, SQUARE, and TRIANGLE. + * + * Frequency for an oscillator is calculated with: + * offset + (voiceFrequency × multiplier) + * + * Oscillator frequency is then modulated by the level obtained + * by the input mixer: level of 1.0 shifts frequency up by + * 8 octaves, level of -1.0 shifts frequency down by 8 octaves. + * + * The envelope modifies amplitude of the oscillator output, + * using the following rules: + * - stay at 0 until Note On + * - stay at 0 for `delayTime` milliseconds + * - linear increase to `holdAmplitude` for `attackTime` milliseconds + * - stay at `holdAmplitude` for `holdTime` milliseconds + * - linear decrease to `sustainAmplitude` for `decayTime` milliseconds + * - stay at `sustainAmplitude` until Note Off + * - linear decrease to 0 for `releaseTime` milliseconds + */ +typedef struct FMOperator { + // Oscillator + short waveform; + float offset; + float multiplier; + + // Envelope + float delayTime; + float attackTime; + float holdAmplitude; + float holdTime; + float decayTime; + float sustainAmplitude; + float releaseTime; +} FMOperator; + +/** FMPatch defines all parameters to a voice patch. + * + * This defines the "sound" of an FM voice, + * just like a "Patch" does in a hardware synthesizer. + * I think of a "patch" being the physical cables that + * connect oscillators together, and to the output mixer. + * + * Each operator has NUM_OPERATORS input gains, + * one output gain (to the voice output mixer), + * and NUM_OPERATORS operators. + * + * Historical FM synthisizers, + * such as the DX7 or DX9, + * used "algorithms" to patch operators into one another: + * this is done with 0.0 or 1.0 values to the gains. + * The "feedback" on operator 4 of the DX9 + * can be accomplished by patching an operator into itself. + */ +typedef struct FMPatch { + char *name; + float gains[NUM_OPERATORS][NUM_OPERATORS+1]; + FMOperator operators[NUM_OPERATORS]; +} FMPatch; + +/** FMVoice sets up all the Audio objects for a voice. + */ +typedef struct FMVoice { + AudioMixer4 mixers[NUM_OPERATORS]; + AudioSynthWaveformModulated oscillators[NUM_OPERATORS]; + AudioEffectEnvelope envelopes[NUM_OPERATORS]; + AudioMixer4 outputMixer; + FMPatch *patch; +} FMVoice; + +/** FMOperatorWiring outputs AudioConnection initializers to wire one FM Operator + */ +#define FMOperatorWiring(name, i) \ + {name.mixers[i], 0, name.oscillators[i], 0}, \ + {name.oscillators[i], 0, name.envelopes[i], 0}, \ + {name.envelopes[i], 0, name.outputMixer, i}, \ + {name.envelopes[i], 0, name.mixers[0], i}, \ + {name.envelopes[i], 0, name.mixers[1], i}, \ + {name.envelopes[i], 0, name.mixers[2], i}, \ + {name.envelopes[i], 0, name.mixers[3], i} + +/** FMVoiceWiring outputs AudioConnection initializer to wire one FMVoice + */ +#define FMVoiceWiring(name) \ + FMOperatorWiring(name, 0), \ + FMOperatorWiring(name, 1), \ + FMOperatorWiring(name, 2), \ + FMOperatorWiring(name, 3) + +/** FMVoiceLoadPatch loads a patch into a voice. + */ +void FMVoiceLoadPatch(FMVoice *v, FMPatch *p); + +/** FMVoiceSetPitch sets the pitch (Hz) of a voice. + * + * This does not signal the envelope in any way. + * You would use this for a glissando, portamento, or pitch bend. + * In my bagpipe, this prevents "reed noise" when changing notes. + */ +void FMVoiceSetPitch(FMVoice *v, float pitch); + +/** FMVoiceNoteOn sets the pitch (Hz) of a voice, and starts in playing. + * + * This tells the envelope generators to begin. + * On a piano, this is what you would use when a key is pressed. + * In my bagpipe, this triggers "reed noise". + */ +void FMVoiceNoteOn(FMVoice *v, float pitch); + +/** FMVoiceNoteOff stops a note from playing. + * + * This turns the voice "off" by shutting down all the envelope generators. + * On a piano, this is what you would use when a key is released. + * In my bagpipe, this corresponds to all holes being closed. + */ +void FMVoiceNoteOff(FMVoice *v); diff --git a/synth.ino b/uilleann.ino similarity index 67% rename from synth.ino rename to uilleann.ino index e93c364..96e1d0e 100644 --- a/synth.ino +++ b/uilleann.ino @@ -12,11 +12,47 @@ #define KNEE_OFFSET 0 #define KEY_OFFSET 2 -float cmaj_low[8] = { 130.81, 146.83, 164.81, 174.61, 196.00, 220.00, 246.94, 261.63 }; -float cmaj_high[8] = { 261.6, 293.7, 329.6, 349.2, 392.0, 440.0, 493.9, 523.3 }; +FMVoice Chanter; +FMVoice Drones[3]; +FMVoice Regulators[3]; -AudioEffectEnvelope *envs[] = {&env1, &env2, &env3, &env4}; -AudioSynthWaveformSineModulated *oscs[] = {&osc1, &osc2, &osc3, &osc4}; +AudioFilterBiquad biquad1; +AudioMixer4 mixDrones; +AudioMixer4 mixRegulators; +AudioMixer4 mixL; +AudioMixer4 mixR; +AudioOutputAnalogStereo dacs1; + +AudioConnection FMVoicePatchCords[] = { + {Chanter.outputMixer, 0, biquad1, 0}, + {biquad1, 0, mixL, 0}, + {biquad1, 0, mixR, 0}, + + {Drones[0].outputMixer, 0, mixDrones, 0}, + {Drones[1].outputMixer, 0, mixDrones, 1}, + {Drones[2].outputMixer, 0, mixDrones, 2}, + {mixDrones, 0, mixL, 1}, + {mixDrones, 0, mixR, 1}, + + {Regulators[0].outputMixer, 0, mixRegulators, 0}, + {Regulators[1].outputMixer, 0, mixRegulators, 1}, + {Regulators[2].outputMixer, 0, mixRegulators, 2}, + {mixRegulators, 0, mixL, 2}, + {mixRegulators, 0, mixR, 2}, + + {mixL, 0, dacs1, 0}, + {mixR, 0, dacs1, 1}, + + FMVoiceWiring(Chanter), + FMVoiceWiring(Drones[0]), + FMVoiceWiring(Drones[1]), + FMVoiceWiring(Drones[2]), + FMVoiceWiring(Drones[3]), + FMVoiceWiring(Regulators[0]), + FMVoiceWiring(Regulators[1]), + FMVoiceWiring(Regulators[2]), + FMVoiceWiring(Regulators[3]), +}; int currentPatch = 0; @@ -25,57 +61,8 @@ Adafruit_NeoTrellisM4 trellis = Adafruit_NeoTrellisM4(); MicroOLED oled(9, 1); QwiicButton bag; -// Hat tip to Kyle Gann -// https://www.kylegann.com/tuning.html -float JustPitches[MaxNote + 1]; -void setupJustPitches(uint8_t baseNote, float basePitch) { - JustPitches[baseNote + 0] = basePitch * 1 / 1; - JustPitches[baseNote + 1] = basePitch * 16 / 15; - JustPitches[baseNote + 2] = basePitch * 9 / 8; - JustPitches[baseNote + 3] = basePitch * 6 / 5; - JustPitches[baseNote + 4] = basePitch * 5 / 4; - JustPitches[baseNote + 5] = basePitch * 4 / 3; - JustPitches[baseNote + 6] = basePitch * 45 / 32; - JustPitches[baseNote + 7] = basePitch * 3 / 2; - JustPitches[baseNote + 8] = basePitch * 8 / 5; - JustPitches[baseNote + 9] = basePitch * 5 / 3; - JustPitches[baseNote + 10] = basePitch * 9 / 5; - JustPitches[baseNote + 11] = basePitch * 15 / 8; - // Octaves - for (int note = baseNote; note < baseNote + 12; note++) { - for (int i = 1; i < 9; i++) { - int multiplier = 1<= 0) { - JustPitches[dnNote] = JustPitches[note] / multiplier; - } - } - } -} - -void loadPatch(Patch p) { - for (int i=0; i<4; i++) { - Operator op = p.operators[i]; - - oscs[i]->amplitude(op.gain); - envs[i]->delay(op.delay); - envs[i]->attack(op.attack); - envs[i]->hold(op.hold); - envs[i]->decay(op.decay); - envs[i]->sustain(op.sustain); - envs[i]->release(op.release); - } - feedback.gain(0, p.feedback); -} - -void setup(){ +void setup() { setupJustPitches(NOTE_D4, PITCH_D4); pinMode(LED_BUILTIN, OUTPUT); @@ -117,31 +104,6 @@ void setup(){ AudioMemoryUsageMaxReset(); } -void setPitch(float freq) { - for (int i=0; i<4; i++) { - Operator op = Bank[currentPatch].operators[i]; - oscs[i]->frequency(op.baseFrequency + freq*op.multiplier); - } -} - -void noteOn(float freq) { - AudioNoInterrupts(); - for (int i=0; i<4; i++) { - Operator op = Bank[currentPatch].operators[i]; - oscs[i]->frequency(op.baseFrequency + freq*op.multiplier); - envs[i]->noteOn(); - } - AudioInterrupts(); -} - -void noteOff() { - AudioNoInterrupts(); - for (int i=0; i<4; i++) { - envs[i]->noteOff(); - } - AudioInterrupts(); -} - #define BUTTON_UP 0 #define BUTTON_DOWN 8 #define BUTTON_PITCH 24 @@ -207,13 +169,13 @@ void updateTunables(uint8_t buttons, int note) { int bankSize = sizeof(Bank) / sizeof(Bank[0]); patch = (patch + bankSize) % bankSize; - Patch p = Bank[patch]; - loadPatch(p); + FMPatch *p = &Bank[patch]; + FMVoiceLoadPatch(&Chanter, p); oled.clear(PAGE); oled.setFontType(0); oled.setCursor(0, 0); - oled.print(p.name); + oled.print(p->name); oled.setCursor(0, 10); oled.print("Patch "); oled.print(patch); @@ -290,7 +252,7 @@ void loop() { } if (silent) { - noteOff(); + FMVoiceNoteOff(&Chanter); playing = false; } else { // Calculate pitch, and glissando pitch @@ -310,9 +272,9 @@ void loop() { } if (playing) { - setPitch(pitch); + FMVoiceSetPitch(&Chanter, pitch); } else { - noteOn(pitch); + FMVoiceNoteOn(&Chanter, pitch); } playing = true; }