Synth - Starting out - emphasis on Expressive

Davidelvig

Well-known member
There are so many examples of synths in this forum!
I'm browsing with gratitude and bewilderment.

I've built some simple sketches with the GUI tool to good effect.
Now I'd like to build a good sounding one as a first synth for a wind controller.

I can build a great sounding synth in MonoPoly with only :
- a correctly pitched sawtooth wave
- a low-pass filter with "volume" represented as an increase in cutoff frequency
- a little reverb
- a litte chorus

Below is the general structure (modeling the signal flow in Korg's MonoPoly).
I'd welcome comments on my assumptions and questions here:
  • I'll control the frequencies of the Waveforms based on MIDI Note On, pitch bend and portamento in code.
  • I'll control the mixers with code
  • I'll adjust the cutoff frequency and resonance of the "filter" based on one of the MIDI Expression CCs
    • Question: which of the 4 filter options would you recommend I start with to implement a low-pass filter from 0Hz to 20kHz, and allow adding resonance, where the cutoff and resonance are controlled my code
  • Question: Are effects typically done in parallel or series? I expect either could apply, but how best to start?
  • Question: Does the general flow make sense? Signal -> Filter(s) -> Effects -> Output
  • Last Question: What am I missing at a high level?
Thanks
1715723589088.png

1715723948533.png
 
Bearing in mind I've not actually got round to creating a completed synth of my own ... but do have a fairly deep appreciation of the pitfalls of the Audio library, I'd say:
  • Your topology looks like as good a starting point as any
    • perhaps add a "note start" section with a short envelope triggered at note on?
  • Bear in mind you can, with recent Teensyduino versions, re-connect patchcords at runtime, so you might want to consider that for
    • selecting which filter you use
    • changing the effects routing
  • On a related note, a fully disconnected object consumes no CPU - not likely to be an issue for a wind synth, though
  • You haven't put in any modulation sources for the waveforms:
    • increasing modulation depth with breath pressure can be quite effective...
    • ...as can changing PWM duty cycle
  • Consider using DC objects for CC changes, rather than directly changing objects' settings with code
    • changes only take effect every 2.9ms due to the audio update interval of 128 samples
    • they can cause "clicks" if large
    • using AudioSynthWaveformDc::amplitude(level, time) will give a smooth transition to the new value - time only needs to be a few milliseconds, so latency shouldn't be too apparent
    • mixer objects don't provide a gradual change, but a DC object as one input to an AudioEffectMultiplier will do the job
  • Start considering now how you're going to save and load patches! One way might be
    • put patch-level data (waveform octave/detune, shape, mixer settings, patchcord routings...) into members of a class
    • give that class at least save(filename) and load(filename) functions
    • you might want to consider having an optional filesystem parameter, so you can use SD, LittleFS etc
    • human-readable patch files are nice
  • If you have physical control knobs, ensure they "decouple" from the controlled parameter when you load a patch, and only "pick up" as they pass through the patch's setting
 
Some advice on FX. The usual order of FX section is that they are connected one after another (serial, not parallel setup as in your scheme) and reverb comes last. Flanger/chorus first. With your current parallel setup, the reverb would only be applied to dry signal, not chorused one.

So I would advice
Phaser->Chorus/Flanger->Reverb
 
A few comments:

  1. DETUNE: You've got four waveform generators to mimic the m/p. You'll need to purposely detune their pitches a little bit relative to each other in order to achieve that "analog synth" sound. Four oscillators in perfect tune with each other is really boring.
  2. 4-POLE: Be aware that the biquad filter that's you're showing is only a 2-pole filter, whereas the m/p uses a 4-pole filter. To get a four-pole response like the m/p, you'll need to run two biquad in series. For the two filters, you can use the same cutoff frequency and resonance ("Q"). Or, maybe use only *nearly* the same cutoff and resonance values, as perhaps a touch of difference might sound good? Or, probably the best choice is to simply the ladder filter block instead as the ladder filter is already 4-pole and, in theory, it is more akin to the kind of filter used in many analog synths like the m/p.
  3. EFFECTS: Someone else already mentioned that your effects should probably be in series, not parallel. So yeah, I'd do the change that they suggested.
  4. STEREO: Your signal chain is mono, which is fine. But if you wanted to try stereo, you can keep everything the same except in your effects section. There, you'd change to making *two* signal lines come out of your filter (which is legal). One signal line would be like it is now...it'd go from your filter to your phaser+chorus, into a reverb block, and into the first port on the i2s output block. This is your "wet" signal path. Next, add a "dry" path...go from the output of your filter, do NOT include any phase or chorus, go into its own reverb block, and then into the other port on the i2s block. Done! Other stereo setups are fun, too, but this is the simplest.
Chip
 
Last edited:
Bear in mind you can, with recent Teensyduino versions, re-connect patchcords at runtime, so you might want to consider that for
  • selecting which filter you use
  • changing the effects routing
With this simple setup:
AudioInputI2S i2sInput;
AudioMixer4 mixerLeft;
AudioMixer4 mixerRight;
AudioConnection patchCord1(i2sInput, 0, mixerLeft, 0);

I see these options for my AudioConnection patchCord1 in VS Code.

1715790686744.png


connect() accepts no parameters, so I don't see how to assign a new destination.

Likewise, AudioMixer4 mixerLeft;
once instantiated, does not look like it has access its input array.

1715791168761.png

Might I have an old version of the teensy libraries, and how might I check that in VS Code with PlatformIO?

And with the correct libraries, how might I switch the connection destination from mixerLeft to mixerRight?

Thanks!
 
The functions available for audio connection objects are documented on this page of the PJRC site. You'd write something like:
C++:
patchCord1.disconnect(); // disconnect ...
patchCord1.connect(i2sInput, 1, mixerRight, 0); // ...and re-connect to mixerRight, input 0
Note I've now connected the right channel input to the right channel mixer ... this may be what you want, or not!

I know nothing of PlatformIO, so hard to say how you check your version. Someone more knowledgeable than me will be along shortly, knowing this forum :)
 
