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

08 Presets and state

A chord pad. The sound is parameters; the chord shape is a list kept in a Controller.

You will learn

  • factory presets and their metadata
  • user presets and the preset bar
  • a Controller for state that is not a parameter
  • mark_dirty

The code is in examples/08-presets-and-state/. It is included here as it is on disk; cargo test -p example-08-presets-and-state builds it.

//! Example 08: presets, and session state that is not a parameter.
//!
//! A chord pad: one key plays up to four notes, a *shape* such as `[0, 4, 7]` (a major
//! chord: the key, four semitones up, seven up). The sound settings are ordinary
//! parameters. The chord shape is not: it is a *list*, which no parameter holds. It is
//! kept in a [`Controller`], and still behaves like part of the sound:
//!
//! * it is saved with the host's session and restored with it,
//! * it is stored in every preset (user and factory) and loaded back with it,
//! * the web view shows "changed" next to the preset name when it is edited, and
//!   `mark_dirty` tells the host that the project has unsaved changes.
//!
//! **Presets.** A `.apfpreset` file is JSON: parameter values by key, in the units the user
//! sees, plus optional metadata (`type`, `subtype`, `mood`, `description`, `author`) for
//! sound browsers. Factory presets are compiled in (`presets/`, listed in
//! `FACTORY_PRESETS`); the preset bar `<div data-apf-presets>` of the web view saves, lists and
//! deletes the user's own.

use std::sync::atomic::{AtomicI32, AtomicU64, Ordering};
use std::sync::Arc;

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

const PARAMS: &[ParamDef] = &[
    ParamDef::choice("wave", "Waveform", &["Sine", "Saw", "Square", "Triangle"], 1),
    ParamDef::float("attack", "Attack", 1.0, 2000.0, 8.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, -14.0).unit("dB").decimals(1),
];
const WAVE: ParamId = ParamId::of(PARAMS, "wave");
const ATTACK: ParamId = ParamId::of(PARAMS, "attack");
const RELEASE: ParamId = ParamId::of(PARAMS, "release");
const GAIN: ParamId = ParamId::of(PARAMS, "gain");

const UI: &[Asset] = assets!["index.html" => "../ui/index.html"];
const VOICES: usize = 6;
/// Notes in a chord, and the marker of an unused slot.
const NOTES: usize = 4;
const OFF: i32 = i32::MIN;

/// The chord shape: semitones above the key played. The audio thread reads the atomics, the
/// controller writes them, nobody waits.
struct Chord {
    intervals: [AtomicI32; NOTES],
    /// Bumped by every change, so the framework knows when the session or a preset differs.
    revision: AtomicU64,
}

impl Chord {
    fn new() -> Self {
        let chord = Chord { intervals: std::array::from_fn(|_| AtomicI32::new(OFF)), revision: AtomicU64::new(0) };
        chord.set(&[0, 4, 7]);
        chord
    }

    fn set(&self, intervals: &[i32]) {
        for (i, slot) in self.intervals.iter().enumerate() {
            let v = intervals.get(i).map_or(OFF, |&v| v.clamp(-36, 36));
            slot.store(v, Ordering::Relaxed);
        }
        self.revision.fetch_add(1, Ordering::AcqRel);
    }

    fn get(&self) -> Vec<i32> {
        self.intervals.iter().map(|s| s.load(Ordering::Relaxed)).filter(|&v| v != OFF).collect()
    }

    /// Audio thread: the shape without allocating.
    fn notes(&self) -> [i32; NOTES] {
        std::array::from_fn(|i| self.intervals[i].load(Ordering::Relaxed))
    }

    fn to_json(&self) -> String {
        let list: Vec<String> = self.get().iter().map(i32::to_string).collect();
        format!("{{\"intervals\":[{}]}}", list.join(","))
    }

    /// `{"intervals": [0, 4, 7]}`. Anything else is refused, and nothing changes.
    fn load(&self, text: &str) -> Result<(), String> {
        let root = json::parse(text)?;
        let Some(Value::Arr(items)) = root.get("intervals") else { return Err("expected {\"intervals\": [...]}".into()) };
        if items.len() > NOTES {
            return Err(format!("at most {NOTES} notes"));
        }
        let intervals = items.iter().map(|v| v.as_f64().map(|f| f.round() as i32).ok_or("intervals are numbers")).collect::<Result<Vec<_>, _>>()?;
        self.set(&intervals);
        Ok(())
    }
}

