Patcher: a synth played from MIDI

Patcher: a synth played from MIDI#

Goal: play a four-voice synth from a MIDI keyboard. .notein reads the keyboard, .poly gives every note a voice of its own, and four copies of the voice from Patcher: a voice in a subpatcher make the sound.

Read Patcher: a voice in a subpatcher first: this page uses its CreateVoice function unchanged. The code is compiled and run by the test suite (Tests/patcher/test_patcher_tutorials.cpp), which plays single notes and chords into the patch through the same MIDI input path a keyboard uses, and checks that every note sounds and every release goes quiet.

Note

.notein needs the MIDI device backend, which is built on Windows and Linux by default but not on macOS or Android. There, CreateObject(".notein") returns nullptr and the patch has no MIDI input. See Platform availability.

The patch#

.notein 0              pitch, velocity (0 = release), channel
  |    |
.poly 4 1              voice number, pitch, velocity
  |   |   |
.pack 0 0 0            "voice pitch velocity"
  |
.route 1 2 3 4         strips the voice number
  |    |    |    |
voice voice voice voice
  \    |    |    /
   ~+ -> ~+ -> ~+      sums the four voices
            |
          ~* 0.25 -> ~dac (both channels)
// A four-voice synth played from MIDI input `port` (an index into
// YSE::System().getMidiInDeviceName()). Uses CreateVoice() from the voice
// tutorial.
void BuildMidiSynth(YSE::patcher& patch, const std::string& port) {
  YSE::pHandle* keys = patch.CreateObject(".notein", port); // pitch, velocity, channel
  YSE::pHandle* alloc = patch.CreateObject(".poly", "4 1"); // 4 voices, steal the oldest
  YSE::pHandle* pack = patch.CreateObject(".pack", "0 0 0"); // "voice pitch velocity"
  YSE::pHandle* route = patch.CreateObject(".route", "1 2 3 4");

  patch.Connect(keys, 0, alloc, 0); // pitch
  patch.Connect(keys, 1, alloc, 1); // velocity
  patch.Connect(alloc, 0, pack, 0); // voice number: sent last, so it is the hot one
  patch.Connect(alloc, 1, pack, 1); // pitch
  patch.Connect(alloc, 2, pack, 2); // velocity
  patch.Connect(pack, 0, route, 0);

  // Voice n gets what .route leaves of "n pitch velocity": "pitch velocity".
  // The voices are summed with a chain of ~+, because a signal inlet takes
  // one cord.
  YSE::pHandle* mix = nullptr;
  for (int n = 0; n < 4; n++) {
    YSE::pHandle* voice = CreateVoice(patch);
    patch.Connect(route, n, voice, 0);
    if (mix == nullptr) {
      mix = voice;
    } else {
      YSE::pHandle* sum = patch.CreateObject("~+");
      patch.Connect(mix, 0, sum, 0);
      patch.Connect(voice, 0, sum, 1);
      mix = sum;
    }
  }

  YSE::pHandle* level = patch.CreateObject("~*", "0.25");
  YSE::pHandle* dac = patch.CreateObject("~dac");
  patch.Connect(mix, 0, level, 0);
  patch.Connect(level, 0, dac, 0);
  patch.Connect(level, 0, dac, 1);
}

The keyboard#

.notein 0 listens to MIDI input port 0. The port number is an index into YSE::System().getMidiInDeviceName(); the .midiinfo object lists the ports from inside a patch. When the port does not open (nothing is plugged in), the object stays valid and receives nothing, so the patch loads the same on any machine.

For every note-on and note-off, .notein sends the channel, the velocity and the pitch, in that order (outlets fire right to left). A release arrives as velocity 0, whether the keyboard sent a note-off message or a note-on with velocity 0. So the rest of the patch only has one kind of release to handle.

MIDI input reaches the patch at the start of the next audio block, on the audio thread. MIDI explains the path and its limits.

Voice allocation#

.poly 4 1 keeps a table of four voices. For each note-on it picks a free voice and sends its number (1-4) with the pitch and velocity. For the release of that note it sends the same voice number with velocity 0, so the release reaches the voice that is playing the note. The 1 turns on stealing: a fifth note takes over the voice that has played longest, and .poly sends that voice’s release first.

.poly also fires right to left: velocity, then pitch, then the voice number. .pack 0 0 0 stores the velocity and the pitch in its cold inlets, and the voice number in its hot left inlet sends the finished list voice pitch velocity.

.route 1 2 3 4 looks at the first item and sends the rest out of the matching outlet. So 2 64 100 leaves outlet 1 as 64 100, which is exactly the list a voice takes.

Mixing#

A signal inlet takes one cord, so the four voices are added up with a chain of ~+ before they reach the ~dac. The ~* 0.25 keeps four voices at full velocity from clipping.

Playing it#

YSE::System().init();

YSE::patcher patch;
patch.create(2);
BuildMidiSynth(patch, "0");   // MIDI input port 0

YSE::sound sound;
sound.create(patch);
sound.play();
// ... call YSE::System().update() regularly, and play the keyboard.

To find the right port, list them first:

#if YSE_ENABLE_MIDI_DEVICE
for (unsigned int i = 0; i < YSE::System().getNumMidiInDevices(); i++) {
  std::cout << i << ": " << YSE::System().getMidiInDeviceName(i) << "\n";
}
#endif

When the patcher is cleared or destroyed, .poly releases every voice first, so no note is left hanging.

Things to try#

  • Add a sustain pedal: put a .sustain between .notein and .poly (pitch into inlet 0, velocity into inlet 1, its two outlets into the two inlets of .poly), and wire a .ctlin 0 0 64 (port 0, every channel, controller 64) into its pedal inlet, inlet 2.

  • Wire a .r level into the right inlet of the ~* 0.25 so the host can set the overall level with PassData.

  • Save the patch with DumpJSON. It loads back with its four voices, and plays from the keyboard as soon as it is loaded.

Next#

  • Patcher: presets — store and recall a patch’s settings.

  • MIDI — every MIDI object, input and output, and the note handling objects.

  • MIDI — reference for .notein, .poly and the rest.