I know nothing of PlatformIO,
After some search, I found that I could specify a teensy platform version in PlatformIO

in PlatformIO.ini file, in the [env] section
I changed:
platform = teensy
to:
platform = teensy@^5.0.0

A number of installations automatically occurred in VS Code, and after that, my patchCord1.connect() has 3 possible overloads, including the one you suggested.
Thanks a bunch!
 
I've incorporated as much as I can of the feedback above. Here's the next draft.

  • Am I getting it?
  • Any comments/improvements?
1715808277848.png
 
One minor observation: a DC object simply puts out a fixed DC voltage. This fixed DC voltage can be changed by calling the amplitude() function on the object. The DC output voltage can also ramp from one fixed DC level to another fixed DC level within a specified amount of time by calling a different version of the amplitude() function. A DC type object would normally provide a modifying signal to another object (waveform, filter, etc.). Based upon this, I would guess that you probably want either waveform objects (AudioSynthWaveform: generate a given waveform at a given frequency & amplitude), or waveformMod objects (AudioSynthWaveFormModulated: generate a given waveform at a given frequency & amplitude, where either the frequency or the phase of the output can be modulated by another object e.g. LFO) as the inputs to the mixer, rather than DC objects. Personally, I used AudioSynthWaveformModulated objects for everything (LFOs & VFOs) for maximum flexibility.

Good luck & have fun !!

Mark J Culross
KD5RXT
 
Dc objects can also be used with multiply objects for transitions of amplitudes.

As already suggested I would add an envelope after "mixerInputs".

I would use a dry path for the reverb, i.e. from ladder1 to both final mixers. (there is no dry/wet option in the reverb).
(assuming "reverbDry" is a reverb object)
It could also make sense to use a reverb as "send effect", i.e. you have a mixer before the reverb to mix the other effects and the dry ladder output signal.

For stereo output the freeverbs with stereo-output could be an advantage.
 
Great stuff... I have a basic synth driven by my wind controller. The code is below, and I have these symptoms that I expect are simple to address and best addressed early.
  • with low expression levels, I'm hearing the steps with level changes. I expect I need a non-linear mapping of the 128 MIDI expression levels to compress the cutoff frequency changes when in the low range. I would be guessing at this point.
  • Also, I'd like to enter "from nothing" where the lowest expression levels are very soft... not so, yet.
  • I have a low buzz or hum that is pitch-related to the intended note (i.e. higher notes have a higher buzzes) - "aliasing" perhaps, about which, I know little.