struct ChordController {
    chord: Arc<Chord>,
    host: HostHandle,
}

impl Controller for ChordController {
    // Called when the host saves the session, and when a preset is saved.
    fn save(&self) -> String {
        self.chord.to_json()
    }
    // Called when a session or preset is loaded. An error leaves the old state in place.
    fn load(&self, text: &str) -> Result<(), String> {
        self.chord.load(text)
    }
    // A session or preset without chord data (an older version): back to the default.
    fn reset(&self) {
        self.chord.set(&[0, 4, 7]);
    }
    fn revision(&self) -> u64 {
        self.chord.revision.load(Ordering::Acquire)
    }
    // The web view's requests: `apf.plugin.get("chord")` and `apf.plugin.post("chord", {...})`.
    fn request(&self, r: &ControllerRequest) -> ControllerResponse {
        if r.path != "chord" {
            return ControllerResponse::not_found();
        }
        if r.is_post() {
            if let Err(e) = self.chord.load(r.body_text().unwrap_or("")) {
                return ControllerResponse::new(400, "text/plain", e.into_bytes());
            }
            // The chord is not a parameter, so the host cannot see the change by itself.
            self.host.mark_dirty();
        }
        ControllerResponse::json(self.chord.to_json())
    }
}

#[derive(Clone, Copy)]
struct Voice {
    oscs: [Oscillator; NOTES],
    count: usize,
    env: Adsr,
    key: i16,
    held: bool,
    age: u64,
}

const IDLE: Voice = Voice { oscs: [Oscillator::new(); NOTES], count: 0, env: Adsr::IDLE, key: -1, held: false, age: 0 };

pub struct ChordPad {
    voices: [Voice; VOICES],
    counter: u64,
    shape: AdsrShape,
    chord: Arc<Chord>,
    sample_rate: f32,
}

impl Plugin for ChordPad {
    const INFO: PluginInfo = PluginInfo {
        id: "dev.miha.apf.example.presets",
        name: "APF Example 08 Presets",
        vendor: "Miha Smrekar",
        url: "",
        version: env!("CARGO_PKG_VERSION"),
        description: "A chord pad with presets",
        features: &["instrument", "synthesizer", "stereo"],
    };
    const LAYOUT: AudioLayout = AudioLayout::INSTRUMENT_STEREO;
    const PARAMS: &'static [ParamDef] = PARAMS;
    // The text of the preset files, compiled into the plugin. `apf::presets!` works like
    // `include_str!`: paths are relative to this file.
    const FACTORY_PRESETS: &'static [&'static str] =
        apf::presets!["../presets/Major.apfpreset", "../presets/Minor.apfpreset", "../presets/Power.apfpreset"];

    fn new() -> Self {
        Self { voices: [IDLE; VOICES], counter: 0, shape: AdsrShape::default(), chord: Arc::new(Chord::new()), sample_rate: 48_000.0 }
    }

    fn controller(&mut self, host: HostHandle) -> Option<Arc<dyn Controller>> {
        Some(Arc::new(ChordController { chord: self.chord.clone(), host }))
    }

    fn activate(&mut self, config: &ProcessConfig, _params: &Params) {
        self.sample_rate = config.sample_rate as f32;
        self.reset();
    }

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

    fn event(&mut self, event: &Event, _ctx: &ProcessContext) {
        match *event {
            Event::NoteOn { key, .. } => {
                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].age)).unwrap_or(0);
                let v = &mut self.voices[slot];
                *v = IDLE;
                for (osc, &interval) in v.oscs.iter_mut().zip(&self.chord.notes()) {
                    if interval != OFF {
                        osc.set_frequency(midi_to_hz(key as f32 + interval as f32), self.sample_rate);
                        v.count += 1;
                    }
                }
                v.env.start();
                (v.key, v.held, v.age) = (key, true, self.counter);
            }
            Event::NoteOff { key, .. } => {
                for v in self.voices.iter_mut().filter(|v| v.held && v.key == key) {
                    v.held = false;
                    v.env.release();
                }
            }
            _ => {}
        }
    }

    fn process(&mut self, audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus {
        let p = ctx.params;
        self.shape.set(self.sample_rate, p.get_f32(ATTACK), 50.0, 1.0, p.get_f32(RELEASE));
        let wave = Waveform::from_index(p.get_index(WAVE));
        let gain = db_to_gain(p.get_f32(GAIN));
        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()) {
                let env = v.env.tick(&self.shape);
                // `count` notes of equal level: scale so a bigger chord is not much louder.
                let notes: f32 = v.oscs.iter_mut().take(v.count).map(|o| o.tick(wave)).sum();
                sum += notes * env / (v.count.max(1) as f32).sqrt();
            }
            *l = sum * gain;
            *r = sum * gain;
        }
        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(520, 330, UI).background(18, 19, 24)))
    }
}

