Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

09 MIDI and learn

A small synth that behaves with keyboards and controllers.

You will learn

  • the sustain pedal
  • MIDI learn, remote controls and why the first parameters matter
  • host automation and where it lands

The code is in examples/09-midi-and-learn/. It is included here as it is on disk; cargo test -p example-09-midi-and-learn builds it.

//! Example 09: working with MIDI controllers and hosts.
//!
//! * **Sustain pedal** (MIDI controller 64): while it is down, released keys keep sounding.
//!   You implement this one yourself, below.
//! * **MIDI learn**: nothing to write. In the web view, right-click a control (anything with
//!   `data-param`), choose "MIDI learn", move a knob on your controller: from then on that
//!   knob moves the parameter, in every host and format, and the host records it as
//!   automation. The framework applies learned controllers *before* your plugin sees the
//!   event, so `event` is never called for them; mappings are saved with the session.
//!   Controllers that must keep their meaning (sustain pedal, bank select, data entry,
//!   channel mode messages) cannot be learned.
//! * **Remote controls**: `REMOTE_CONTROLS` lists pages of eight parameters. CLAP hosts
//!   such as Bitwig and REAPER put a page on the knobs of a hardware controller. (Logic's
//!   Smart Controls take the *first* parameters of the list: that is another reason to
//!   keep the important ones at the top of `PARAMS`.)
//! * **Host automation**: a host changes parameters while it plays. The framework splits the
//!   audio block at every such change, so `process` always sees the value as of its first
//!   sample, and a parameter change lands on its exact sample.

use apf::prelude::*;
use apf_webview::{assets, Asset, WebView};

const PARAMS: &[ParamDef] = &[
    // The knobs people reach for first come first.
    ParamDef::float("cutoff", "Cutoff", 100.0, 20_000.0, 4000.0).unit("Hz").curve(Curve::Log).decimals(0),
    ParamDef::float("attack", "Attack", 1.0, 2000.0, 5.0).unit("ms").curve(Curve::Log).decimals(0),
    ParamDef::float("release", "Release", 5.0, 3000.0, 400.0).unit("ms").curve(Curve::Power(2.0)).decimals(0),
    ParamDef::float("gain", "Gain", -48.0, 6.0, -12.0).unit("dB").decimals(1),
    ParamDef::choice("wave", "Waveform", &["Sine", "Saw", "Square", "Triangle"], 1),
];
const CUTOFF: ParamId = ParamId::of(PARAMS, "cutoff");
const ATTACK: ParamId = ParamId::of(PARAMS, "attack");
const RELEASE: ParamId = ParamId::of(PARAMS, "release");
const GAIN: ParamId = ParamId::of(PARAMS, "gain");
const WAVE: ParamId = ParamId::of(PARAMS, "wave");

const UI: &[Asset] = assets!["index.html" => "../ui/index.html"];
const VOICES: usize = 8;

#[derive(Clone, Copy)]
struct Voice {
    osc: Oscillator,
    env: Adsr,
    key: i16,
    /// The key is down. A sounding voice whose key is up is held by the pedal, or releasing.
    held: bool,
    age: u64,
}

const IDLE: Voice = Voice { osc: Oscillator::new(), env: Adsr::IDLE, key: -1, held: false, age: 0 };

pub struct MidiSynth {
    voices: [Voice; VOICES],
    counter: u64,
    shape: AdsrShape,
    gain: Smoother,
    lowpass: OnePole,
    pedal: bool,
    sample_rate: f32,
}

impl MidiSynth {
    fn note_on(&mut self, key: i16) {
        // Striking a key that the pedal is still holding: the old note gives way, as on a piano.
        for v in self.voices.iter_mut().filter(|v| !v.held && v.key == key && v.env.stage != AdsrStage::Release) {
            v.env.release();
        }
        self.counter += 1;
        let slot = self.voices.iter().position(|v| v.env.is_idle()).or_else(|| (0..VOICES).min_by_key(|&i| (self.voices[i].held, self.voices[i].age))).unwrap_or(0);
        let mut osc = Oscillator::new();
        osc.set_frequency(midi_to_hz(key as f32), self.sample_rate);
        let mut env = Adsr::IDLE;
        env.start();
        self.voices[slot] = Voice { osc, env, key, held: true, age: self.counter };
    }

    fn note_off(&mut self, key: i16) {
        let pedal = self.pedal;
        for v in self.voices.iter_mut().filter(|v| v.held && v.key == key) {
            v.held = false;
            // With the pedal down the note keeps sounding; it ends when the pedal comes up.
            if !pedal {
                v.env.release();
            }
        }
    }

    fn set_pedal(&mut self, down: bool) {
        if self.pedal && !down {
            for v in self.voices.iter_mut().filter(|v| !v.held) {
                v.env.release();
            }
        }
        self.pedal = down;
    }
}

impl Plugin for MidiSynth {
    const INFO: PluginInfo = PluginInfo {
        id: "dev.miha.apf.example.midi",
        name: "APF Example 09 MIDI",
        vendor: "Miha Smrekar",
        url: "",
        version: env!("CARGO_PKG_VERSION"),
        description: "A synth with sustain pedal, remote controls and MIDI learn",
        features: &["instrument", "synthesizer", "stereo"],
    };
    const LAYOUT: AudioLayout = AudioLayout::INSTRUMENT_STEREO;
    const PARAMS: &'static [ParamDef] = PARAMS;
    // One page of eight knobs; "" leaves a knob unassigned.
    const REMOTE_CONTROLS: &'static [RemotePage] =
        &[RemotePage { section: "", name: "Main", params: ["cutoff", "attack", "release", "gain", "wave", "", "", ""] }];

