Patcher: a step sequencer#

Goal: build an eight-step sequencer in a patch. A .metro sets the pace, a .counter counts the steps, and a .coll holds the note for each step. The host starts and stops it, changes its speed and rewrites steps while it plays.

This tutorial assumes you have worked through Patcher: modular synthesis in code. The code on this page is compiled and run by the test suite (Tests/patcher/test_patcher_tutorials.cpp), which checks that the pattern plays in order and that the patch makes sound.

The patch#

.r run   .r rate
   |        |
 .metro 125           a bang every 125 ms
   |
 .counter -1          0, 1, 2 ...
   |
 .% 8                0 ... 7, 0 ... 7
   |
 .coll pattern  <---  .r edit
   |
 .mtof -> ~saw -> ~lp 1200 -> ~* 0.2 -> ~dac (both channels)

In code:

struct StepSequencer {
  YSE::pHandle* metro;
  YSE::pHandle* counter;
  YSE::pHandle* pattern;
};

StepSequencer BuildStepSequencer(YSE::patcher& patch) {
  // The clock: a bang every 125 ms, a sixteenth note at 120 BPM.
  YSE::pHandle* run = patch.CreateObject(".r", "run"); // 1 starts, 0 stops
  YSE::pHandle* rate = patch.CreateObject(".r", "rate"); // step length in ms
  YSE::pHandle* metro = patch.CreateObject(".metro", "125");
  patch.Connect(run, 0, metro, 0);
  patch.Connect(rate, 0, metro, 1);

  // The step number. .counter adds its step before it sends, so starting it
  // at -1 makes it count 0, 1, 2 ...; folded into 0-7, that is the step.
  YSE::pHandle* counter = patch.CreateObject(".counter", "-1");
  YSE::pHandle* wrap = patch.CreateObject(".%", "8");
  patch.Connect(metro, 0, counter, 0);
  patch.Connect(counter, 0, wrap, 0);

  // The pattern: a step number goes in, the note stored for it comes out.
  YSE::pHandle* pattern = patch.CreateObject(".coll", "pattern");
  YSE::pHandle* edit = patch.CreateObject(".r", "edit"); // "<step> <note>" rewrites a step
  patch.Connect(wrap, 0, pattern, 0);
  patch.Connect(edit, 0, pattern, 0);

  // The sound: a filtered sawtooth at the note's pitch, on both channels.
  YSE::pHandle* mtof = patch.CreateObject(".mtof");
  YSE::pHandle* osc = patch.CreateObject("~saw");
  YSE::pHandle* filter = patch.CreateObject("~lp", "1200");
  YSE::pHandle* level = patch.CreateObject("~*", "0.2");
  YSE::pHandle* dac = patch.CreateObject("~dac");
  patch.Connect(pattern, 0, mtof, 0);
  patch.Connect(mtof, 0, osc, 0);
  patch.Connect(osc, 0, filter, 0);
  patch.Connect(filter, 0, level, 0);
  patch.Connect(level, 0, dac, 0);
  patch.Connect(level, 0, dac, 1);

  return {metro, counter, pattern};
}

The clock#

.metro 125 sends a bang every 125 ms once it is started: a sixteenth note at 120 BPM. Its left inlet starts it with any non-zero number or a bang, and stops it with 0 or stop. Every start sends a bang straight away. A number in the right inlet changes the interval, also while it runs. Both inlets are wired to receivers, so the host controls it by name.

A millisecond .metro runs on the engine’s timer thread, not on the audio thread, so each step is heard at the start of the next audio block. That is fine for a sequencer that plays on its own. When the steps must stay in time with other things (clips, a second sequencer, a tempo that changes), give the metro a beat interval on a domain clock instead: send clock main to its left inlet (main being a clock the host created) and 16n to its right inlet. Time, scheduling and clocks explains the clocks and what each one is accurate to.

The step number#

.counter adds its step (1) to its value and sends the result. Its argument is the value it starts at, so .counter -1 sends 0 on its first bang, then 1, 2 …. .% 8 folds that into the range 0-7, so the pattern repeats every eight steps. To change the pattern length, change the 8. A number in the counter’s right inlet changes the step: send it 2 and the sequencer plays every other step.

A reset message puts the counter back at its start value (-1), so the next bang plays step 0 again. The test on this page uses it to replay the first step.

The pattern#

.coll stores messages at addresses. A list whose first item is a number stores the rest of the list at that address, and a number on its own recalls what is stored there. So "3 63" stores note 63 at step 3, and the step number 3 coming from .% 8 sends 63 on to .mtof.

Fill the pattern by sending one list per step to the .coll:

const int notes[8] = {48, 55, 60, 63, 48, 58, 60, 67};
for (int step = 0; step < 8; step++) {
  seq.pattern->SetListData(0, std::to_string(step) + " " + std::to_string(notes[step]));
}

SetListData delivers the list straight to the object’s inlet, on the calling thread, before it returns. A step that holds nothing sends nothing, so the previous note keeps sounding.

The collection is named pattern. Every .coll pattern in the same patcher shares one collection, so a second .coll pattern elsewhere in the patch can read or edit the same steps. The contents are saved with DumpJSON and come back with ParseJSON. Collections, dictionaries and arrays covers .coll and the other stores.

The sound#

.mtof turns the note into a frequency for the ~saw. The ~lp softens it and the ~* sets the level. One outlet can feed several inlets, so the ~* feeds both channels of the ~dac.

Playing it#

Attach the patcher to a sound and start the metro:

YSE::System().init();

YSE::patcher patch;
patch.create(2);
StepSequencer seq = BuildStepSequencer(patch);
// ... fill seq.pattern as above ...

YSE::sound sound;
sound.create(patch);
sound.play();

patch.PassData(1, "run");      // start
patch.PassData(100, "rate");   // faster: 100 ms per step
patch.PassData(0, "run");      // stop

While it plays, rewrite a step from the host through the edit receiver:

patch.PassData(std::string("0 36"), "edit");   // step 0 now plays note 36

PassData queues the value, and it reaches the .coll at the start of the next audio block. .coll is safe to write on the audio thread: it never allocates or waits there.

Things to try#

  • Replace everything after the .coll with the voice from Patcher: a voice in a subpatcher, and store a velocity with each note ("3 63 100"). The .coll then sends the list 63 100, which is exactly what the voice takes, and a step stored with velocity 0 ("5 63 0") is a rest.

  • Build a second .metro → .counter → .coll pattern chain at a different rate. Both .coll objects share one pattern, so a second line reads the same notes at its own pace.

Next#