apf::export_plugin!(ChordPad);

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

    #[test]
    fn the_chord_round_trips_and_bad_text_changes_nothing() {
        let chord = Chord::new();
        assert_eq!(chord.to_json(), "{\"intervals\":[0,4,7]}");
        chord.load("{\"intervals\":[0,3,7,10]}").unwrap();
        assert_eq!(chord.get(), [0, 3, 7, 10]);
        let before = chord.to_json();
        assert!(chord.load("{\"intervals\":[0,1,2,3,4]}").is_err());
        assert!(chord.load("{\"intervals\":\"no\"}").is_err());
        assert!(chord.load("not json").is_err());
        assert_eq!(chord.to_json(), before);
        // Values far out of range are clamped, an empty shape is allowed (a silent key).
        chord.load("{\"intervals\":[100]}").unwrap();
        assert_eq!(chord.get(), [36]);
    }

    #[test]
    fn every_change_bumps_the_revision() {
        let chord = Chord::new();
        let r = chord.revision.load(Ordering::Relaxed);
        chord.set(&[0]);
        assert!(chord.revision.load(Ordering::Relaxed) > r);
    }

    /// Every factory preset names this plugin, describes itself, and carries a chord the
    /// controller accepts: a typo in a preset file is found here, not by a user.
    #[test]
    fn factory_presets_are_complete() {
        let factory = apf::preset::Factory::parse(ChordPad::FACTORY_PRESETS);
        assert_eq!(factory.len(), 3);
        let params = Params::new(PARAMS);
        for preset in factory.presets() {
            assert_eq!(preset.plugin.as_deref(), Some(ChordPad::INFO.id), "{}", preset.name);
            assert!(!preset.meta.category.is_empty() && !preset.meta.description.is_empty(), "{}", preset.name);
            let chord = Chord::new();
            chord.load(preset.controller.as_deref().expect("chord data")).unwrap();
            preset.apply(&params);
        }
        let minor = factory.get("Minor Seventh").unwrap();
        minor.apply(&params);
        assert_eq!(params.get_index(WAVE), 3, "Triangle");
        assert_eq!(params.get_f32(RELEASE), 900.0);
    }
}

Reading it

Factory presets. presets/*.apfpreset are JSON files; apf::presets![...] compiles them into the plugin and FACTORY_PRESETS lists them. A preset names its parameters by key and gives values in the units a person reads. Parameters it does not mention take their defaults, so adding a parameter later does not invalidate old presets. The type, subtype, mood, description and author members describe the sound for browsers; the preset bar groups by type.

Controller data in a preset. The "controller" member is handed to Controller::load after the parameters. Presets without it call reset.

What the controller must provide. save and load round-trip the state as text; revision changes whenever save would (the framework asks often, and calls save only when it moved); reset is the default state. request answers the page; here GET chord and POST chord.

Telling the host. Editing the chord is not a parameter change, so the host would not know the project changed. HostHandle::mark_dirty() does that.

Sharing with the audio thread. The chord is four AtomicI32s. The audio thread copies them at note-on; it never locks.

The tests. A unit test checks that every factory preset names this plugin, has metadata and carries a chord the controller accepts. An integration test saves a user preset into a scratch folder with preset::save_in and reads it back with its metadata and chord.

Try it

  • Add a fourth factory preset, appended last. (Audio Unit hosts number presets by position.)
  • Add a strum parameter that delays each note of the chord by a few milliseconds.
  • Store the chord’s root note too, and see the preset bar mark the sound as changed when you edit it.

Build and test

cargo test -p example-08-presets-and-state
cargo xtask bundle example-08-presets-and-state --install