    fn new() -> Self {
        Self {
            voices: [IDLE; VOICES],
            counter: 0,
            shape: AdsrShape::default(),
            gain: Smoother::new(0.25),
            lowpass: OnePole::new(),
            pedal: false,
            sample_rate: 48_000.0,
        }
    }

    fn activate(&mut self, config: &ProcessConfig, params: &Params) {
        self.sample_rate = config.sample_rate as f32;
        // Automation moves `gain` in steps; the smoother turns each step into a short ramp.
        self.gain.set_time(config.sample_rate, 10.0);
        self.gain.reset(db_to_gain(params.get_f32(GAIN)));
        self.reset();
    }

    fn reset(&mut self) {
        self.voices = [IDLE; VOICES];
        self.lowpass.reset();
        self.pedal = false;
    }

    fn event(&mut self, event: &Event, _ctx: &ProcessContext) {
        match *event {
            Event::NoteOn { key, .. } => self.note_on(key),
            Event::NoteOff { key, .. } => self.note_off(key),
            // Controllers arrive as 0..1: the pedal is down from the middle of the range up.
            Event::ControlChange { cc: cc::SUSTAIN_PEDAL, value, .. } => self.set_pedal(value >= 0.5),
            // "All notes off": every key and the pedal let go.
            Event::ControlChange { cc: cc::ALL_NOTES_OFF, .. } => {
                self.pedal = false;
                self.voices.iter_mut().for_each(|v| {
                    v.held = false;
                    v.env.release();
                });
            }
            _ => {}
        }
    }

    fn process(&mut self, audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus {
        let p = ctx.params;
        // `process` is called once per piece between events, so these reads are always the
        // values in force at the first sample of the piece.
        self.shape.set(self.sample_rate, p.get_f32(ATTACK), 50.0, 1.0, p.get_f32(RELEASE));
        self.gain.set_target(db_to_gain(p.get_f32(GAIN)));
        self.lowpass.set_cutoff(p.get_f32(CUTOFF), self.sample_rate);
        let wave = Waveform::from_index(p.get_index(WAVE));

        let Some((left, right)) = audio.stereo() else { return ProcessStatus::Error };
        for (l, r) in left.iter_mut().zip(right.iter_mut()) {
            let mut sum = 0.0;
            for v in self.voices.iter_mut().filter(|v| !v.env.is_idle()) {
                sum += v.osc.tick(wave) * v.env.tick(&self.shape);
            }
            let out = self.lowpass.lowpass(sum) * self.gain.tick();
            *l = out;
            *r = out;
        }
        if self.voices.iter().any(|v| !v.env.is_idle()) {
            ProcessStatus::Continue
        } else {
            ProcessStatus::Sleep
        }
    }

    fn view() -> Option<Box<dyn View>> {
        Some(Box::new(WebView::new(460, 260, UI).background(18, 19, 24)))
    }
}

apf::export_plugin!(MidiSynth);

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_remote_page_names_real_parameters() {
        apf::param::validate_pages(MidiSynth::REMOTE_CONTROLS, PARAMS).unwrap();
        apf::param::validate(PARAMS).unwrap();
    }
}

Reading it

The sustain pedal is controller 64. Down: keys that are released stay sounding. Up: every note whose key is not down is released. A key struck again while the pedal holds it first releases the old note, as on a piano, so notes do not pile up. note_off and set_pedal are the whole logic.

MIDI learn needs no plugin code. The user right-clicks a bound control in the web view, chooses MIDI learn and moves a knob. From then on that controller moves the parameter, in every format, as a host gesture, and the mapping is saved with the session. APF applies learned controllers before the plugin sees the event, so event is never called for them. A few controllers cannot be learned because they must keep their meaning (the sustain pedal, bank select, data entry, the channel mode messages).

MIDI learn has no plugin code to test here, and starting it is a web view action that a test host does not reach. The framework’s own tests cover it: midi_learn_takes_the_next_controller in crates/apf/src/view.rs (what can be learned, replacing, cancelling) and a_learned_controller_moves_its_parameter_and_tells_the_host in examples/demo-synth/tests/clap_host.rs (a learned controller moves its parameter at its sample, the host gets the gesture, and the mapping survives a saved session).

Remote controls. REMOTE_CONTROLS lists pages of eight parameter keys. CLAP hosts that implement the remote-controls extension put a page on a hardware controller’s knobs (APF’s remote pages are not yet checked in a host that uses them). Without pages the first eight parameters are used. Logic’s Smart Controls also take the first parameters, so keep the ones people want at the top of PARAMS.

Host automation. A host writes parameter changes into the block with a sample position. APF splits the block there, so process reads the new value and the change lands on its exact sample. A parameter that is read once per block jumps at that sample, so smooth the ones that would click (the gain here). The test compares a render with and without automation and finds them identical up to the sample of the change.

Try it

  • Add a sostenuto pedal (controller 66): it holds only the notes down when it was pressed.
  • Respond to Event::ChannelPressure by opening the cutoff.
  • Add a second remote page.

Build and test

cargo test -p example-09-midi-and-learn
cargo xtask bundle example-09-midi-and-learn --install