Here's the sound (MP3, wav, audacity file).
Teensy 4.1 running through an SGTL to the line-out port.
Line-Out goes to an iRig Quattro interface to a Mac through USB
(buzzes/hums are accentuated through the SGTL's headphone port.)

Any advice?

#include <Audio.h> #include <Wire.h> #include <SPI.h> #include <SD.h> #include <SerialFlash.h> #include <dbMIDICommands.h> // GUItool: begin automatically generated code AudioInputI2S i2sInput; //xy=542.7058715820312,214 AudioSynthWaveform waveform1; //xy=553.7058715820312,261 AudioMixer4 mixerLeft; //xy=728.705810546875,241.99998474121094 AudioFilterBiquad biquad1; //xy=944.8235473632812,256.82354736328125 AudioOutputI2S i2sOutput; //xy=1402.7057495117188,261.9999694824219 AudioConnection patchCord1(i2sInput, 0, mixerLeft, 0); AudioConnection patchCord2(waveform1, 0, mixerLeft, 1); AudioConnection patchCord3(mixerLeft, biquad1); AudioConnection patchCord4(biquad1, 0, i2sOutput, 0); AudioConnection patchCord5(biquad1, 0, i2sOutput, 1); AudioControlSGTL5000 sgtl5000_1; //xy=544.7058715820312,333.99998474121094 // GUItool: end automatically generated code dbAudio audio; dbAudioSynth audioSynth; void dbAudio::setup(void) { AudioMemory(15); sgtl5000_1.enable(); setVolume(headphoneVolume()); sgtl5000_1.inputSelect(AUDIO_INPUT_LINEIN); waveform1.begin(.5, 0, WAVEFORM_SAWTOOTH); } void dbAudio::setVolume(uint8_t vol) { sgtl5000_1.volume((float)headphoneVolume() / 100); } float lastAmplitude = 0; float lastFreq = 0; unsigned long lastAmplitudeTime = 0; #define AMPLITUDE_INTERVAL 10 void dbAudioSynth::consume(const dbMsg& msg){ switch(msg.type()) { case NOTE_OFF: break; case NOTE_ON: _freq = dbMIDI::getFrequency(msg.value1()); break; case POLY_AFTERTOUCH: break; case CONTROL_CHANGE: _ccValues[msg.value1()] = (uint8_t)msg.value2(); if (msg.value1() == 2 || msg.value1() == 11) { _amplitude = msg.value2(); } else { } break; case PROGRAM_CHANGE: break; case CHANNEL_AFTERTOUCH: break; case PITCH_BEND: _pitchBend = ((msg.value1() << 7) + msg.value2()) - 8192; break; case SYSEX: break; default: break; } if (_amplitude != lastAmplitude && millis() - lastAmplitudeTime > AMPLITUDE_INTERVAL) { lastAmplitude = _amplitude; biquad1.setLowpass(0, map(_amplitude, 0, 127, 1, 15000), 0.9); } if (_freq != lastFreq) { lastFreq = _freq; waveform1.frequency(_freq); } Serial.printf("_freq: %3.1f\t_amplitude: %3.2f\n", _freq, (float)_amplitude / 128.0); }
 
Last edited:
@TomChiron note that typically a wind synth doesn’t use an envelope, the amplitude shape is controlled by the player using one of the CCs - looks like either 2 or 11. You might use a velocity controlled envelope to help get an authentic sounding chiff at note on.

Can’t see lastAmplitudeTime getting set … 10ms feels a bit sluggish, though.

map() is probably not your friend going from CC value to cutoff, you’ll need to roll your own transfer function, maybe use some CCs to play with its parameters. To add “from nothing”, put in a multiplier object with audio on one input and a DC object on the other: set the DC amplitude from your expression CC, but again it probably won’t be linear.

The buzz may be the filter cutoff being changed often. The biquad isn’t your best option, looking at the documentation, because it doesn’t have a corner frequency input. Try the state variable or ladder filers, with a DC connected to the frequency control input, and use the amplitude(level,time) call to give smooth rather than step changes.
 
Thanks for this starting place, everyone!
I'm going to open new threads on particular topics introduced here.
Next, is getting the simplest of breath-control-to-filter-cutoff patches rolling.
I'll be trying to mimic what is done in Korg's MonoPoly with the simplest of modifications on a sawtooth wave.
 
This thread inspired me to break out my wind controller and have a go! Here's a super-basic sketch which illustrates a few points made in posts above. I've deliberately not put in any effects, so any gremlins are exposed. In particular, very low levels of breath pressure give a distinctly "lumpy" volume response, so clearly some sort of mapping curve is needed. I may yet be back with a more sophisticated version...
C++:
#include <MIDI.h>
#include <Audio.h>

// GUItool: begin automatically generated code
AudioSynthWaveformDc     dc1;            //xy=186,141
AudioSynthWaveformModulated waveformMod1;   //xy=361,165
AudioSynthWaveformDc     dc2; //xy=389,221
AudioEffectMultiply      multiply1;      //xy=563,177
AudioOutputI2S           i2sOut;           //xy=849,176

AudioConnection          patchCord1(dc1, 0, waveformMod1, 0);
AudioConnection          patchCord2(waveformMod1, 0, multiply1, 0);
AudioConnection          patchCord3(dc2, 0, multiply1, 1);
AudioConnection          patchCord4(multiply1, 0, i2sOut, 0);
AudioConnection          patchCord5(multiply1, 0, i2sOut, 1);

AudioControlSGTL5000     sgtl5000;     //xy=865,230
// GUItool: end automatically generated code

// It's good to see the object types in the Design Tool, but
// for the code it's nicer to have their intended functions
// apparent. Use macros to map one to the other:
#define osc1   waveformMod1
#define bender dc1
#define volctl dc2

MIDI_CREATE_INSTANCE(HardwareSerial, Serial6, MIDI);  // pick your serial hardware port here

//=========================================================================================
#define NOT_PLAYING 255
byte activeNote = NOT_PLAYING;
void myNoteOff(byte channel, byte note, byte velocity)
{
  Serial.printf(" Off: %d -> %d\n", note, velocity); 

  // Yamaha WX5 sends note on before note off during legato playing,
  // so only action a note off if it's the currently active note
  if (note == activeNote)
  {
    myControlChange(0, midi::BreathController, 0); // set breath control to 0 - silence
    activeNote = NOT_PLAYING;
  }   
}

//-----------------------------------------------------------------------------------------
// one octave of note frequencies, starting at middle C / C4 / MIDI note 60
const float notes[] = {261.6256,   277.1826,   293.6648,   311.1270, 329.6276,  349.2282,  369.9944,  391.9954,  415.3047,   440.0000,   466.1638,   493.8833};
void myNoteOn(byte channel, byte note, byte velocity)
{
  Serial.printf("  On: %d -> %d\n", note, velocity);
  activeNote = note;

  // compute required note frequency from MIDI number
  int octave = (note / 12) - 5;
  float freq = notes[note % 12];
  Serial.printf("Freq: %f; octave: %d -> ", freq, octave);
  if (octave < 0) freq = freq / (1 << -octave);
  if (octave > 0) freq = freq * (1 <<  octave);

  osc1.frequency(freq);
  Serial.println(freq);
}

//-----------------------------------------------------------------------------------------
float changeTime_ms = 3.0f; // change parameters over this many milliseconds
void myControlChange(byte channel, byte control, byte value)
{
  Serial.printf("  CC: %d -> %d\n", control, value); 
  switch (control)
  {
    default:
      break;
      
    case midi::BreathController: // breath
    {
      analogWrite(LED_BUILTIN, value);  // breath pressure to LED brightness, for feedback
      volctl.amplitude((float) value / 127.0f, changeTime_ms);
    }
      break;
  }
}

//-----------------------------------------------------------------------------------------
void myPitchChange(byte channel, int pitch)
{
  Serial.printf("bend: %d\n", pitch);

  bender.amplitude((float) pitch / 8192.0f, changeTime_ms);
}

//=========================================================================================
void setup()
{
  while (!Serial) // MUST have USB host connected!
    ;

  AudioMemory(50);   

  Serial.println("Ready");   

  pinMode(LED_BUILTIN, OUTPUT);

  MIDI.setHandleNoteOff(myNoteOff);
  MIDI.setHandleNoteOn(myNoteOn);
  MIDI.setHandleControlChange(myControlChange);
  MIDI.setHandlePitchBend(myPitchChange);
 
  MIDI.begin();

  volctl.amplitude(0.0f);
  osc1.begin(0.95f, 110.0f, WAVEFORM_TRIANGLE);
  osc1.frequencyModulation(1.0f/12); // max pitch bend is 1 semitone

  sgtl5000.enable();
  sgtl5000.volume(0.25f);
}

//=========================================================================================
void loop()
{
  MIDI.read();
}
Points to note:
  • needs a USB serial port open for a flood of monitoring data
  • set to use Serial6 for MIDI data - you may want to change this
  • lots of parameters are hard-coded where you'd normally make them patch-dependent
  • note on/off is a bit WX5-specific, your controller may need different treatment
  • tested with Teensy 4.1 and audio adaptor, Serial + MIDI + Audio, 600MHz
 
Back
Top