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

Introduction

APF is a framework for writing audio plugins in Rust. A plugin is an instrument or an effect that you load into a music program (a host, such as Logic Pro or REAPER). With APF you write one type that implements the Plugin trait and, if you want a window, a web page. The build turns them into a CLAP, a VST3, an Audio Unit (v2 and v3) and a standalone app. APF is MIT licensed, and so are its dependencies.

  sound   Rust           fn process(&mut self, audio, ctx) { ... }
  window  HTML + JS      <input type="range" data-param="gain">

Who this book is for

You know some Rust and have heard of audio plugins. You do not need to know the plugin formats, MIDI details or DSP (digital signal processing, the maths of sound); the book explains each when it first matters. A host is nice to have but not needed to learn: every example’s test plays the plugin in memory.

How the book is organised

  1. Guide. Install and first plugin gets a plugin into your host and starts a project of your own. The chapters after it take the ideas in the order you meet them, from parameters to hot reload.
  2. The example ladder. Ten small plugins in the examples/ folder, one chapter each. Each is complete, has a test that renders audio without a sound card, and adds one idea to the one before. The ladder is the overview.
  3. Reference. How APF works inside, the web view’s script API, the DSP building blocks, Windows, the repository’s tools, test results, licences, a comparison with other frameworks, and the known issues.

Words used in this book

  • Host: the program that loads plugins (a DAW such as Logic or REAPER).
  • Format: a plugin interface a host loads: CLAP, VST3, Audio Unit. APF builds them all from one crate.
  • Block: the few hundred samples of audio a host asks for at a time.
  • Audio thread: the thread that calls process.
  • Sample rate: samples per second, usually 44 100 or 48 000.
  • Parameter: a value the user or the host’s automation changes.
  • Voice: the state of one sounding note.
  • Session: everything a host saves for one plugin instance in a project.

Install and first plugin

This chapter builds an example plugin, puts it into your host, and starts a project of your own. It is written for macOS; on Windows the same commands build CLAP and VST3, with the differences in Windows.

What you need

  • Rust 1.85 or newer.

  • A C++ compiler and CMake 3.24 or newer, for the plugin formats:

    xcode-select --install     # Xcode's command line tools
    brew install cmake         # or the installer from https://cmake.org/download
    
  • Git, and a network connection for the first build of each plugin.

  • Only for the Audio Unit v3: Xcode itself, from the App Store. Everything else builds without it.

Run an example’s test

git clone https://github.com/mihasm/apf && cd apf
cargo test -p example-02-gain

The test loads the gain plugin through the same C interface a host uses, plays audio through it in memory and checks the result. No sound card is opened. This is how you will check most changes: in seconds, without a host.

Build it and install it

cargo xtask bundle example-02-gain --install

What cargo xtask is

cargo xtask is APF’s build tool. It is a small Rust program in the repository’s xtask/ folder, and cargo xtask ... is an alias in .cargo/config.toml for cargo run --package xtask -- ...: Cargo compiles the program the first time and runs it, so there is nothing to install. (Keeping a project’s build scripts in Rust like this is a common Rust convention, called the xtask pattern.)

It exists because Cargo alone cannot make a plugin. Cargo turns your crate into a Rust library, but a host loads a bundle: a folder in a format’s own layout, with its own metadata, and for VST3 and Audio Unit with C++ code that translates between the format and your plugin. cargo xtask bundle does every step:

  1. Cargo builds your plugin crate as a static library.
  2. CMake builds the C++ project in wrapper/, which links that library into each format with clap-wrapper: CLAP, VST3, Audio Unit v2 and a standalone app, and the Audio Unit v3 with Xcode when you ask for it.
  3. The bundles are copied to dist/release/ and signed for this Mac.
  4. With --install, they are copied to ~/Library/Audio/Plug-Ins/, where hosts look.

Its other commands:

CommandWhat it does
cargo xtask install <plugin>copies what bundle built to the plugin folders
cargo xtask uninstall <plugin>removes it from there again
cargo xtask new <name>starts a project of your own (below)
cargo xtask hot <plugin> --watchswaps each saved change into a running host (Hot reload)
cargo xtask validate <plugin>runs the third-party validators (Testing)
cargo xtask --helpevery command and option

The plugin’s name is its package name in Cargo.toml (here example-02-gain), not the name a host shows.

How long it takes

The first build on a machine downloads the plugin SDKs (about 45 MB). The first build of each plugin downloads clap-wrapper (5 MB) and compiles both: 20 to 40 seconds on an M2. A rebuild after a change takes a few seconds. A debug build (--debug) and the AUv3 have build folders of their own, so their first build costs the same again. The C++ compiler output goes to a log file, named if the build fails; --verbose shows it as it runs.

The plugin files are in dist/release/:

APF Example 02 Gain.clap         CLAP
APF Example 02 Gain.vst3         VST3
APF Example 02 Gain.component    Audio Unit v2
APF Example 02 Gain.app          standalone app (not installed)

Hear it

Start your host, or rescan its plugins if it is running. Put APF Example 02 Gain (vendor Miha Smrekar) on a track that plays audio:

  • REAPER: FX on the track, then search for “APF”. REAPER lists the CLAP, VST3 and AU versions; any of them works.
  • Logic Pro: an Audio FX slot, then Audio Units > Miha Smrekar > APF Example 02 Gain. Logic checks a new Audio Unit when it starts.

The gain plugin has no window of its own, so the host shows its one parameter, Gain, as a plain slider. Move it and the track gets louder or quieter. For an instrument, build example-01-sine the same way, put it on an instrument track and play a note.

Start your own plugin

cargo xtask new my-synth --vendor "Ann Example" --id-prefix org.annexample --manufacturer AnEx
cd ../my-synth
cargo test
cargo xtask bundle --install

new writes a project next to the APF repository, made from example 01 (a sine instrument): its own Cargo workspace that depends on apf by path, with your names. Its own .cargo/config.toml points cargo xtask at APF’s xtask/, so in its folder cargo xtask builds that project, and the plugin’s name can be left out. The plugin files land in ../my-synth/dist/release/.

Choose the three options now. --vendor is the name hosts show. --id-prefix makes the plugin’s id (org.annexample.my-synth), and --manufacturer is your four-character Audio Unit code (at least one capital letter, the same for all your plugins). Hosts store the id and the Audio Unit codes in every saved project, so they must not change once someone uses the plugin. Without the options you get placeholders (com.example, Xmpl, “Your Name”); change them later in Cargo.toml and src/lib.rs. Hosts and formats explains each setting.

What a plugin looks like

A complete gain effect (the version in examples/02-gain adds smoothing):

#![allow(unused)]
fn main() {
extern crate apf;
use apf::prelude::*;

const PARAMS: &[ParamDef] = &[ParamDef::float("gain", "Gain", -60.0, 12.0, 0.0).unit("dB")];
const GAIN: ParamId = ParamId::of(PARAMS, "gain");

struct Gain;

impl Plugin for Gain {
    const INFO: PluginInfo = PluginInfo {
        id: "com.example.gain", name: "Gain", vendor: "Example", url: "", version: "0.1.0",
        description: "A gain control", features: &["audio-effect", "stereo"],
    };
    const LAYOUT: AudioLayout = AudioLayout::EFFECT_STEREO;
    const PARAMS: &'static [ParamDef] = PARAMS;

    fn new() -> Self { Gain }

    fn process(&mut self, audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus {
        let gain = db_to_gain(ctx.params.get_f32(GAIN));
        for channel in audio.channels_mut() {
            for sample in channel { *sample *= gain; }
        }
        ProcessStatus::Continue
    }
}

apf::export_plugin!(Gain);
}
  • PARAMS lists the parameters once, as a constant. ParamId::of gives a handle that is checked when the program compiles.
  • INFO is what the host shows. The id must never change after release.
  • LAYOUT says what audio the plugin takes. EFFECT_STEREO is a stereo input and a stereo output; the output already holds a copy of the input, so an effect changes it in place.
  • process runs on the audio thread for every block of audio.
  • export_plugin! makes the type a plugin in every format.

The next chapters take these apart. Or go straight to the example ladder. To try changes in a running host without restarting it, see Hot reload.

Parameters

A parameter is a value the user or the host’s automation changes while the plugin runs: a volume, a cutoff frequency, a waveform. You list them once, as a constant:

#![allow(unused)]
fn main() {
extern crate apf;
use apf::prelude::*;

const PARAMS: &[ParamDef] = &[
    ParamDef::float("cutoff", "Cutoff", 20.0, 20_000.0, 1000.0).unit("Hz").curve(Curve::Log),
    ParamDef::choice("mode", "Mode", &["Low-pass", "High-pass"], 0),
];
// A typed handle. It is a compile error if the key is missing from the list.
const CUTOFF: ParamId = ParamId::of(PARAMS, "cutoff");
const MODE: ParamId = ParamId::of(PARAMS, "mode");

struct Filter;

impl Plugin for Filter {
    const INFO: PluginInfo = PluginInfo {
        id: "com.example.filter", name: "Filter", vendor: "Example", url: "", version: "0.1.0",
        description: "A filter", features: &["audio-effect", "filter", "stereo"],
    };
    const LAYOUT: AudioLayout = AudioLayout::EFFECT_STEREO;
    // Hand the list to the framework.
    const PARAMS: &'static [ParamDef] = PARAMS;

    fn new() -> Self { Filter }

    fn process(&mut self, _audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus {
        // Read at the top of `process`, once per block.
        let cutoff_hz = ctx.params.get_f32(CUTOFF);      // in Hz, as the user sees it
        let high_pass = ctx.params.get_index(MODE) == 1; // the index of the chosen option
        let _ = (cutoff_hz, high_pass);
        ProcessStatus::Continue
    }
}

apf::export_plugin!(Filter);
}

From that list APF builds the lock-free value store the audio thread reads, the text hosts display (“1.00 kHz”) and its parsing, host automation, saved sessions, presets and the controls of the web view.

Kinds

KindConstructorRead with
continuousParamDef::float(key, name, min, max, default)params.get_f32(ID)
whole numberParamDef::int(key, name, min, max, default)params.get_f32(ID)
switchParamDef::bool(key, name, default), ParamDef::bypass(key)params.get_bool(ID)
listParamDef::choice(key, name, &[options], default)params.get_index(ID)

ParamDef::bypass is a switch that hosts connect their own bypass button to.

Add details to a definition with .unit("Hz") (shown after the number and understood when the user types one), .decimals(n), .curve(..), .module("Group") (a group name in hosts that show groups), .not_automatable() (hosts cannot record it) and .hidden(). Example 03 shows every one.

A parameter’s value is always read in the unit the user sees: Hz, dB, the option index. A host, by contrast, sees every continuous parameter as 0 to 1, so that its automation lane follows the parameter’s curve:

  • Curve::Linear: the slider position is proportional to the value.
  • Curve::Log: equal ratios take equal distances. Use it for frequencies and times.
  • Curve::Power(e): more resolution at the quiet end.

Reading values

Read parameters at the top of process, once per block. The framework splits the block at every change a host sends, so the value you read is the value in force at the block’s first sample, and automation lands on its exact sample. Smooth a value that would otherwise jump, with a Smoother: see example 02.

The three rules

  1. Never rename a key. The id a host stores in projects and automation lanes is a 32-bit hash of the key. A different key is a different parameter, and every saved project loses that setting. The display name may change freely.
  2. Only append. Audio Units list parameters by position, and Logic keys its automation and Smart Controls to that order. Add new parameters at the end of the list. Never insert in the middle, never delete (use .hidden() to retire one), and put the important ones first: they are what a controller’s first eight knobs reach.
  3. Never change what a stored value means. If a saved project says gain = -6, it must still mean -6 dB. If you need different behaviour, add a new parameter whose default reproduces the old behaviour. The demo synth added its attack, decay and sustain this way, and a test plays a session saved by the first version and compares it sample by sample with what the first version produced.

Example 03 has a test that pins the ids of its parameters. Copy that test into your plugin on the day you release it.

Options of a choice follow the same rule: appending one at the end later is safe, reordering them is not.

Sessions

The host saves the plugin’s state with the project and gives it back later. APF stores the parameter values by id. A parameter that a session lacks (it was saved by an older version) gets its default; a parameter the session has but the plugin lacks (a newer version) is ignored. So projects load in both directions. State that is not a number is the job of a controller.

Audio and MIDI

A plugin does two things: it turns audio blocks into audio blocks (process), and it reacts to notes and controllers (event). Both are called by the host on the audio thread, so both follow the rules in What is real-time safe.

Audio

process(&mut self, audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus is called for every block. AudioBuffer is the output of one block (or one piece of it, see below).

  • For an instrument it starts silent; you add your voices to it.
  • For an effect it holds a copy of the input; you change it in place.

Reach the channels with audio.stereo() (left and right), audio.channel(i) or audio.channels_mut(), and audio.len() for the number of samples. ctx carries the parameters (ctx.params), the sample rate and the host’s transport.

LAYOUT says which ports the plugin has:

LayoutMeaning
AudioLayout::INSTRUMENT_STEREOno audio in, stereo out, notes in
AudioLayout::EFFECT_STEREOstereo in, stereo out, no notes
your own AudioLayout { main_input, main_output, note_input }any channel count; main_input: 0 for none

An instrument that also takes audio, or an effect that is controlled by notes, sets both: AudioLayout { main_input: 2, main_output: 2, note_input: true } gives a stereo input next to the notes in every format. The output starts as a copy of the input, and the plugin adds its voices to it. For an Audio Unit that is an effect controlled by MIDI, set au-type = "aumf" in the packaging metadata and make audio-effect the first feature in INFO (Hosts and formats). Validation lists what was checked.

What process returns tells the host what to do next: Continue (call me again), ContinueIfNotQuiet, Tail (a reverb or delay tail is still sounding), Sleep (nothing to do until the next event; the host may stop calling) or Error (the host may mute the plugin).

Events arrive at their sample

A host sends a note-on “at sample 100 of this block”. APF cuts the block into pieces at every event: process is called for samples 0 to 100, then event with the note-on, then process for the rest. Your process never has to look at timestamps, and the note starts on time. This is also why process may be called with very short blocks: do not assume a fixed size. Parameter changes from automation are applied the same way.

#![allow(unused)]
fn main() {
extern crate apf;
use apf::prelude::*;
struct Synth;
impl Synth {
fn event(&mut self, event: &Event, ctx: &ProcessContext) {
    match *event {
        Event::NoteOn { key, velocity, .. } => { /* start a voice */ }
        Event::NoteOff { key, .. } => { /* release it */ }
        Event::PitchBend { value, .. } => { /* -1..1 */ }
        _ => {}
    }
}
}
}

Event decodes the common messages, whichever way the host sends them:

VariantMeaning
NoteOn, NoteOff, Chokekey (MIDI note number, 69 is A4 = 440 Hz), channel, velocity 0 to 1
ControlChangea MIDI controller, value 0 to 1; apf::cc names the usual numbers (mod wheel, sustain pedal, all notes off)
PitchBendthe wheel, -1 to 1; the plugin applies its own bend range
ChannelPressurechannel aftertouch, 0 to 1
NoteExpressiona per-note expression from a CLAP host
Midiany other message as raw bytes (program change, poly aftertouch, …)

A pitch bend arrives as PitchBend whether the host sent it as MIDI or, in the Audio Units built by clap-wrapper, as a channel-wide tuning expression (fixed at plus or minus two semitones; the framework turns it back into the wheel position, so your bend range applies). A key or channel of -1 means “all”.

Transport

ctx.transport is the host’s transport at the start of the piece being processed: whether it is playing or recording, the tempo, the song position in beats and the time signature (each Option where the host may not know). The framework moves the song position on by the frames before each piece, so a clock that follows the host lands on its exact sample.

Controllers with knobs

Hardware controllers with knobs reach a plugin in three ways, from the most automatic:

  • Logic (keyboards and other controllers with a Logic integration, encoders in “Plugin” mode) puts the knobs on the track’s Smart Controls, which Logic maps to the first parameters in PARAMS order. Put the important ones first.
  • CLAP hosts that implement the remote-controls extension use the plugin’s remote-control pages (Plugin::REMOTE_CONTROLS, pages of eight parameter keys; without pages, the first eight parameters are used). Not yet checked in such a host.
  • Anything else: MIDI learn in the web view. Right-click a control, choose MIDI learn, move a knob. That controller (on any channel) moves the parameter from then on, in every format, as a host gesture that hosts record as automation. Mappings are saved with the session. A learned controller never reaches event: the framework applies it first. A few controllers cannot be learned because they must keep their meaning: the sustain pedal, bank select, data entry and the channel mode messages.

Example 09 shows all three.

What is real-time safe

A host asks your plugin for audio many times a second: 256 samples at 48 kHz is a request every 5.3 milliseconds, and the answer must be ready in a fraction of that time, every time. If one block arrives late the listener hears a click or a dropout. The thread that does this is the audio thread, and a few rules keep it on time.

The rules

On the audio thread (process, event, reset), never:

  • allocate or free memory (Vec::new followed by a push, Box::new, String, format!, vec![...], cloning anything that owns heap memory). The allocator may take a lock or ask the operating system for memory, and neither has a time limit.
  • lock a Mutex or RwLock that another thread may hold. If the other thread is descheduled while holding it, the audio thread waits as long as that takes.
  • wait, sleep, do file or network I/O, or print.
  • call anything that does the above without knowing. This is the hard part: a library function that “just works” may allocate.

What is fine: arithmetic, arrays that already exist, atomics, Copy structs, loops, reading parameters, and the helpers in apf::dsp.

How to live by the rules

Allocate before audio starts. Plugin::new and Plugin::activate run on the main thread and may allocate. A delay line needs its memory? Create it there (DelayLine::new). activate is told the largest block the host will use.

Use fixed-size storage for voices. Example 04 keeps [Voice; 8], a plain array of Copy structs. Starting a note overwrites a slot. When all slots are busy a note steals one; nothing grows.

Share numbers with atomics. Another thread (the web view, a controller) hands the audio thread a value through an AtomicU32 or AtomicF64; the audio thread hands one back the same way. Example 07’s meter is the pattern.

Send events through a queue that cannot block. apf::queue::SpscQueue is a bounded single-producer single-consumer queue. A full queue refuses the push instead of waiting, and the sender decides what to drop. The framework’s own web view-to-audio path is one.

Watch for hidden allocation. format!, .to_string(), Vec::push beyond capacity, Box<dyn Trait> creation, HashMap growth, String building, and logging macros all allocate. If you need to tell the user something, set an atomic flag and let the main thread report it.

Panics

A panic in process must not take down the host. APF catches it at the C boundary, mutes the instance and reports an error. That is a safety net, not a design: an unwinding panic also allocates.

Denormals

Floating point numbers very close to zero (below about 1e-38) are denormal, and many CPUs handle them hundreds of times slower. A reverb tail or a filter decaying to silence passes through them. APF turns the CPU’s flush-to-zero mode on for the duration of every process call and restores the host’s setting afterwards, so inside a plugin this is mostly taken care of. See example 06 for what to do where you need an exact zero.

Proving it: the counting allocator

Rules that nobody checks are forgotten. Every example has a test that installs a counting allocator, plays notes and automation through the plugin, and fails if process allocated or freed anything:

extern crate apf_test;
#[global_allocator]
static ALLOCATOR: apf_test::CountingAllocator = apf_test::CountingAllocator;

fn main() {
// ... play the plugin with Instance::process ...
assert_eq!(apf_test::allocations_in_process(), 0, "process() allocated");
}

The allocator counts only while the plugin’s process is running on the test’s thread, so setup code and the test’s own buffers are not blamed. See Testing without a host. Copy the test into your own plugin; it will save you from the day a harmless-looking change adds a Vec.

The web view

A plugin’s web view is a web page. APF shows it with the system’s browser engine (WKWebView on macOS, WebView2 on Windows) as a child of the window the host provides. You write HTML, CSS and JavaScript; the page is compiled into the plugin and served from memory, so the plugin is still one file and works offline.

Add a web view

use apf_webview::{assets, Asset, WebView};

const UI: &[Asset] = assets!["index.html" => "../ui/index.html"];   // path relative to this file

fn view() -> Option<Box<dyn View>> {          // in `impl Plugin for ...`
    Some(Box::new(WebView::new(420, 240, UI)))   // width and height in logical pixels
}

The plugin crate depends on apf-webview as well as apf. ui/index.html loads the bridge library, which defines window.apf:

<script src="/__apf/apf.js"></script>

Example 07 is a complete working web view.

Bindings without code

Put an attribute on an element and it follows a parameter in both directions:

<input type="range" data-param="gain">          <!-- a slider bound to the parameter "gain" -->
<select data-param="wave"></select>             <!-- options filled in from the parameter -->
<input type="checkbox" data-param="mute">
<span data-param-name="gain"></span>            <!-- the parameter's name -->
<output data-param-text="gain"></output>        <!-- its value as text: "-6.0 dB" -->
<small data-param-cc="gain"></small>            <!-- the MIDI controller learned for it -->
<div data-apf-presets></div>                    <!-- a preset bar -->
<div data-apf-resize-grip></div>                <!-- a corner grip for resizable web views -->

The value of data-param must be a parameter’s key. (Example 07 has a unit test that checks every key in the page.) You get these without writing anything:

  • Dragging a control tells the host that a gesture started and ended, so automation recording and touch modes work.
  • Double-click resets to the default.
  • Right-click opens a menu with MIDI learn, forget, and the default value. After learning, a controller moves the parameter in every format (see Audio and MIDI).

Beyond parameters

For anything that is not a parameter (a level meter, a list of chord notes, a tuning table) the page talks to the plugin’s controller:

const peaks = await apf.plugin.get("meter");            // GET  /__apf/plugin/meter
await apf.plugin.post("chord", { intervals: [0, 4, 7] });
apf.plugin.onChange(refresh);   // a preset or a loaded session changed the state

The controller’s request method answers (Presets and state). The page never touches the audio thread: it cannot wait for it, and the audio thread must never wait for a page.

A meter: from process to the page

The audio thread writes a number into an atomic; the controller reads it when the page asks.

  1. process records the block’s peak: meter.left.fetch_max(peak.to_bits(), Relaxed). (For non-negative floats the bit pattern orders like the number.)
  2. Controller::request("meter") swaps the atomics for 0 and returns the peaks since the last request as JSON.
  3. The page polls. Polling is not free: each poll is a request into the plugin, and on Windows it crosses from the web view’s process into the host’s. apf.poller keeps it cheap:
apf.poller(async () => {
  const peaks = await apf.plugin.get("meter");
  draw(peaks);
  return peaks.l > 0 || peaks.r > 0;      // true: something is happening, keep polling fast
}, { fast: 33, slow: 100, idle: 250 });

It polls every fast ms while the function reports activity, every slow after 1.5 s without, every idle after 6 s, and not at all while the page is hidden. Call wake() on the returned object from your own input handlers to bring it back to fast. Returning 1 instead of true means minor activity (a blinking light): the poll stays at the light rate, which you can set. APF’s own value polling uses the same helper.

Resizable web views

WebView::new(1280, 800, UI).resizable(true).size_limits((320, 200), (2400, 1600))

Lay the page out for any size (100vw and 100vh, flex or grid). The size the user chose is saved with the session and a new web view opens at it. Some hosts give the window no resize edge (Logic’s Audio Unit v3 windows): the page’s own <div data-apf-resize-grip></div> covers them, or call apf.resize(w, h).

Keys

While the web view has the focus it receives every key. A key the page does not claim goes on to the host, so the space bar still starts playback. To take shortcuts for yourself: apf.keys.claim(["cmd+d", "backspace"]), and call preventDefault() in the handler for the ones you handle.

Working on the page

  • Set APF_UI_DIR=path/to/ui and the plugin serves the page from disk: edit, reload, no rebuild.
  • Inside a host, hot reload rebuilds and swaps in the whole plugin, page and Rust code together, without closing the host.
  • apf.log(...) writes a line to the plugin’s stderr, since hosts show no web console.
  • apf-host --gui-test opens the web view in a window and drives it end to end, and apf-host --gui 3 --screenshot out.png writes a picture of it (Testing).

A page is an ordinary page: use your own CSS, canvas, SVG or any library you bundle into the assets. Keep it self-contained, without network access: a plugin must work offline, and a host’s sandbox may forbid it.

The web view reference lists every attribute and script function.

Presets and state

A host saves a session with each project: everything needed to bring the plugin back as it was. APF saves the parameter values for you. This chapter covers the rest: presets (sounds the user can browse and recall), and a plugin’s own state that is not a parameter.

Presets

A preset is a small JSON file (.apfpreset) with parameter values by key, in the units a person reads:

{
  "apf-preset": 1,
  "plugin": "dev.miha.apf.example.presets",
  "name": "Minor Seventh",
  "type": "Chord", "subtype": "Seventh", "mood": ["Dark", "Warm"],
  "description": "Four notes: minor seventh, soft and slow",
  "params": { "wave": "Triangle", "attack": 60, "release": 900, "gain": -13 },
  "controller": { "intervals": [0, 3, 7, 10] }
}

Choices are stored by option name, so reordering options in a later version does not scramble old presets, and parameters a preset does not mention get their defaults. A preset therefore stays valid when you add a parameter later.

Factory presets are compiled into the plugin. Put the files in a presets/ folder and list them:

const FACTORY_PRESETS: &'static [&'static str] =
    apf::presets!["../presets/Init.apfpreset", "../presets/Pad.apfpreset"];   // in `impl Plugin`

Paths are relative to the source file, like include_str!. They appear in the web view’s preset bar and in hosts’ preset menus, new ones last (Audio Unit hosts number them by position, so append new factory presets at the end).

User presets are files in the folder where hosts and people expect them, shared by all formats:

SystemFolder
macOS~/Library/Audio/Presets/<vendor>/<plugin>/
WindowsDocuments\<vendor>\<plugin>\Presets
Linux (not built yet)~/.local/share/<vendor>/<plugin>/presets

The web view gets a preset bar from <div data-apf-presets></div>: list, previous and next, save, replace, delete, and a mark when the sound was changed. The session remembers which preset the sound came from. A session from before presets existed is named after the factory preset it equals.

Hosts see presets too. The framework implements CLAP preset discovery and preset load, which clap-wrapper publishes as Audio Unit factory presets (AUv2 by itself, AUv3 with APF’s patch) and as VST3 program lists, factory presets first and in the plugin’s order. A host that gives a flat list shows them numbered by position.

A sound library: metadata

A preset may describe itself for a browser, with optional top-level members (type, subtype, mood, description, author). Unknown members are ignored and old presets load unchanged.

  • Rust: preset::Metadata { category, subcategory, tags, description, author } (for the keys type, subtype, mood, description, author; missing ones are empty) on Preset::meta and Entry::meta; preset::to_json_with_meta(info, name, &meta, params, controller); ViewContext::save_preset_with(name, overwrite, Some(&meta)) (save_preset and None keep the metadata of the preset they overwrite); preset::Factory::shared(FACTORY_PRESETS) parses the factory presets once per process.
  • Web view: apf.presets.list() and apf.presets.save(name, overwrite, meta) (Web view reference). The preset bar groups factory presets by type, shows the description as a tooltip, and a sound saved from a typed preset keeps its type, subtype and mood.
  • Hosts: CLAP preset discovery reports type, subtype and mood words as features (lower case), the description and the author.
  • Speed: a list of 250 factory presets of 6 KB took 0.5 ms in a debug build and 0.1 ms in a release build, after a one-time parse when the first instance is created (25 ms in release). User preset files are read again only when their size or modification time changed (250 files: 21 ms the first time and 1.2 ms after, release).

Testing presets

Presets can be tested without any host. apf::preset::Factory::parse(P::FACTORY_PRESETS) parses the factory presets, and apf::preset::save_in(dir, ...) saves a user preset into a scratch folder (example 08).

State that is not a parameter

Some state is not a number: a list of chord notes, a tuning table, a patch, a step sequence. A plugin with such state returns a Controller from Plugin::controller. The controller lives on the main thread and never sees the plugin instance. Whatever process needs of it, the plugin shares through its own wait-free structures (atomics, a queue), created in Plugin::controller and held by both. examples/tuning-demo publishes its tuning as atomics.

#![allow(unused)]
fn main() {
extern crate apf;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use apf::prelude::*;

/// Three chord intervals, shared between the controller and the audio thread.
#[derive(Default)]
struct Chord([AtomicI32; 3]);

impl Controller for Chord {
    // The text stored in sessions and presets (JSON in practice).
    fn save(&self) -> String {
        let n: Vec<i32> = self.0.iter().map(|a| a.load(Ordering::Relaxed)).collect();
        format!("[{},{},{}]", n[0], n[1], n[2])
    }
    // Restore it. An error resets the controller.
    fn load(&self, text: &str) -> Result<(), String> {
        let n: Vec<i32> = text.trim_matches(|c| c == '[' || c == ']')
            .split(',').filter_map(|s| s.trim().parse().ok()).collect();
        if n.len() != 3 { return Err("expected three numbers".into()); }
        for (a, v) in self.0.iter().zip(n) { a.store(v, Ordering::Relaxed); }
        Ok(())
    }
    // A session or preset without your data: the default state.
    fn reset(&self) { for (a, v) in self.0.iter().zip([0, 4, 7]) { a.store(v, Ordering::Relaxed); } }
    // Changes whenever `save()` would.
    fn revision(&self) -> u64 { 0 }
}

struct Pad { chord: Arc<Chord> }

impl Plugin for Pad {
    const INFO: PluginInfo = PluginInfo {
        id: "com.example.pad", name: "Pad", vendor: "Example", url: "", version: "0.1.0",
        description: "A pad", features: &["instrument", "stereo"],
    };
    const LAYOUT: AudioLayout = AudioLayout::INSTRUMENT_STEREO;

    fn new() -> Self { Pad { chord: Arc::new(Chord::default()) } }

    fn controller(&mut self, _host: HostHandle) -> Option<Arc<dyn Controller>> {
        Some(self.chord.clone())      // the plugin keeps its own handle for `process`
    }

    fn process(&mut self, _audio: &mut AudioBuffer, _ctx: &ProcessContext) -> ProcessStatus {
        ProcessStatus::Sleep
    }
}

apf::export_plugin!(Pad);
}

(This sketch keeps a fixed revision; a real controller bumps an atomic counter on every edit.)

The Controller trait in full:

MethodCalled
save(&self) -> Stringto store the state in a session or preset
load(&self, text: &str) -> Result<(), String>when a session or preset is loaded; an error resets the controller
reset(&self)for a session or preset without your data
revision(&self) -> u64often; changes whenever save() would, and save is called only when it moved
request(&self, req: &ControllerRequest) -> ControllerResponsefor the web view’s requests; the default answers 404
on_main_thread(&self)after host.request_callback()

How it fits in:

  • Sessions. The text is stored as a state chunk (CTRL) next to the parameters. A session without it (older, or saved without a controller) calls reset, and the old sessions of plugins without a controller load as before.
  • Presets. Saving puts the text into the .apfpreset as "controller": the JSON value itself when it is a JSON object or array (laid out, readable, every digit kept), otherwise a string. Loading applies the parameters, then load (or reset). The preset bar’s “changed” mark also follows the controller’s data (compared as JSON, member order ignored, only when revision moved). Host preset menus (CLAP preset load, AU factory presets, VST3 programs) work the same way.
  • Web view. GET and POST /__apf/plugin/<path>?<query> reach request; in the page use apf.plugin.get, post, raw and onChange (Web view reference). A view written in Rust (not a web page) gets the controller from ViewContext::controller().
  • Host. HostHandle::mark_dirty() tells the host the session changed, so its “project changed” mark lights (CLAP state.mark_dirty; through clap-wrapper VST3 setDirty(true), for AUv2 a ClassInfo and a PresentPreset notification, the latter being what Logic listens to, for AUv3 a fullState change; the standalone ignores it). Parameter changes need no call. Off the main thread it waits for the next main-thread callback. request_callback() asks for Controller::on_main_thread.
  • Parameters from the controller. begin_edit, set_param, set_param_plain, end_edit and edit_param on HostHandle move a parameter as a host gesture from the main thread (through the web view’s queue, so the host records it). Use them for a knob in a patch that is mapped to a macro parameter.

Examples 07 and 08 show a controller, and examples/tuning-demo a larger one.

Testing without a host

The apf-test crate loads a plugin through the CLAP entry points, exactly as a host does, and lets a test play it in memory. No sound card is opened, no window appears, and a test runs in milliseconds.

[dev-dependencies]
apf-test = { path = "../apf/crates/apf-test" }     # or `.workspace = true` inside the APF workspace
use apf_test::{allocations_in_process, frequency, CountingAllocator, Event, Instance};

#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;

#[test]
fn a_note_has_the_right_pitch() {
    let mut p = Instance::load(my_plugin::apf_entry_init, my_plugin::apf_entry_get_factory);
    p.activate(48_000.0, 4800);
    let audio = p.render(&[Event::note_on(1000, 69, 1.0)], 4800);   // A4 at sample 1000
    assert!(audio.left[..1000].iter().all(|&s| s == 0.0));
    assert!((frequency(&audio.left[2000..], 48_000.0) - 440.0).abs() < 1.0);
    assert_eq!(allocations_in_process(), 0);
}
WhatHow
play notes, MIDI, automationEvent::note_on/off, cc, pitch_bend, param, midi, at a sample position
an instrumentrender(&events, frames)
an effectrender_effect(&events, &left, &right, block_size)
parameters as a host sees themparam_id("Name"), host_value, value_to_text, set_text("Gain", "-6 dB")
a saved projectsave_state(), load_state(bytes)
measurerms, frequency

The pattern of every example’s test: render audio, then measure it (its pitch, its level, its spectrum with a Goertzel filter), and run the whole thing under the counting allocator.

Presets can be tested without any host: apf::preset::Factory::parse(P::FACTORY_PRESETS) parses the factory presets, and apf::preset::save_in(dir, ...) saves a user preset into a scratch folder (example 08).

Beyond unit tests

apf-host is a minimal CLAP host in this repository. It loads a built .clap, renders audio and drives the web view end to end:

cargo run --release -p apf-host -- "dist/release/APF Example 07 GUI.clap" --gui-test
cargo run --release -p apf-host -- "dist/release/APF Example 07 GUI.clap" --gui 3 --screenshot out.png

--gui-test opens the web view, edits a parameter from the page and checks that the host sees it, then the other way round, then resizes the window and sends keys. Nothing is played to a sound card.

cargo xtask validate <plugin> runs the third-party validators on the plugin that cargo xtask bundle built: clap-validator, the Steinberg VST3 validator, Apple’s auval (macOS, on the installed Audio Units), and APF’s own state checks (a restored session must sound the same, in several restore orders). clap-validator is installed separately; the command says how when it is missing. Validation lists the tools and what they found. With --hot it also checks that the hot-reload shell swaps in a new build in a CLAP host and in both Audio Unit versions. The run expects a plugin with parameters, and the web view check at least three; a plugin without parameters makes clap-validator 0.4.1 fail its parameter test.

Hosts and formats

How one plugin becomes five formats

APF implements the CLAP interface in Rust, once (crates/apf/src/clap.rs). The other formats are produced from that CLAP by clap-wrapper (MIT, C++), built by cargo xtask bundle:

FormatmacOSWindows
CLAPyesyes
VST3yesyes
Audio Unit v2yesno such format
Audio Unit v3yes (needs Xcode)no such format
Standalone appyesnot verified
Linux (any format)not builtnot built
LV2, AAX, VST2not offerednot offered

Your plugin never sees the format. Everything a format does differently (how it numbers parameters, how it saves state, how it asks for a window) is the wrapper’s job, and where the wrappers had bugs APF carries patches (wrapper/patches/) and checks them with validators.

Building

cargo xtask bundle <plugin>                     # CLAP, VST3, AUv2, standalone → dist/release/
cargo xtask bundle <plugin> --formats all       # plus AUv3 (macOS, needs Xcode)
cargo xtask bundle <plugin> --formats clap,vst3 # only these (clap, vst3, auv2, auv3, standalone)
cargo xtask bundle <plugin> --debug             # a debug build → dist/debug/
cargo xtask bundle <plugin> --install           # and copy into the plugin folders
cargo xtask install <plugin>                    # copy what is built (takes the same --formats)
cargo xtask uninstall <plugin>                  # remove it from the plugin folders, every format
cargo xtask install <plugin> --hot              # development: swap in new builds while the host runs (see Hot reload)
cargo xtask validate <plugin>                   # run every validator on the built plugin

<plugin> is the package name. In a project of its own (one made by cargo xtask new) run the commands in the project’s folder and leave the name out; its .cargo/config.toml points cargo xtask at APF’s build tool, and the bundles land in its own dist/. From the APF repository, --project <dir> names such a project instead. cargo xtask --help lists every option.

bundle builds the plugin as a Rust static library, then the CMake project in wrapper/, which links it into each format with clap-wrapper. Each plugin, profile and generator has its own wrapper build folder in <cargo target dir>/apf-wrapper/, with its own copy of clap-wrapper; the SDKs are downloaded once into apf-wrapper/cpm-cache/ and shared. The compiler output goes to apf-build.log in the build folder (--verbose shows it instead). The AUv3 is built by Xcode in a folder of its own.

Where install puts the plugin (macOS): ~/Library/Audio/Plug-Ins/CLAP, VST3 and Components; the AUv3 is an app extension inside ~/Applications/<name> AUv3.app, registered with LaunchServices. install also restarts the Audio Unit registry so a new Audio Unit is found at once. cargo xtask uninstall removes all of it. macOS also registers the AUv3 app that Xcode built, so a host lists the AUv3 after bundle --formats all even before you install it; uninstall unregisters those copies too. The standalone app stays in dist/; do not leave it running unattended (Known issues). On Windows see Windows.

The packaging identity lives in the plugin’s Cargo.toml:

[package.metadata.apf]
name = "APF Example 01 Sine"
bundle-id = "dev.miha.apf.example.sine"
au-type = "aumu"           # aumu instrument, aufx effect, aumf effect that takes MIDI
au-subtype = "ExSi"        # four characters, at least one capital
au-manufacturer = "MiSm"   # four characters, at least one capital; yours, the same for all your plugins
au-manufacturer-name = "Miha Smrekar"

Optional: au-v3-name (the name hosts list the AUv3 under, when the AUv2 is installed beside it), au-check-preset (a factory preset cargo xtask validate loads for its Audio Unit MIDI check, when the default sound would not show a released key).

These identifiers are forever. The CLAP id, the bundle id and the Audio Unit codes are stored in every project a user saves. Change one after release and the host no longer finds the plugin. The AUv3 takes the first three characters of the subtype plus 3, so two plugins of yours must not share their first three characters.

What is tested where

  • macOS, Apple silicon: CLAP, VST3, AUv2, AUv3 and the standalone app are built. clap-validator, the Steinberg VST3 validator, auval for both Audio Units and APF’s state checks pass for the demo synth. Logic Pro (AUv2 and AUv3) and REAPER load it, restore its sessions and show its web view.
  • Windows 11, x64: CLAP and VST3, the web view in WebView2, REAPER; the same validators.
  • Not verified: other hosts (Bitwig, Cubase, Ableton Live, FL Studio, Studio One), the Windows standalone, Windows on ARM, Intel Macs, and a second monitor with another scale.
  • Not built: Linux, LV2, AAX, VST2.

Validation has the full results with versions. When you report a host problem, say which host, which format and which version.

Quirks worth knowing

  • Pitch bend reaches Audio Units as a channel-wide tuning expression; APF turns it back into the wheel position (Audio and MIDI).
  • Logic hands parameter values back with less precision (32-bit floats through the Audio Unit API), so a preset counts as unchanged when every parameter shows the same text, not only when the numbers are equal.
  • Logic’s Audio Unit v3 windows have no resize edge. The page’s own grip covers them (The web view).
  • AUv2 and AUv3 side by side: the AUv3 has its own subtype, so hosts list two entries when both are installed.
  • A VST3 shows many parameters in REAPER (about 2100 for a plugin that takes MIDI): clap-wrapper adds MIDI controller mapping parameters, because VST3 has no controller events.

The rest is in Known issues.

Not offered

VST2 is out (Steinberg no longer licenses it) and AAX needs Avid’s SDK under a GPL or a separate agreement; neither is in the build (Licences).

Hot reload

Changing a plugin usually means quitting the host, installing the new build and opening the project again. With hot reload the host keeps running: you save a source file, and a few seconds later the new build plays in every open instance of the plugin. The project, the plugin’s settings and its open window stay where they were.

PlatformFormatsChecked in
macOS (Apple silicon)CLAP, VST3, AUv2, AUv3Logic Pro 12.4 (AUv3, 2026-10-30); apf-host (CLAP); AUv2 and AUv3 hosted in-process and out of process by au-hot-check
Windows, Linux—not available yet

Hot reload is for development. Ship and measure normal builds.

Try it with example 10

This walks through it with the synth from chapter 10 and Logic Pro. Any host works the same way.

1. Install the hot-reload shell, once. Run this in the APF repository:

cargo xtask install example-10-synth --hot --formats all

This installs the plugin in every format as before, but the installed plugin is now a small shell. The shell runs the plugin from a separate library, which this command builds and publishes too.

2. Start the host. Open Logic Pro, or quit and reopen it if it was running: a host only loads a newly installed plugin when it starts. Make a software instrument track with APF Example 10 Synth, open its window and play a few notes. The AUv3 is listed with the other Audio Units; the AUv2 works the same way.

3. Start the watcher and leave it running in a terminal:

cargo xtask hot example-10-synth --watch

4. Change the code. In examples/10-synth/src/lib.rs, find this line in process:

let wave = Waveform::from_index(p.get_index(WAVE));

Change it to let wave = Waveform::Square; and save while a region plays in a loop (a note held on the keyboard stops at the swap). The terminal shows:

== Hot build: example-10-synth (apf-hot)
== Published libexample_10_synth-1793351257351.dylib in 3.1 s → ...

A moment later the synth in Logic sounds hollow: every note is a square wave, whatever the Waveform menu says. The window closes and opens again by itself, with the same settings. Change the line back, save, and the menu works again. Edits to the page in ui/ are reloaded the same way.

A changed default value is not heard this way: the swap keeps the plugin’s settings, so the old value stays. A new instance gets the new default.

5. When you are done, stop the watcher with Ctrl+C. Put the normal plugin back with cargo xtask install example-10-synth --formats all, and restart the host once more.

A plugin in its own project works the same way: run the commands in the project’s folder, without the plugin’s name (cargo xtask install --hot, then cargo xtask hot --watch). The plugin’s code needs nothing for it: every plugin made with apf::export_plugin! can be hot-reloaded.

Commands

cargo xtask install <plugin> --hot [--formats all]   # install the shell (and publish a first build)
cargo xtask hot <plugin>                             # build and swap in once
cargo xtask hot <plugin> --watch                     # on every saved change, until Ctrl+C
cargo xtask hot <plugin> --debug                     # a debug build: faster to compile, slower DSP
cargo xtask validate <plugin> --hot                  # the usual validators, plus the swap itself
cargo xtask install <plugin>                         # back to the normal plugin

--watch watches the folders of the crates the plugin is built from: your plugin, the crates of your workspace it uses, and APF itself. A build that comes out the same as the running one is not swapped in.

Any normal install replaces the shell. If you work with tools or agents that install your plugin, have them use --hot and cargo xtask hot while you work this way.

What happens in the host

The shell (crates/apf-hot) has no sound or window of its own. It loads the plugin from a library that hot builds and publishes, and passes every call from the host on to it. When a new build is published, each open instance does this on the host’s main thread:

  1. Creates an instance of the new build.
  2. Holds the audio thread for one block, which plays silence, and saves the running instance’s state, as when the project is saved.
  3. Closes the old window, then stops and destroys the old instance.
  4. Loads the state into the new instance and starts it with the host’s sample rate and block size. The audio thread carries on with it.
  5. Opens the new window in the same place.

A swap takes about 100 ms of the main thread and one block of audio. Builds use the profile apf-hot: release code without LTO, compiled in 16 parts and incrementally. A change to one crate then builds in a few seconds. The DSP is a little slower than in a shipped build.

What reloads and what does not

Everything in the plugin’s code reloads: sound, MIDI handling, the web view page and its files, presets and the controller.

Some things a host reads only when it scans or creates the plugin. A change to these needs install --hot again and a host restart:

  • the plugin’s id, name, vendor and features;
  • its audio and note ports;
  • its parameter list. A build with a different number of parameters still loads, but the host keeps showing the old list.

Other limits:

  • Held notes stop at each swap. A playing voice is not part of the state.
  • The state format must stay readable. If the new build cannot read the old one’s state, the plugin starts from its defaults.
  • A failed build changes nothing. If a build fails to compile or load, the running build stays.
  • Memory grows a little with every swap. Each swap keeps the old library loaded until the host quits, because macOS cannot unload code that registered Objective-C classes.

Where things are

Published builds<target dir>/apf-hot/<plugin>-…-hot/; the file current names the newest
Loghot.log in that folder: every load and swap, from every host process
Shell bundlesdist/hot/

Old builds are deleted after twelve newer ones. When a swap does not happen, read hot.log first: it says what was loaded, by which process, and why something failed.

Notes for Audio Unit hosts

  • The swap does not wait for the host. The shell runs it on the process’s main dispatch queue. It cannot rely on the host’s on_main_thread callback alone: clap-wrapper’s AUv3 makes no such callback while the plugin processes, and an instrument in Logic processes all the time.
  • The AUv3 sandbox. The AUv3 runs in a sandboxed extension. The shell’s build of it may also read the folder the builds are published to, through an extra entitlement that only the shell gets.

cargo xtask validate <plugin> --hot checks the swap in three hosts:

  • apf-host --hot-test on the CLAP;
  • scripts/au-hot-check.swift on the AUv2 in its own process;
  • scripts/au-hot-check.swift on the installed AUv3, out of process as Logic loads it.

Each plays a chord with the window open, publishes a copy of the current build and keeps rendering until the swap. It then checks three things: a moved parameter kept its value, the window is open again, and the plugin still makes sound.

The example ladder

Ten small plugins in the examples/ folder. Each is a complete plugin that builds into every format, has a test that renders audio without a sound card and fails if process allocates memory, and adds one idea to the one before. Read them in order.

#ExampleTeaches
01sinethe Plugin trait, export_plugin!, a note in and a sine out, events at their sample, one parameter
02gainan effect: audio in and out, a parameter, Smoother, decibels
03paramsevery parameter kind, curves, units, groups; never rename a key, only append
04envelopeAdsr, velocity, polyphony, voice stealing
05modulationan LFO, mod wheel, pitch bend, modulating without touching the parameter
06filterthe state-variable filter, cutoff smoothing, denormals
07guidata-param bindings, a meter from process to the page, apf.poller, resizing
08presets-and-statefactory and user presets, metadata, a Controller for non-parameter state
09midi-and-learnsustain pedal, MIDI learn, remote controls, host automation
10syntha compact complete synth that uses all of the above, and a look at the full demo synth

Working with an example

cargo test -p example-01-sine                    # plays a note in memory
cargo xtask bundle example-01-sine --install     # builds it and installs it for your host

Open the example’s src/lib.rs, change something, and run its test. The “Try it” list at the end of each chapter has ideas. To hear a change in your host without restarting it, use hot reload.

Each example’s tests/process.rs shows how the plugin is tested through the same interface a host uses.

01 Sine: the smallest instrument

A MIDI note comes in, a sine wave goes out. This is the whole of a plugin. It is also the template cargo xtask new copies.

You will learn

  • the shape of a Plugin: INFO, LAYOUT, new, activate, reset, event, process
  • that event is called at the exact sample an event happened
  • apf::export_plugin!, the one line that makes the type a plugin in every format
  • Oscillator and Smoother, and how to return ProcessStatus::Sleep
  • a first parameter: a Level knob in decibels, read in process

The code is in examples/01-sine/. It is included here as it is on disk.

#![allow(unused)]
fn main() {
extern crate apf;
//! Example 01: the smallest instrument. Play a MIDI note, hear a sine wave.
//!
//! A plugin is a type that implements [`Plugin`]. The host calls it in three ways:
//!
//! * `new` / `activate`: set up (the main thread; allocating is fine here),
//! * `event`: "a key went down" (the audio thread, at the exact sample it happened),
//! * `process`: "fill this block of audio" (the audio thread, called hundreds of times a
//!   second; it must be fast and must never allocate, lock or wait).
//!
//! There is one parameter, `Level`, a volume knob in decibels. A plugin with none works,
//! but some hosts and test tools (clap-validator among them) trip over a parameter-less
//! plugin, so even the smallest one carries a single knob. Example 02 explains parameters.
//!
//! Read this file top to bottom: it is the whole plugin.

use apf::prelude::*;

// The plugin's only parameter. The key ("level") is its permanent name: hosts store it in
// saved projects, so it must never change.
const PARAMS: &[ParamDef] = &[ParamDef::float("level", "Level", -48.0, 0.0, -6.0).unit("dB").decimals(1)];
// A typed handle to it, checked at compile time: a misspelt key does not build.
const LEVEL: ParamId = ParamId::of(PARAMS, "level");

/// A monophonic sine voice: one note at a time, the newest key wins.
pub struct Sine {
    /// Makes the wave. It keeps a phase (where in the cycle we are) between samples.
    osc: Oscillator,
    /// The loudness of the note (velocity while a key is down, 0 after), glided so that
    /// starting and stopping a note does not click.
    level: Smoother,
    /// The Level knob as a plain factor, glided so that turning it does not crackle.
    volume: Smoother,
    /// The key that is sounding, so that releasing an *older* key does not stop the note.
    key: i16,
    sample_rate: f32,
}

impl Plugin for Sine {
    // What the host shows. The id is a reverse-DNS name that must stay the same forever:
    // hosts remember saved projects by it.
    const INFO: PluginInfo = PluginInfo {
        id: "dev.miha.apf.example.sine",
        name: "APF Example 01 Sine",
        vendor: "Miha Smrekar",
        url: "",
        version: env!("CARGO_PKG_VERSION"),
        description: "A sine wave that follows MIDI notes",
        // The first feature is the category: "instrument", "audio-effect" or "note-effect".
        features: &["instrument", "synthesizer", "stereo"],
    };

    // No audio in, a stereo pair out, and the host sends us notes.
    const LAYOUT: AudioLayout = AudioLayout::INSTRUMENT_STEREO;
    const PARAMS: &'static [ParamDef] = PARAMS;

    // `new` runs when the host creates the plugin. The sample rate is not known yet.
    fn new() -> Self {
        Self { osc: Oscillator::new(), level: Smoother::new(0.0), volume: Smoother::new(0.5), key: -1, sample_rate: 48_000.0 }
    }

    // `activate` runs when audio is about to start; now the sample rate is known.
    fn activate(&mut self, config: &ProcessConfig, params: &Params) {
        self.sample_rate = config.sample_rate as f32;
        // Take about 5 ms to reach a new level: short enough to feel instant, long enough
        // to avoid the click of a wave that is cut off in the middle of a cycle.
        self.level.set_time(config.sample_rate, 5.0);
        self.volume.set_time(config.sample_rate, 20.0);
        // Start at the knob's current value instead of gliding from the default.
        self.volume.reset(db_to_gain(params.get_f32(LEVEL)));
        self.reset();
    }

    // `reset` silences everything (the host's "stop" or "panic").
    fn reset(&mut self) {
        self.level.reset(0.0);
        self.key = -1;
    }

    // Called at the exact sample where the event happened: the framework cuts the audio
    // block in two around it, so the note starts on time, not at the next block.
    fn event(&mut self, event: &Event, _ctx: &ProcessContext) {
        match *event {
            Event::NoteOn { key, velocity, .. } => {
                self.key = key;
                // MIDI note numbers are semitones: 69 is A4 = 440 Hz.
                self.osc.set_frequency(midi_to_hz(key as f32), self.sample_rate);
                // Velocity is 0..1 (how hard the key was hit).
                self.level.set_target(velocity);
            }
            Event::NoteOff { key, .. } if key == self.key => {
                self.key = -1;
                self.level.set_target(0.0);
            }
            _ => {}
        }
    }

    // Fill the block. The buffer is already silent for an instrument.
    fn process(&mut self, audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus {
        // The knob's value as of the first sample of this piece of the block (the framework
        // cuts the block wherever the host changes it). Decibels become a plain factor.
        self.volume.set_target(db_to_gain(ctx.params.get_f32(LEVEL)));
        let Some((left, right)) = audio.stereo() else { return ProcessStatus::Error };
        for (l, r) in left.iter_mut().zip(right.iter_mut()) {
            let sample = self.osc.tick(Waveform::Sine) * self.level.tick() * self.volume.tick();
            *l = sample;
            *r = sample;
        }
        // A fade towards 0 gets ever slower and takes a long time to arrive. Once the key
        // is up and the level is far below hearing (-80 dB), jump to silence for good.
        if self.key < 0 && self.level.value() < 1e-4 {
            self.level.reset(0.0);
        }
        // Tell the host whether it can stop calling us until the next note.
        if self.level.value() == 0.0 {
            ProcessStatus::Sleep
        } else {
            ProcessStatus::Continue
        }
    }
}

// One line turns the type into a plugin: it defines the C functions every host looks for.
apf::export_plugin!(Sine);
}

Reading it

Construction versus activation. new runs when the host creates the plugin and does not know the sample rate. activate runs when audio is about to start and does. Anything that depends on the sample rate (the oscillator’s step, a smoother’s time) is set there.

Events at their sample. A host sends a note-on “at sample 100 of this block”. APF cuts the block into pieces at every event: process is called for samples 0..100, then event with the note-on, then process for the rest. Your process never has to look at timestamps, and the note starts on time. (This is also why process may be called with very short blocks: do not assume a fixed size.)

One parameter. PARAMS lists the plugin’s one knob, Level, from -48 to 0 dB. process reads it with ctx.params.get_f32(LEVEL) and turns decibels into a plain factor with db_to_gain. It is here partly because clap-validator 0.4.1 fails on a plugin with no parameter at all; Parameters and example 02 explain them properly.

Why a smoother. A sine that starts or stops in the middle of a cycle makes a click. The Smoother glides the level over about 5 ms (a second one glides the knob). Gliding towards zero takes forever in floating point, so process declares silence once the level is far below hearing and the plugin then Sleeps: the host may stop calling it until the next event.

Try it

  • Change Waveform::Sine to Waveform::Saw and listen. (PolyBLEP keeps the saw clean at high notes.)
  • Make it respond to velocity differently, or play two notes by keeping a second oscillator. Example 04 does it properly.
  • Run cargo test -p example-01-sine: the test plays A4 at sample 1000, measures 440 Hz, and checks that the Level knob changes the loudness.

Build and test

cargo test -p example-01-sine
cargo xtask bundle example-01-sine --install

02 Gain: an effect

A volume knob. Audio in, audio out, one parameter.

You will learn

  • AudioLayout::EFFECT_STEREO: an input, and an output that already holds a copy of it
  • ParamDef and ParamId in detail
  • reading a parameter in process
  • Smoother, and why decibels

The code is in examples/02-gain/. It is included here as it is on disk.

#![allow(unused)]
fn main() {
extern crate apf;
//! Example 02: a gain effect. Audio in, audio out, one parameter.
//!
//! New in this example:
//!
//! * an effect has an audio *input* (`AudioLayout::EFFECT_STEREO`),
//! * a parameter: a value the user (or the host's automation) changes while the plugin runs,
//! * a [`Smoother`], which turns the jumps of a changing parameter into a glide,
//! * decibels: how loudness is normally measured and shown.

use apf::prelude::*;

// The plugin's parameters, listed once as a constant. The key ("gain") is the parameter's
// permanent name: hosts store it in saved projects. See example 03 for the rules.
const PARAMS: &[ParamDef] = &[
    // key, name shown to the user, minimum, maximum, default
    ParamDef::float("gain", "Gain", -60.0, 12.0, 0.0).unit("dB").decimals(1),
];

// A typed handle to the parameter, checked at compile time: a misspelt key does not build.
const GAIN: ParamId = ParamId::of(PARAMS, "gain");

pub struct Gain {
    /// Follows the parameter smoothly, as a *linear* factor (1.0 = unchanged).
    gain: Smoother,
}

impl Plugin for Gain {
    const INFO: PluginInfo = PluginInfo {
        id: "dev.miha.apf.example.gain",
        name: "APF Example 02 Gain",
        vendor: "Miha Smrekar",
        url: "",
        version: env!("CARGO_PKG_VERSION"),
        description: "A gain control",
        features: &["audio-effect", "utility", "stereo"],
    };

    // Stereo in, stereo out. The host gives us the input already copied into the output
    // buffer, so an effect just changes the samples it finds there ("in place").
    const LAYOUT: AudioLayout = AudioLayout::EFFECT_STEREO;
    const PARAMS: &'static [ParamDef] = PARAMS;

    fn new() -> Self {
        Self { gain: Smoother::new(1.0) }
    }

    fn activate(&mut self, config: &ProcessConfig, params: &Params) {
        // 20 ms to glide to a new setting: no clicks, still feels immediate.
        self.gain.set_time(config.sample_rate, 20.0);
        // Start at the parameter's current value instead of gliding from the default.
        self.gain.reset(db_to_gain(params.get_f32(GAIN)));
    }

    fn process(&mut self, audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus {
        // Read the parameter once per block. `get_f32` returns the *plain* value in the
        // parameter's own unit (here decibels), whatever the host stores internally.
        let db = ctx.params.get_f32(GAIN);
        // Decibels are logarithmic: +6 dB is about twice the amplitude, -6 dB about half.
        // Samples are multiplied by a plain factor, so convert.
        self.gain.set_target(db_to_gain(db));

        // Both channels get the same gain at each sample, so tick the smoother once per
        // sample *frame*, not once per channel.
        let Some((left, right)) = audio.stereo() else { return ProcessStatus::Error };
        for (l, r) in left.iter_mut().zip(right.iter_mut()) {
            let g = self.gain.tick();
            *l *= g;
            *r *= g;
        }
        ProcessStatus::Continue
    }
}

apf::export_plugin!(Gain);
}

Reading it

In place. An effect changes the samples it finds in the output buffer. APF has copied the host’s input there before calling you.

Decibels. Loudness is perceived logarithmically, so a gain control is in dB: -6 dB is half the amplitude, +6 dB double, -60 dB a thousandth. db_to_gain converts. The parameter holds dB; process multiplies by a plain factor.

Smoothing. If the knob’s value jumped, the signal would jump with it: a click. The smoother makes it a 20 ms glide. Tick it once per sample frame and use that value on both channels. activate sets the smoother to the parameter’s current value so the first block does not glide from a stale default.

Automation. The test sends a parameter event at sample 100 and checks that the output is unchanged before sample 100.

Try it

  • Add a bool parameter that mutes the output (use the smoother so the mute does not click).
  • Add a pan control: scale the two channels differently. Example 03 has a constant-power pan.

Build and test

cargo test -p example-02-gain
cargo xtask bundle example-02-gain --install

03 Params: every kind of parameter

A small channel tool, built to show every kind of parameter.

You will learn

  • float, int, bool, choice and bypass
  • curves (Linear, Log, Power), units, decimals, module, not_automatable
  • the three rules: never rename a key, only append, never change a value’s meaning
  • how a test can pin the ids so nobody breaks them by accident

The code is in examples/03-params/. It is included here as it is on disk.

#![allow(unused)]
fn main() {
extern crate apf;
//! Example 03: every kind of parameter, in a small channel tool.
//!
//! A parameter is described once, at compile time, by a [`ParamDef`]. From that description
//! the framework builds everything else: the value store the audio thread reads, the list a
//! host shows, the text it displays ("2.00 kHz"), saved projects and presets, and the
//! controls of a web view.
//!
//! **Three rules to keep forever once a plugin is released**
//!
//! 1. *Never rename a key.* The id a host stores in projects and automation is a hash of the
//!    key. A renamed key is a different parameter, and old projects lose their settings.
//!    (The display *name* may change freely.)
//! 2. *Only append.* Audio Units, and Logic's automation and Smart Controls, address
//!    parameters by their position in this list: add new ones at the end, never in the
//!    middle, and never delete one (hide it with `.hidden()` if it must go).
//! 3. *Never change what a stored value means.* A "gain" saved as -6 dB must not become -6 %.
//!    Need different behaviour? Add a new parameter.

use apf::prelude::*;

const PARAMS: &[ParamDef] = &[
    // A float with a linear scale: the slider position is proportional to the value.
    // `.unit` is shown after the number and understood when the user types one;
    // `.decimals` is how many digits to show; `.module` groups parameters in hosts that
    // support groups.
    ParamDef::float("level", "Level", -24.0, 24.0, 0.0).unit("dB").decimals(1).module("Level"),
    ParamDef::float("pan", "Pan", -100.0, 100.0, 0.0).unit("%").decimals(0).module("Level"),
    // A bool is a switch.
    ParamDef::bool("invert", "Invert Polarity", false).module("Level"),
    // `Curve::Log` makes equal *ratios* equally long on the slider: the right choice for
    // frequencies and times, where 100 -> 200 Hz matters as much as 5 -> 10 kHz.
    ParamDef::float("hicut", "Hi-cut", 200.0, 20_000.0, 20_000.0).unit("Hz").curve(Curve::Log).decimals(0).module("Tone"),
    // `Curve::Power(2.0)` spends more of the slider on the low end of the range: the
    // quietest settings are the subtle ones.
    ParamDef::float("drive", "Drive", 0.0, 100.0, 0.0).unit("%").curve(Curve::Power(2.0)).decimals(0).module("Tone"),
    // A choice lists named options; the value is the index of the option. Appending
    // options at the end later is safe, reordering them is not.
    ParamDef::choice("mode", "Mode", &["Stereo", "Left", "Right", "Mono"], 0).module("Routing").not_automatable(),
    // An int is a whole number.
    ParamDef::int("delay", "Right Delay", 0, MAX_DELAY as i32, 0).unit("smp").module("Routing"),
    // The bypass switch: hosts connect their own bypass button to it.
    ParamDef::bypass("bypass"),
];

const LEVEL: ParamId = ParamId::of(PARAMS, "level");
const PAN: ParamId = ParamId::of(PARAMS, "pan");
const INVERT: ParamId = ParamId::of(PARAMS, "invert");
const HICUT: ParamId = ParamId::of(PARAMS, "hicut");
const DRIVE: ParamId = ParamId::of(PARAMS, "drive");
const MODE: ParamId = ParamId::of(PARAMS, "mode");
const DELAY: ParamId = ParamId::of(PARAMS, "delay");
const BYPASS: ParamId = ParamId::of(PARAMS, "bypass");

const MAX_DELAY: usize = 48;

pub struct ChannelTool {
    level: Smoother,
    pan: Smoother,
    lowpass: [OnePole; 2],
    delay: DelayLine,
    sample_rate: f32,
}

impl Plugin for ChannelTool {
    const INFO: PluginInfo = PluginInfo {
        id: "dev.miha.apf.example.params",
        name: "APF Example 03 Params",
        vendor: "Miha Smrekar",
        url: "",
        version: env!("CARGO_PKG_VERSION"),
        description: "Every kind of parameter",
        features: &["audio-effect", "utility", "stereo"],
    };
    const LAYOUT: AudioLayout = AudioLayout::EFFECT_STEREO;
    const PARAMS: &'static [ParamDef] = PARAMS;

    fn new() -> Self {
        // Allocating is fine here: `new` runs on the main thread, never inside `process`.
        Self {
            level: Smoother::new(1.0),
            pan: Smoother::new(0.0),
            lowpass: [OnePole::new(); 2],
            delay: DelayLine::new(MAX_DELAY),
            sample_rate: 48_000.0,
        }
    }

    fn activate(&mut self, config: &ProcessConfig, params: &Params) {
        self.sample_rate = config.sample_rate as f32;
        self.level.set_time(config.sample_rate, 20.0);
        self.pan.set_time(config.sample_rate, 20.0);
        self.level.reset(db_to_gain(params.get_f32(LEVEL)));
        self.pan.reset(params.get_f32(PAN) * 0.01);
        self.reset();
    }

    fn reset(&mut self) {
        self.lowpass.iter_mut().for_each(OnePole::reset);
        self.delay.clear();
    }

    fn process(&mut self, audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus {
        let p = ctx.params;
        // Each kind of parameter has its getter. Floats come back in their own unit.
        if p.get_bool(BYPASS) {
            return ProcessStatus::Continue; // the output already holds the input
        }
        self.level.set_target(db_to_gain(p.get_f32(LEVEL)));
        self.pan.set_target(p.get_f32(PAN) * 0.01);
        let polarity = if p.get_bool(INVERT) { -1.0 } else { 1.0 };
        let mode = p.get_index(MODE); // a choice: the index of the selected option
        let delay = p.get_f32(DELAY); // an int, as a float
        let drive = p.get_f32(DRIVE) * 0.01;
        let hicut = p.get_f32(HICUT);
        for filter in &mut self.lowpass {
            filter.set_cutoff(hicut, self.sample_rate);
        }
        // The top of the range means "off": the filter would still dull the top octave.
        let filter_on = hicut < 19_999.0;

        let Some((left, right)) = audio.stereo() else { return ProcessStatus::Error };
        for (l, r) in left.iter_mut().zip(right.iter_mut()) {
            let (mut a, mut b) = match mode {
                1 => (*l, *l),
                2 => (*r, *r),
                3 => (0.5 * (*l + *r), 0.5 * (*l + *r)),
                _ => (*l, *r),
            };
            a *= polarity;
            b *= polarity;
            // Soft clipping: blend between the clean and the saturated signal.
            if drive > 0.0 {
                let shape = |x: f32| (x * 3.0).tanh() / 3f32.tanh();
                a += (shape(a) - a) * drive;
                b += (shape(b) - b) * drive;
            }
            if filter_on {
                a = self.lowpass[0].lowpass(a);
                b = self.lowpass[1].lowpass(b);
            }
            b = self.delay.process(b, delay);
            // Constant-power pan: unity gain in the centre, the sum of the powers stays equal.
            let angle = (self.pan.tick() + 1.0) * std::f32::consts::FRAC_PI_4;
            let level = self.level.tick() * std::f32::consts::SQRT_2;
            *l = a * angle.cos() * level;
            *r = b * angle.sin() * level;
        }
        ProcessStatus::Continue
    }
}

apf::export_plugin!(ChannelTool);
}

Reading it

Read the comment at the top of the file, then the PARAMS list: each line is a different kind or option. The rest of the file is ordinary DSP that gives every parameter something to do.

The ids test. PINNED_IDS in tests/process.rs lists the 32-bit id of every parameter, in order. If someone renames a key, reorders the list or removes a parameter, the test fails with the numbers side by side. Once your plugin has shipped, that failure is a bug report from the future: saved projects would break.

Display text. The test also checks what a host shows: “2.00 kHz” for the middle of the log range, “25 %” for half of a squared curve, “Mono” for a choice. All of it comes from the definitions.

Try it

  • Append a bool parameter after bypass, and watch PINNED_IDS still pass for the old eight.
  • Insert one in the middle and watch it fail… then undo.
  • Change the curve of drive to Linear and see the text test (“25 %” at half travel) fail.

Build and test

cargo test -p example-03-params
cargo xtask bundle example-03-params --install

04 Envelope: voices and velocity

A polyphonic synth: eight voices, an ADSR envelope, velocity.

You will learn

  • Adsr and AdsrShape
  • a fixed pool of voices, and voice stealing
  • velocity sensitivity

The code is in examples/04-envelope/. It is included here as it is on disk.

#![allow(unused)]
fn main() {
extern crate apf;
//! Example 04: a polyphonic synth. Envelope, velocity, several notes at once.
//!
//! New in this example:
//!
//! * an [`Adsr`] envelope: how a note's loudness rises (attack), falls to a held level
//!   (decay, sustain) and dies away after the key is released (release),
//! * velocity: how hard the key was struck,
//! * *polyphony*: a fixed pool of voices, one per sounding note, and *voice stealing*:
//!   what to do when a ninth note arrives and all eight voices are busy.

use apf::prelude::*;

const PARAMS: &[ParamDef] = &[
    ParamDef::float("attack", "Attack", 1.0, 5000.0, 5.0).unit("ms").curve(Curve::Log).decimals(1),
    ParamDef::float("decay", "Decay", 1.0, 5000.0, 300.0).unit("ms").curve(Curve::Log).decimals(0),
    ParamDef::float("sustain", "Sustain", 0.0, 100.0, 70.0).unit("%").decimals(0),
    ParamDef::float("release", "Release", 5.0, 3000.0, 300.0).unit("ms").curve(Curve::Power(2.0)).decimals(0),
    // 0 %: every note is equally loud. 100 %: loudness follows velocity fully.
    ParamDef::float("velocity", "Velocity Sens", 0.0, 100.0, 80.0).unit("%").decimals(0),
    ParamDef::float("gain", "Gain", -48.0, 6.0, -12.0).unit("dB").decimals(1),
];
const ATTACK: ParamId = ParamId::of(PARAMS, "attack");
const DECAY: ParamId = ParamId::of(PARAMS, "decay");
const SUSTAIN: ParamId = ParamId::of(PARAMS, "sustain");
const RELEASE: ParamId = ParamId::of(PARAMS, "release");
const VELOCITY: ParamId = ParamId::of(PARAMS, "velocity");
const GAIN: ParamId = ParamId::of(PARAMS, "gain");

const VOICES: usize = 8;

/// Everything one sounding note needs. A plain `Copy` struct in a fixed array: no
/// allocation when a note starts.
#[derive(Clone, Copy)]
struct Voice {
    osc: Oscillator,
    /// This voice's own envelope position. The *times* are shared (see `AdsrShape`).
    env: Adsr,
    key: i16,
    velocity: f32,
    /// Is the key still down? A voice that is not held is in its release.
    held: bool,
    /// Order of arrival, to find the oldest.
    age: u64,
}

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

pub struct EnvelopeSynth {
    voices: [Voice; VOICES],
    /// Counts note-ons, so every voice gets a distinct age.
    counter: u64,
    /// The envelope times, shared by all voices; recomputed once per block.
    shape: AdsrShape,
    sample_rate: f32,
}

impl EnvelopeSynth {
    /// Pick the voice a new note will use.
    fn choose_voice(&self) -> usize {
        // 1. A free voice.
        if let Some(i) = self.voices.iter().position(|v| v.env.is_idle()) {
            return i;
        }
        // 2. Otherwise steal. A voice already releasing is on its way out: taking it is the
        //    least audible. Among equals, take the oldest note.
        (0..VOICES).min_by_key(|&i| (self.voices[i].held, self.voices[i].age)).unwrap_or(0)
    }

    fn note_on(&mut self, key: i16, velocity: f32) {
        self.counter += 1;
        let i = self.choose_voice();
        let voice = &mut self.voices[i];
        // A stolen voice is still sounding. Restarting its envelope at 0 would cut the
        // sound off in a click, so the new attack starts from the level it has.
        let level = voice.env.level;
        voice.env.start();
        voice.env.level = level;
        voice.osc.set_frequency(midi_to_hz(key as f32), self.sample_rate);
        voice.key = key;
        voice.velocity = velocity;
        voice.held = true;
        voice.age = self.counter;
    }

    fn note_off(&mut self, key: i16) {
        for voice in self.voices.iter_mut().filter(|v| v.held && v.key == key) {
            voice.held = false;
            voice.env.release();
        }
    }
}

impl Plugin for EnvelopeSynth {
    const INFO: PluginInfo = PluginInfo {
        id: "dev.miha.apf.example.envelope",
        name: "APF Example 04 Envelope",
        vendor: "Miha Smrekar",
        url: "",
        version: env!("CARGO_PKG_VERSION"),
        description: "A polyphonic synth with an ADSR envelope",
        features: &["instrument", "synthesizer", "stereo"],
    };
    const LAYOUT: AudioLayout = AudioLayout::INSTRUMENT_STEREO;
    const PARAMS: &'static [ParamDef] = PARAMS;

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

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

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

    fn event(&mut self, event: &Event, _ctx: &ProcessContext) {
        match *event {
            Event::NoteOn { key, velocity, .. } => self.note_on(key, velocity),
            Event::NoteOff { key, .. } => self.note_off(key),
            _ => {}
        }
    }

    fn process(&mut self, audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus {
        let p = ctx.params;
        // The envelope times are shared by all voices, so they are computed once per block.
        self.shape.set(self.sample_rate, p.get_f32(ATTACK), p.get_f32(DECAY), p.get_f32(SUSTAIN) * 0.01, p.get_f32(RELEASE));
        let sensitivity = p.get_f32(VELOCITY) * 0.01;
        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 voice in self.voices.iter_mut().filter(|v| !v.env.is_idle()) {
                // `tick` moves the envelope on by one sample and returns its level, 0..1.
                // When a release has died away the voice becomes idle by itself.
                let env = voice.env.tick(&self.shape);
                // Blend between "ignore velocity" (1.0) and "follow velocity".
                let loudness = 1.0 - sensitivity + sensitivity * voice.velocity;
                sum += voice.osc.tick(Waveform::Saw) * env * loudness;
            }
            *l = sum * gain;
            *r = sum * gain;
        }
        if self.voices.iter().any(|v| !v.env.is_idle()) {
            ProcessStatus::Continue
        } else {
            ProcessStatus::Sleep
        }
    }
}

apf::export_plugin!(EnvelopeSynth);

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

    fn sounding(synth: &EnvelopeSynth) -> usize {
        synth.voices.iter().filter(|v| !v.env.is_idle()).count()
    }

    #[test]
    fn a_ninth_note_steals_the_oldest_voice() {
        let mut synth = EnvelopeSynth::new();
        for key in 60..68 {
            synth.note_on(key, 0.8);
        }
        assert_eq!(sounding(&synth), 8);
        synth.note_on(80, 0.8);
        assert_eq!(sounding(&synth), 8, "still eight voices");
        let keys: Vec<i16> = synth.voices.iter().map(|v| v.key).collect();
        assert!(keys.contains(&80) && !keys.contains(&60), "key 60 was the oldest: {keys:?}");
    }

    #[test]
    fn releasing_voices_are_stolen_before_held_ones() {
        let mut synth = EnvelopeSynth::new();
        for key in 60..68 {
            synth.note_on(key, 0.8);
        }
        synth.note_off(64); // not the oldest, but on its way out
        synth.note_on(80, 0.8);
        let keys: Vec<i16> = synth.voices.iter().map(|v| v.key).collect();
        assert!(keys.contains(&60) && !keys.contains(&64), "{keys:?}");
    }
}
}

Reading it

One Adsr per voice, one AdsrShape for all. The times (attack, decay, sustain, release) are the same for every voice, so they are computed once per block into the shape. Each voice’s Adsr holds only where it is. tick returns the level and, when a release has died away, turns the voice idle by itself.

The voice pool. [Voice; 8] is a Copy array: starting a note writes into a slot and allocates nothing. choose_voice takes a free voice; if there is none it steals: a voice already releasing first (the least audible), then the oldest. The unit tests at the bottom of the file check those rules.

A click-free steal. A stolen voice is still sounding, so restarting its envelope at zero would cut the sound. note_on keeps the current level and ramps up from there.

Held or released. A voice remembers whether its key is still down; that is what note_off looks for. Example 09 adds the sustain pedal to the same idea.

Try it

  • Add a second oscillator an octave up per voice.
  • Steal the quietest voice instead of the oldest.
  • Add a Voices int parameter (1 to 8) that limits the polyphony.

Build and test

cargo test -p example-04-envelope
cargo xtask bundle example-04-envelope --install

05 Modulation: LFO, wheels

A one-voice lead with vibrato, tremolo, mod wheel and pitch bend.

You will learn

  • Lfo and its shapes
  • MIDI controllers in event: ControlChange and PitchBend
  • smoothing control values that arrive in coarse steps
  • modulating a parameter: what the user set stays; modulation is added on top

The code is in examples/05-modulation/. It is included here as it is on disk.

#![allow(unused)]
fn main() {
extern crate apf;
//! Example 05: modulation. An LFO, the mod wheel and the pitch wheel.
//!
//! *Modulation* means "a value that moves by itself, or under the player's hand, on top of
//! what the user set". Three sources here:
//!
//! * an [`Lfo`] (low-frequency oscillator): a slow wave, 0.1 to 20 times a second,
//! * the *mod wheel* (MIDI controller 1), which scales how deep the vibrato goes,
//! * the *pitch wheel*, which bends the note by up to a chosen number of semitones.
//!
//! The rule to remember: **a parameter holds what the user set; modulation is added to it
//! inside `process` and never written back.** The "Vibrato" parameter below stays at 30
//! cents while the pitch that comes out moves around it.

use apf::prelude::*;

const PARAMS: &[ParamDef] = &[
    ParamDef::choice("wave", "Waveform", &["Sine", "Saw", "Square", "Triangle"], 1).module("Oscillator"),
    ParamDef::float("rate", "LFO Rate", 0.1, 20.0, 5.0).unit("Hz").curve(Curve::Log).decimals(2).module("LFO"),
    ParamDef::choice("shape", "LFO Shape", &["Sine", "Triangle", "Saw", "Square", "Sample & Hold"], 0).module("LFO"),
    // The vibrato depth at full mod wheel, in cents (a hundredth of a semitone).
    ParamDef::float("vibrato", "Vibrato", 0.0, 200.0, 30.0).unit("cents").decimals(0).module("LFO"),
    ParamDef::float("tremolo", "Tremolo", 0.0, 100.0, 0.0).unit("%").decimals(0).module("LFO"),
    ParamDef::int("bend_range", "Bend Range", 0, 24, 2).unit("st").module("Pitch"),
    ParamDef::float("gain", "Gain", -48.0, 6.0, -12.0).unit("dB").decimals(1),
];
const WAVE: ParamId = ParamId::of(PARAMS, "wave");
const RATE: ParamId = ParamId::of(PARAMS, "rate");
const SHAPE: ParamId = ParamId::of(PARAMS, "shape");
const VIBRATO: ParamId = ParamId::of(PARAMS, "vibrato");
const TREMOLO: ParamId = ParamId::of(PARAMS, "tremolo");
const BEND_RANGE: ParamId = ParamId::of(PARAMS, "bend_range");
const GAIN: ParamId = ParamId::of(PARAMS, "gain");

pub struct Modulated {
    osc: Oscillator,
    lfo: Lfo,
    /// The sounding note's frequency, before any modulation.
    base_hz: f32,
    key: i16,
    /// Note loudness, glided (no click on start and stop).
    level: Smoother,
    /// Wheel positions arrive in coarse steps (128 for the mod wheel); smoothing the
    /// values removes the audible "zipper" of a stepped control.
    wheel: Smoother,
    bend: Smoother,
    sample_rate: f32,
}

impl Plugin for Modulated {
    const INFO: PluginInfo = PluginInfo {
        id: "dev.miha.apf.example.modulation",
        name: "APF Example 05 Modulation",
        vendor: "Miha Smrekar",
        url: "",
        version: env!("CARGO_PKG_VERSION"),
        description: "LFO vibrato and tremolo, mod wheel and pitch bend",
        features: &["instrument", "synthesizer", "stereo"],
    };
    const LAYOUT: AudioLayout = AudioLayout::INSTRUMENT_STEREO;
    const PARAMS: &'static [ParamDef] = PARAMS;

    fn new() -> Self {
        Self {
            osc: Oscillator::new(),
            lfo: Lfo::new(LfoShape::Sine),
            base_hz: 440.0,
            key: -1,
            level: Smoother::new(0.0),
            wheel: Smoother::new(0.0),
            bend: Smoother::new(0.0),
            sample_rate: 48_000.0,
        }
    }

    fn activate(&mut self, config: &ProcessConfig, _params: &Params) {
        self.sample_rate = config.sample_rate as f32;
        self.level.set_time(config.sample_rate, 5.0);
        self.wheel.set_time(config.sample_rate, 10.0);
        self.bend.set_time(config.sample_rate, 5.0);
        self.reset();
    }

    fn reset(&mut self) {
        self.level.reset(0.0);
        self.wheel.reset(0.0);
        self.bend.reset(0.0);
        self.lfo.reset();
        self.key = -1;
    }

    fn event(&mut self, event: &Event, _ctx: &ProcessContext) {
        match *event {
            Event::NoteOn { key, velocity, .. } => {
                self.key = key;
                self.base_hz = midi_to_hz(key as f32);
                self.level.set_target(0.5 * velocity);
            }
            Event::NoteOff { key, .. } if key == self.key => {
                self.key = -1;
                self.level.set_target(0.0);
            }
            // The mod wheel is MIDI controller 1; `value` is already scaled to 0..1.
            Event::ControlChange { cc: cc::MOD_WHEEL, value, .. } => self.wheel.set_target(value),
            // The pitch wheel: -1 (down) .. 0 (centre) .. +1 (up).
            Event::PitchBend { value, .. } => self.bend.set_target(value),
            _ => {}
        }
    }

    fn process(&mut self, audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus {
        let p = ctx.params;
        let wave = Waveform::from_index(p.get_index(WAVE));
        self.lfo.set_shape(LfoShape::from_index(p.get_index(SHAPE)));
        self.lfo.set_rate(p.get_f32(RATE), self.sample_rate); // ticked once per sample
        let vibrato_cents = p.get_f32(VIBRATO);
        let tremolo = p.get_f32(TREMOLO) * 0.01;
        let bend_range = p.get_f32(BEND_RANGE);
        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 lfo = self.lfo.tick(); // -1 .. 1
            // Pitch = the note + the pitch wheel + vibrato, all added in semitones. Adding
            // semitones multiplies the frequency: that is how pitch works.
            let semitones = self.bend.tick() * bend_range + lfo * vibrato_cents * 0.01 * self.wheel.tick();
            self.osc.set_frequency(self.base_hz * (semitones / 12.0).exp2(), self.sample_rate);
            // Tremolo: the LFO pushes the loudness down from full by up to `tremolo`.
            let tremolo_gain = 1.0 - tremolo * (0.5 - 0.5 * lfo);
            let sample = self.osc.tick(wave) * self.level.tick() * tremolo_gain * gain;
            *l = sample;
            *r = sample;
        }
        if self.key < 0 && self.level.value() < 1e-4 {
            self.level.reset(0.0);
        }
        if self.level.value() == 0.0 {
            ProcessStatus::Sleep
        } else {
            ProcessStatus::Continue
        }
    }
}

apf::export_plugin!(Modulated);
}

Reading it

Modulation is not a parameter change. The “Vibrato” parameter says how deep the vibrato may go at full mod wheel. The LFO times the wheel times that depth gives a number of cents this sample, which process adds to the pitch. Nothing writes back to the parameter, so the user’s setting, the host’s automation and a saved session are untouched.

Semitones add, frequencies multiply. Pitch is logarithmic: the note, the bend and the vibrato are summed in semitones and converted once with exp2.

Zipper noise. A 7-bit controller moves in 128 steps. Fed straight into the pitch they would be heard as stairs; a 5 to 10 ms smoother rounds them off.

Per sample or per block. The LFO is ticked every sample here because vibrato wants smooth pitch. For slow changes you could tick once per block with set_rate(hz, sample_rate / frames).

Try it

  • Add a delay before the vibrato starts (the way a violinist does).
  • Route the LFO to the waveform’s pulse width, or to the filter in example 06.
  • Make Sample & Hold a stepped pitch for a random-arpeggio effect.

Build and test

cargo test -p example-05-modulation
cargo xtask bundle example-05-modulation --install

06 Filter: cutoff, resonance, denormals

A filter effect with four modes.

You will learn

  • the state-variable filter: Svf and SvfCoeffs
  • computing coefficients at a lower rate than the audio
  • smoothing a cutoff
  • denormals

The code is in examples/06-filter/. It is included here as it is on disk.

#![allow(unused)]
fn main() {
extern crate apf;
//! Example 06: a filter effect. A state-variable filter with a smoothed cutoff.
//!
//! New in this example:
//!
//! * the state-variable filter ([`Svf`]): one filter that makes a low-pass, band-pass,
//!   high-pass and notch at once,
//! * filter *coefficients*: numbers computed from the cutoff and resonance that are
//!   expensive to calculate (a `tan`), so they are not recomputed more often than needed,
//! * smoothing a cutoff that the user drags or a host automates,
//! * *denormals*: tiny numbers that make a CPU slow, and what to do about them.

use apf::prelude::*;

const PARAMS: &[ParamDef] = &[
    ParamDef::choice("mode", "Mode", &["Low-pass", "Band-pass", "High-pass", "Notch"], 0),
    ParamDef::float("cutoff", "Cutoff", 20.0, 20_000.0, 1000.0).unit("Hz").curve(Curve::Log).decimals(0),
    ParamDef::float("resonance", "Resonance", 0.0, 100.0, 20.0).unit("%").decimals(0),
];
const MODE: ParamId = ParamId::of(PARAMS, "mode");
const CUTOFF: ParamId = ParamId::of(PARAMS, "cutoff");
const RESONANCE: ParamId = ParamId::of(PARAMS, "resonance");

/// The coefficients are recomputed every 16 samples. At 48 kHz that is 3000 times a second:
/// plenty to follow any sweep smoothly, and 16 times cheaper than doing it every sample.
const CONTROL_RATE: usize = 16;

pub struct FilterFx {
    /// One filter per channel: each remembers its own past samples.
    filters: [Svf; 2],
    /// The coefficients both channels share.
    coeffs: SvfCoeffs,
    /// The cutoff glides to its new value. This filter stays continuous when its cutoff
    /// jumps, but a sweep made of steps (a knob dragged, coarse host automation) is audible
    /// as "zipper" noise; the glide turns the steps into a smooth sweep.
    cutoff: Smoother,
    sample_rate: f32,
}

impl Plugin for FilterFx {
    const INFO: PluginInfo = PluginInfo {
        id: "dev.miha.apf.example.filter",
        name: "APF Example 06 Filter",
        vendor: "Miha Smrekar",
        url: "",
        version: env!("CARGO_PKG_VERSION"),
        description: "A state-variable filter",
        features: &["audio-effect", "filter", "stereo"],
    };
    const LAYOUT: AudioLayout = AudioLayout::EFFECT_STEREO;
    const PARAMS: &'static [ParamDef] = PARAMS;

    fn new() -> Self {
        Self {
            filters: [Svf::new(); 2],
            coeffs: SvfCoeffs::new(1000.0, 1.0, 48_000.0),
            cutoff: Smoother::new(1000.0),
            sample_rate: 48_000.0,
        }
    }

    fn activate(&mut self, config: &ProcessConfig, params: &Params) {
        self.sample_rate = config.sample_rate as f32;
        self.cutoff.set_time(config.sample_rate, 10.0);
        self.cutoff.reset(params.get_f32(CUTOFF));
        self.reset();
    }

    fn reset(&mut self) {
        self.filters.iter_mut().for_each(Svf::reset);
    }

    fn process(&mut self, audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus {
        let p = ctx.params;
        self.cutoff.set_target(p.get_f32(CUTOFF));
        let resonance = p.get_f32(RESONANCE) * 0.01;
        let mode = p.get_index(MODE);

        let Some((left, right)) = audio.stereo() else { return ProcessStatus::Error };
        for (i, (l, r)) in left.iter_mut().zip(right.iter_mut()).enumerate() {
            // Always tick the smoother (it must keep gliding), but only turn its value into
            // coefficients every few samples.
            let cutoff = self.cutoff.tick();
            if i % CONTROL_RATE == 0 {
                self.coeffs = SvfCoeffs::from_resonance(cutoff, resonance, self.sample_rate);
            }
            let out_l = self.filters[0].process(*l, &self.coeffs);
            let out_r = self.filters[1].process(*r, &self.coeffs);
            (*l, *r) = match mode {
                1 => (out_l.band, out_r.band),
                2 => (out_l.high, out_r.high),
                3 => (out_l.notch, out_r.notch),
                _ => (out_l.low, out_r.low),
            };
        }
        // Denormals. A filter that is left to ring out decays towards zero, through numbers
        // so small (below 1e-38) that many CPUs take a hundred times longer to multiply
        // them: a quiet filter can then use more CPU than a loud one. APF turns the CPU's
        // "flush to zero" mode on while a plugin processes, which already prevents it; this
        // call is the portable second line of defence and makes the state exactly zero.
        self.filters.iter_mut().for_each(Svf::flush);
        ProcessStatus::Continue
    }
}

apf::export_plugin!(FilterFx);
}

Reading it

Coefficients. A filter is two multiply-adds per sample once its coefficients are known; computing them needs a tan, which is far more expensive. The coefficients only change when the cutoff or resonance does, so the loop recomputes them every 16 samples (3000 times a second at 48 kHz): imperceptibly different from every sample, and 16 times cheaper.

The smoothed cutoff. The smoother ticks every sample; the coefficients are rebuilt from its value every 16th. This filter stays continuous when the cutoff jumps, but a sweep made of steps is heard as zipper noise; the glide makes it smooth.

All four outputs. One Svf::process gives the low-pass, band-pass, high-pass and notch. You pick one, or mix them.

Resonance is mapped to the filter’s damping k (SvfCoeffs::from_resonance). At 0 the filter is gentle, near 1 it rings and at the end of the range it would self-oscillate.

Denormals. A ringing filter decays towards zero through numbers so small that some CPUs take up to a hundred times longer to compute with them: a quiet filter can cost more than a loud one. APF switches the CPU’s flush-to-zero mode on during process; Svf::flush at the end of the block is the portable second line of defence and brings the state to exactly zero (the last test of this example checks that a ring-out ends in exact silence).

Try it

  • Add a Drive parameter before the filter (a tanh of the input).
  • Mix low and high outputs for a “tilt” filter.
  • Compute the coefficients every 64 samples and listen for the difference when you sweep fast.

Build and test

cargo test -p example-06-filter
cargo xtask bundle example-06-filter --install

07 GUI: bindings and a meter

A gain with a web view: a slider, a checkbox, a select and a level meter.

You will learn

  • how a page is embedded and shown (WebView, assets!)
  • data-param bindings
  • getting data from process to the page: atomics, a Controller, apf.poller
  • a resizable web view

The code is in examples/07-gui/. It is included here as it is on disk; cargo test -p example-07-gui builds it.

//! Example 07: a web view. Parameter bindings, a level meter, a resizable window.
//!
//! The web view of an APF plugin is an ordinary web page (HTML, CSS, JavaScript) shown in the
//! system's web view inside the host's plugin window. The page and the plugin talk in two ways:
//!
//! 1. **Parameters**, with no code at all: an element with `data-param="gain"` is bound to the
//!    parameter whose key is `gain`. `apf.js` (loaded from `/__apf/apf.js`) keeps the two in
//!    step in both directions, tells the host about gestures (so automation recording
//!    works), and formats the text. See `ui/index.html`.
//! 2. **Anything else**, through a [`Controller`]: the page asks with
//!    `apf.plugin.get("meter")`, the controller answers. Here that is a level meter: the
//!    audio thread writes peak levels into atomics, and the page reads them a few dozen
//!    times a second.
//!
//! The page never touches the audio thread. It could not wait for it, and the audio thread
//! must never wait for a page.

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;

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

const PARAMS: &[ParamDef] = &[
    ParamDef::float("gain", "Gain", -60.0, 12.0, 0.0).unit("dB").decimals(1),
    ParamDef::bool("mute", "Mute", false),
    ParamDef::choice("listen", "Listen", &["Both", "Left", "Right"], 0),
];
const GAIN: ParamId = ParamId::of(PARAMS, "gain");
const MUTE: ParamId = ParamId::of(PARAMS, "mute");
const LISTEN: ParamId = ParamId::of(PARAMS, "listen");

/// The files of the web view, compiled into the plugin. `index.html` is the page.
const UI: &[Asset] = assets!["index.html" => "../ui/index.html"];

/// The loudest sample of each channel since the page last asked, written by the audio
/// thread and read by the controller. Atomics are the way to hand a number to another thread
/// without waiting.
#[derive(Default)]
pub struct Meter {
    // A non-negative float's bits are ordered like the float itself, so `fetch_max` on the
    // bits keeps the largest value.
    left: AtomicU32,
    right: AtomicU32,
}

impl Meter {
    /// Audio thread: note the peaks of a block.
    fn record(&self, left: f32, right: f32) {
        self.left.fetch_max(left.to_bits(), Ordering::Relaxed);
        self.right.fetch_max(right.to_bits(), Ordering::Relaxed);
    }

    /// Controller: the peaks since the last call, and start over.
    fn take(&self) -> (f32, f32) {
        (f32::from_bits(self.left.swap(0, Ordering::Relaxed)), f32::from_bits(self.right.swap(0, Ordering::Relaxed)))
    }
}

/// Answers the page. It has no session state of its own (the parameters hold everything), so
/// saving, loading and the revision are trivial; see example 08 for a controller with state.
struct MeterController {
    meter: Arc<Meter>,
}

impl Controller for MeterController {
    fn save(&self) -> String {
        String::new()
    }
    fn load(&self, _text: &str) -> Result<(), String> {
        Ok(())
    }
    fn reset(&self) {}
    fn revision(&self) -> u64 {
        0
    }

    // `apf.plugin.get("meter")` in the page ends up here.
    fn request(&self, request: &ControllerRequest) -> ControllerResponse {
        match request.path {
            "meter" => {
                let (l, r) = self.meter.take();
                ControllerResponse::json(format!("{{\"l\":{l},\"r\":{r}}}"))
            }
            _ => ControllerResponse::not_found(),
        }
    }
}

pub struct GuiGain {
    gain: Smoother,
    meter: Arc<Meter>,
}

impl Plugin for GuiGain {
    const INFO: PluginInfo = PluginInfo {
        id: "dev.miha.apf.example.gui",
        name: "APF Example 07 GUI",
        vendor: "Miha Smrekar",
        url: "",
        version: env!("CARGO_PKG_VERSION"),
        description: "A gain with a web view and a level meter",
        features: &["audio-effect", "utility", "stereo"],
    };
    const LAYOUT: AudioLayout = AudioLayout::EFFECT_STEREO;
    const PARAMS: &'static [ParamDef] = PARAMS;

    fn new() -> Self {
        Self { gain: Smoother::new(1.0), meter: Arc::new(Meter::default()) }
    }

    // The framework calls this once per plugin instance. The controller and the plugin
    // share the meter through the same `Arc`.
    fn controller(&mut self, _host: HostHandle) -> Option<Arc<dyn Controller>> {
        Some(Arc::new(MeterController { meter: self.meter.clone() }))
    }

    fn activate(&mut self, config: &ProcessConfig, params: &Params) {
        self.gain.set_time(config.sample_rate, 20.0);
        self.gain.reset(target_gain(params));
    }

    fn process(&mut self, audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus {
        self.gain.set_target(target_gain(ctx.params));
        let listen = ctx.params.get_index(LISTEN);
        let Some((left, right)) = audio.stereo() else { return ProcessStatus::Error };
        let (mut peak_l, mut peak_r) = (0.0f32, 0.0f32);
        for (l, r) in left.iter_mut().zip(right.iter_mut()) {
            let g = self.gain.tick();
            let (a, b) = match listen {
                1 => (*l * g, 0.0),
                2 => (0.0, *r * g),
                _ => (*l * g, *r * g),
            };
            (*l, *r) = (a, b);
            peak_l = peak_l.max(a.abs());
            peak_r = peak_r.max(b.abs());
        }
        self.meter.record(peak_l, peak_r);
        ProcessStatus::Continue
    }

    fn view() -> Option<Box<dyn View>> {
        // 420 x 240 points to start with; the user may resize between the limits. The
        // background colour shows before the page has painted, so the window does not flash.
        Some(Box::new(WebView::new(420, 240, UI).resizable(true).size_limits((320, 200), (2400, 1600)).background(18, 19, 24)))
    }
}

/// The linear gain the parameters ask for: 0 when muted.
fn target_gain(params: &Params) -> f32 {
    if params.get_bool(MUTE) {
        0.0
    } else {
        db_to_gain(params.get_f32(GAIN))
    }
}

apf::export_plugin!(GuiGain);

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

    #[test]
    fn the_meter_keeps_the_loudest_peak_until_it_is_read() {
        let m = Meter::default();
        m.record(0.2, 0.0);
        m.record(0.7, 0.1);
        m.record(0.4, 0.05);
        assert_eq!(m.take(), (0.7, 0.1));
        assert_eq!(m.take(), (0.0, 0.0), "reading starts a new interval");
    }

    #[test]
    fn the_controller_answers_the_page() {
        let meter = Arc::new(Meter::default());
        meter.record(0.5, 0.25);
        let controller = MeterController { meter };
        let get = |path| controller.request(&ControllerRequest { method: "GET", path, query: "", body: b"" });
        let reply = get("meter");
        assert_eq!((reply.status, String::from_utf8(reply.body).unwrap()), (200, "{\"l\":0.5,\"r\":0.25}".into()));
        assert_eq!(get("nothing").status, 404);
    }

    /// Every parameter key the page mentions must exist: a typo in the HTML is otherwise
    /// found only by looking at the web view.
    #[test]
    fn the_page_binds_existing_parameters() {
        let html = include_str!("../ui/index.html");
        let mut found = 0;
        for attribute in ["data-param=\"", "data-param-text=\"", "data-param-name=\""] {
            for part in html.split(attribute).skip(1) {
                let key = part.split('"').next().unwrap();
                assert!(PARAMS.iter().any(|p| p.key == key), "the page binds {key:?}, which is not a parameter");
                found += 1;
            }
        }
        assert!(found >= 6, "the page binds the parameters");
    }
}

Reading it

Open ui/index.html first: it is short, and every binding is one attribute. The <div class="meter"> is the only part that needs script.

The meter’s path. process computes each channel’s peak and writes it with fetch_max into an atomic (Meter::record). The page asks apf.plugin.get("meter"); the controller’s request swaps the atomics for zero and returns the peaks since the last request. The audio thread never waits for the page and the page never touches the audio thread.

apf.poller. The page polls, and polling costs something, so apf.poller polls every 33 ms while the function returns true (the meter is moving), backs off to 100 and then 250 ms when the signal is silent, and stops when the web view is hidden.

Resizing. .resizable(true).size_limits(..) on the WebView, a layout that fills the window, and <div data-apf-resize-grip>. The test host expects a resizable web view to allow at least the size of the screen, so the upper limit here is large.

A test for the page. A unit test reads ui/index.html and checks that every key the page binds is a real parameter. A typo is otherwise found only by looking at the web view.

Run the web view

cargo xtask bundle example-07-gui --formats clap
cargo run --release -p apf-host -- "dist/release/APF Example 07 GUI.clap" --gui-test

--gui-test opens the web view in a window, edits a parameter from the page and checks that the host sees it, then the other way round, then resizes the window. Set APF_UI_DIR=examples/07-gui/ui to edit the page and reload without rebuilding.

Try it

  • Add a peak-hold line to the meter in the page.
  • Add a second bound slider (a pan parameter) and see it appear with no Rust change but the parameter.
  • Return RMS as well as peak from the controller.

Build and test

cargo test -p example-07-gui
cargo xtask bundle example-07-gui --install

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

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

10 A complete synth

A compact polyphonic synth that puts examples 04 to 09 together: eight voices, an envelope, a filter, an LFO and the wheels, a web view, factory presets, and MIDI learn.

You will learn

  • how the pieces of the earlier examples fit into one plugin
  • how modulation (LFO and mod wheel) moves the cutoff without touching the parameter
  • what a complete plugin’s tests look like

The code is in examples/10-synth/. It is included here as it is on disk; cargo test -p example-10-synth builds it.

//! Example 10: a compact, complete polyphonic synth. It puts together what examples 04 to 09
//! teach, in one readable file. (`examples/demo-synth` is the full-featured synth; this is
//! the same idea with everything that is not essential left out.)
//!
//! * 04: voices, an ADSR envelope per voice, velocity, stealing the oldest voice,
//! * 05: an LFO, the mod wheel and the pitch wheel, applied on top of the parameters,
//! * 06: a state-variable filter with a cutoff that glides,
//! * 07: a web view whose controls are bound to parameters,
//! * 08: factory presets with metadata, and the preset bar in the web view,
//! * 09: MIDI learn for free (right-click a control), and remote controls.
//!
//! The signal path: oscillator -> filter -> envelope -> sum of the voices -> gain.
//! The LFO and the mod wheel move the filter's cutoff, the pitch wheel bends all voices.

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

// The order matters: Audio Unit hosts list parameters in it, and Logic's Smart Controls take
// the first ones. Keys never change; new parameters are appended (example 03).
const PARAMS: &[ParamDef] = &[
    ParamDef::float("cutoff", "Cutoff", 20.0, 20_000.0, 1800.0).unit("Hz").curve(Curve::Log).decimals(0).module("Filter"),
    ParamDef::float("resonance", "Resonance", 0.0, 100.0, 25.0).unit("%").decimals(0).module("Filter"),
    ParamDef::choice("wave", "Waveform", &["Sine", "Saw", "Square", "Triangle"], 1).module("Oscillator"),
    ParamDef::float("attack", "Attack", 1.0, 3000.0, 5.0).unit("ms").curve(Curve::Log).decimals(0).module("Amp"),
    ParamDef::float("decay", "Decay", 1.0, 3000.0, 300.0).unit("ms").curve(Curve::Log).decimals(0).module("Amp"),
    ParamDef::float("sustain", "Sustain", 0.0, 100.0, 70.0).unit("%").decimals(0).module("Amp"),
    ParamDef::float("release", "Release", 5.0, 4000.0, 300.0).unit("ms").curve(Curve::Power(2.0)).decimals(0).module("Amp"),
    ParamDef::float("lfo_rate", "LFO Rate", 0.1, 20.0, 4.0).unit("Hz").curve(Curve::Log).decimals(1).module("LFO"),
    // How far the LFO swings the cutoff. The mod wheel adds to it: at full wheel the depth is 100 %.
    ParamDef::float("lfo_depth", "LFO Depth", 0.0, 100.0, 0.0).unit("%").decimals(0).module("LFO"),
    ParamDef::int("bend_range", "Bend Range", 0, 24, 2).unit("st").module("Pitch"),
    ParamDef::float("gain", "Gain", -48.0, 6.0, -9.0).unit("dB").decimals(1).module("Amp"),
];
const CUTOFF: ParamId = ParamId::of(PARAMS, "cutoff");
const RESONANCE: ParamId = ParamId::of(PARAMS, "resonance");
const WAVE: ParamId = ParamId::of(PARAMS, "wave");
const ATTACK: ParamId = ParamId::of(PARAMS, "attack");
const DECAY: ParamId = ParamId::of(PARAMS, "decay");
const SUSTAIN: ParamId = ParamId::of(PARAMS, "sustain");
const RELEASE: ParamId = ParamId::of(PARAMS, "release");
const LFO_RATE: ParamId = ParamId::of(PARAMS, "lfo_rate");
const LFO_DEPTH: ParamId = ParamId::of(PARAMS, "lfo_depth");
const BEND_RANGE: ParamId = ParamId::of(PARAMS, "bend_range");
const GAIN: ParamId = ParamId::of(PARAMS, "gain");

const UI: &[Asset] = assets!["index.html" => "../ui/index.html"];
const VOICES: usize = 8;
/// At full depth the LFO swings the cutoff three octaves up and down.
const LFO_OCTAVES: f32 = 3.0;
/// Filter coefficients cost a `tan`: refresh them every 16 samples (example 06).
const CONTROL_RATE: usize = 16;

/// One note. A plain `Copy` struct in a fixed array: no allocation on the audio thread.
#[derive(Clone, Copy)]
struct Voice {
    osc: Oscillator,
    filter: Svf,
    env: Adsr,
    /// The oscillator's phase step for the key, before the pitch wheel.
    step: f32,
    velocity: f32,
    key: i16,
    /// The key is still down (a releasing voice is not).
    held: bool,
    /// The order of the note-ons, to find the oldest voice.
    age: u64,
}

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

pub struct Synth {
    voices: [Voice; VOICES],
    counter: u64,
    /// Envelope times, shared by the voices; recomputed from the parameters every block.
    shape: AdsrShape,
    /// One set of filter coefficients for all voices: they share the cutoff and the LFO.
    coeffs: SvfCoeffs,
    lfo: Lfo,
    /// The smoothed controls: the knobs that are heard as clicks when they jump, and the
    /// wheels, which arrive in coarse steps.
    cutoff: Smoother,
    gain: Smoother,
    wheel: Smoother,
    bend: Smoother,
    sample_rate: f32,
}

impl Synth {
    fn note_on(&mut self, key: i16, velocity: f32) {
        // The LFO is shared by all voices, so it runs on while notes overlap. A note that
        // starts from silence restarts it: the same keys then always sound the same.
        if self.voices.iter().all(|v| v.env.is_idle()) {
            self.lfo.reset();
        }
        self.counter += 1;
        // Voice stealing: a free voice if there is one, else the one that has been going
        // longest, preferring a voice that is already releasing to one whose key is down.
        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 voice = IDLE;
        voice.step = midi_to_hz(key as f32) / self.sample_rate;
        voice.env.start();
        (voice.velocity, voice.key, voice.held, voice.age) = (velocity, key, true, self.counter);
        self.voices[slot] = voice;
    }

    fn note_off(&mut self, key: i16) {
        for v in self.voices.iter_mut().filter(|v| v.held && v.key == key) {
            v.held = false;
            v.env.release();
        }
    }
}

impl Plugin for Synth {
    const INFO: PluginInfo = PluginInfo {
        id: "dev.miha.apf.example.synth",
        name: "APF Example 10 Synth",
        vendor: "Miha Smrekar",
        url: "",
        version: env!("CARGO_PKG_VERSION"),
        description: "A compact polyphonic synth with filter, LFO and presets",
        features: &["instrument", "synthesizer", "stereo"],
    };
    const LAYOUT: AudioLayout = AudioLayout::INSTRUMENT_STEREO;
    const PARAMS: &'static [ParamDef] = PARAMS;
    // One page of eight knobs for a hardware controller (example 09).
    const REMOTE_CONTROLS: &'static [RemotePage] = &[RemotePage {
        section: "",
        name: "Sound",
        params: ["cutoff", "resonance", "wave", "attack", "decay", "sustain", "release", "lfo_depth"],
    }];
    // The preset files, compiled in (example 08). Each names this plugin and carries metadata.
    const FACTORY_PRESETS: &'static [&'static str] =
        apf::presets!["../presets/Warm Pad.apfpreset", "../presets/Pluck.apfpreset", "../presets/Sweep Lead.apfpreset", "../presets/Soft Bass.apfpreset"];

    fn new() -> Self {
        Self {
            voices: [IDLE; VOICES],
            counter: 0,
            shape: AdsrShape::default(),
            coeffs: SvfCoeffs::new(1000.0, 1.0, 48_000.0),
            lfo: Lfo::new(LfoShape::Sine),
            cutoff: Smoother::new(1800.0),
            gain: Smoother::new(0.35),
            wheel: Smoother::new(0.0),
            bend: Smoother::new(0.0),
            sample_rate: 48_000.0,
        }
    }

    fn activate(&mut self, config: &ProcessConfig, params: &Params) {
        self.sample_rate = config.sample_rate as f32;
        self.cutoff.set_time(config.sample_rate, 10.0);
        self.gain.set_time(config.sample_rate, 10.0);
        self.wheel.set_time(config.sample_rate, 10.0);
        self.bend.set_time(config.sample_rate, 5.0);
        // Start where the knobs are, not where the defaults are.
        self.cutoff.reset(params.get_f32(CUTOFF));
        self.gain.reset(db_to_gain(params.get_f32(GAIN)));
        self.reset();
    }

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

    fn event(&mut self, event: &Event, _ctx: &ProcessContext) {
        match *event {
            Event::NoteOn { key, velocity, .. } => self.note_on(key, velocity),
            Event::NoteOff { key, .. } => self.note_off(key),
            // The wheels are only remembered here; `process` applies them (example 05).
            Event::ControlChange { cc: cc::MOD_WHEEL, value, .. } => self.wheel.set_target(value),
            Event::PitchBend { value, .. } => self.bend.set_target(value),
            _ => {}
        }
    }

    fn process(&mut self, audio: &mut AudioBuffer, ctx: &ProcessContext) -> ProcessStatus {
        // Parameters are read once per call. The framework calls `process` once per piece
        // between two events, so these are the values in force at the piece's first sample.
        let p = ctx.params;
        self.shape.set(self.sample_rate, p.get_f32(ATTACK), p.get_f32(DECAY), p.get_f32(SUSTAIN) * 0.01, p.get_f32(RELEASE));
        self.cutoff.set_target(p.get_f32(CUTOFF));
        self.gain.set_target(db_to_gain(p.get_f32(GAIN)));
        self.lfo.set_rate(p.get_f32(LFO_RATE), self.sample_rate);
        let resonance = p.get_f32(RESONANCE) * 0.01;
        let depth = p.get_f32(LFO_DEPTH) * 0.01;
        let bend_range = p.get_f32(BEND_RANGE);
        let wave = Waveform::from_index(p.get_index(WAVE));

        let Some((left, right)) = audio.stereo() else { return ProcessStatus::Error };
        for (i, (l, r)) in left.iter_mut().zip(right.iter_mut()).enumerate() {
            // Modulation is added here and never written back to the parameters (example 05).
            let lfo = self.lfo.tick();
            let wheel = self.wheel.tick();
            let cutoff = self.cutoff.tick();
            // The pitch wheel as a frequency ratio: semitones add, so frequencies multiply.
            let bend = (self.bend.tick() * bend_range / 12.0).exp2();
            if i % CONTROL_RATE == 0 {
                // The wheel pushes the LFO depth from its setting up to 100 %; the LFO then
                // moves the cutoff in octaves, which is how pitch-like things should move.
                let swing = lfo * (depth + wheel * (1.0 - depth)) * LFO_OCTAVES;
                self.coeffs = SvfCoeffs::from_resonance(cutoff * swing.exp2(), resonance, self.sample_rate);
            }
            let mut sum = 0.0;
            for v in self.voices.iter_mut().filter(|v| !v.env.is_idle()) {
                v.osc.set_step(v.step * bend);
                let tone = v.osc.tick(wave) * v.velocity;
                sum += v.filter.lowpass(tone, &self.coeffs) * v.env.tick(&self.shape);
            }
            let out = sum * self.gain.tick();
            *l = out;
            *r = out;
        }
        // Filters that ring out decay into denormal numbers; clear them (example 06).
        self.voices.iter_mut().for_each(|v| v.filter.flush());
        // Sleep once every voice has finished its release (example 01).
        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(720, 330, UI).background(18, 19, 24)))
    }
}

apf::export_plugin!(Synth);

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

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

    #[test]
    fn a_ninth_note_steals_the_oldest_voice() {
        let mut synth = Synth::new();
        synth.sample_rate = 48_000.0;
        for key in 40..40 + VOICES as i16 {
            synth.note_on(key, 1.0);
        }
        synth.note_on(80, 1.0);
        let keys: Vec<i16> = synth.voices.iter().map(|v| v.key).collect();
        assert!(!keys.contains(&40) && keys.contains(&80) && keys.contains(&41), "{keys:?}");
        // A voice that is releasing goes before one whose key is still down.
        synth.note_off(45);
        synth.note_on(81, 1.0);
        let keys: Vec<i16> = synth.voices.iter().map(|v| v.key).collect();
        assert!(!keys.contains(&45) && keys.contains(&41), "{keys:?}");
    }
}

Reading it

The signal path is: oscillator, filter, envelope, sum of the voices, gain. The LFO and the mod wheel move the filter’s cutoff, and the pitch wheel bends every voice.

IdeaExampleWhere in this plugin
the Plugin trait, events at their sample01impl Plugin for Synth
parameters, curves, append-only03PARAMS
envelope, voices, stealing04note_on, Voice
vibrato, mod wheel, bend05the wheel and bend smoothers in process
a filter and denormals06Svf, CONTROL_RATE, flush
the web view07ui/index.html, view()
factory presets08FACTORY_PRESETS, presets/
remote controls and MIDI learn09REMOTE_CONTROLS

Modulation is added, not written back. process computes the cutoff for this moment from the knob, the LFO and the wheel. The LFO swings the cutoff in octaves (three at full depth), because pitch-like things move in ratios; the mod wheel pushes the depth from its setting up to 100 %.

One set of filter coefficients for all voices. They share the cutoff and the LFO, and the coefficients cost a tan, so they are refreshed every 16 samples (example 06).

Voice stealing prefers a releasing voice to one whose key is still down, and then the oldest. A unit test at the bottom of the file checks the rule.

The LFO restarts when a note starts from silence, so the same keys always sound the same. Without that, the same keys sound different after a different history, and a restored session does not sound like the saved one. A test (a_note_from_silence_always_sounds_the_same) covers it, and so does cargo xtask validate.

The tests

tests/process.rs drives the plugin through the CLAP interface the way a host does and measures the audio: three keys sound as three notes (a Goertzel filter finds each pitch), velocity sets the loudness, the envelope releases and the plugin sleeps, the pitch wheel bends by the bend range, the mod wheel opens the LFO to the cutoff, a low cutoff darkens the sound, and many notes with wheels moving never allocate or go silent by mistake. Every factory preset is checked to name this plugin, describe itself and apply to the parameters.

Build and validate it

cargo test -p example-10-synth
cargo xtask bundle example-10-synth
cargo xtask validate example-10-synth     # clap-validator, VST3 validator, auval, state checks

The full demo synth

examples/demo-synth is the full-featured synthesizer; the one above is a simplified, readable version that shares none of its code (and the one to start a synth of your own from). The demo has 16 voices, a resonant filter per voice, a sustain pedal and a mod wheel with four targets. What it adds on top of the ladder is worth reading next.

A very large test. tests/clap_host.rs drives the plugin through the C interface the way a host does: sustain pedal, pitch bend, the mod wheel’s four targets, random playing that must never drop out, presets, and the counting allocator. It also holds the test that makes “never rename a key” real: a session saved by the first version of the plugin must still sound the same, sample for sample, as the first version’s DSP. When you change a plugin that has shipped, write that test first.

Compatibility by construction. When the demo gained attack, decay and sustain controls they were appended at the end of PARAMS, with defaults that reproduce the earlier fixed envelope. Old sessions load with the old sound.

A larger controller. examples/tuning-demo keeps a microtuning table in a Controller, saves it in sessions and presets, and edits it from a resizable page.

Where next

Try hot reload with this synth: it walks through changing its oscillator while it plays in Logic Pro.

Write your own: cargo xtask new my-plugin starts from example 01 with fresh identifiers. Keep the test that installs the counting allocator, and add the test that pins your parameter ids the day you release.

How APF works

This chapter is the map of the whole framework. You do not need it to write a plugin; read it when you want to know what happens underneath.

            your plugin crate (Rust)
   impl apf::Plugin  +  web page (ui/*.html)
                 |
   +-------------+--------------+
   | apf          core          |  crates/apf
   |  Plugin trait, ParamDef,   |   - parameters: const definitions, lock-free atomic store
   |  AudioBuffer, Event        |   - web view -> audio: wait-free SPSC queue
   |  CLAP backend (clap.rs)    |   - CLAP ABI: entry, factory, params, state,
   |                            |     audio/note ports, gui; sample-accurate event splitting
   +----------------------------+
   | apf-webview  web view        |  crates/apf-webview
   |  wry child webview in the  |   - serves the page from memory over apf://
   |  host's NSView/HWND        |   - apf.js binds <input data-param="cutoff"> etc.
   +-------------+--------------+
                 |  static library exporting apf_entry_{init,deinit,get_factory}
   +-------------+--------------+
   | wrapper/   CMake           |  free-audio/clap-wrapper, "clap first"
   |  entry.cpp -> clap_entry   |  one static library linked into every format
   +-+-------+-------+------+---+
   .clap   .vst3  .component  .appex (AUv3) / .app (standalone)

Why CLAP at the core

CLAP is a modern, MIT-licensed plugin interface with clear threading and event rules. APF implements CLAP in Rust once. The other formats are produced from it by clap-wrapper (MIT, C++), which translates VST3 and Audio Unit calls into CLAP calls. Because a plugin never sees the format, the wrappers could be replaced by Rust code later without changing any plugin.

Where clap-wrapper has bugs that matter, APF carries small patches in wrapper/patches/; the list is in Repository and tools.

Real-time rules, built in

  • process never allocates, locks or blocks. Each example’s test drives the plugin through the CLAP interface under a counting allocator and fails on any allocation inside process (see What is real-time safe).
  • Parameter values live in atomics (an AtomicU64 holding the host’s f64 value) and can be read from every thread. Host automation is applied at its exact sample: the framework splits the block at every event.
  • Edits from the web view go through a wait-free single-producer, single-consumer queue. process (or params.flush when the host is not processing) drains it into gesture-begin, value and gesture-end events for the host, so automation recording and touch modes work.
  • A panic in plugin code is caught at the interface boundary. A panic in process mutes the instance instead of crashing the host.

Parameters in the host’s eyes

A parameter’s id is a hash of its string key, so a key must never change. Audio Units list parameters in PARAMS order and Logic keys its automation and Smart Controls to that order, so new parameters go at the end. Continuous parameters are exposed to hosts normalised (0..1) so that automation lanes follow the parameter’s curve; the text (“2.40 kHz”) is converted by the framework in both directions. State is saved by parameter id and loads sessions from older and newer versions: a parameter missing from an old session gets its default. Parameters has the rules.

Transport

ProcessContext.transport is the host’s transport at the first frame of the part of the block being processed. The framework splits blocks at events and moves the song position on by the frames before each part (while playing, at the host’s tempo); a transport event inside the block takes over from its frame.

Web view

Tauri cannot run inside a plugin: it owns the window and the event loop, and in a plugin the host owns both (tauri-apps/tauri#1872). wry, the layer below Tauri, can create a web view as a child of a window it does not own. apf-webview does that with the window the host passes to the CLAP gui.set_parent call. The page is served from memory over a custom apf:// scheme. The web view and the web view reference describe what the page can do.

Web view reference

The full surface of apf.js, the bridge between a plugin’s page and the plugin. The header of crates/apf-webview/src/apf.js is the authority; this page follows it.

Attributes

AttributeElementEffect
data-param="key"slider, select, checkboxbound to the parameter; a select gets its options filled in
data-param-name="key"anythe parameter’s name as text
data-param-text="key"anythe value as text (“1.20 kHz”)
data-param-cc="key"anythe MIDI controller learned for the parameter (“CC 21”)
data-apf-presetsdiva preset bar: list, previous and next, save, replace, delete, and a mark when the sound was changed
data-apf-resize-gripdiva corner grip; hidden while the web view is not resizable
data-apf-maximizebuttontoggles between the largest size the screen allows and the size before

Every bound control has a context menu (right-click or ctrl-click): MIDI learn, forget, the default value.

Script

FunctionPurpose
apf.readya promise; wait for it before using the other functions
apf.param(key)the parameter: key, value, text
apf.on(key, fn(value, text))called when the parameter changes
apf.begin(key), apf.set(key, normalized), apf.end(key)move a parameter as a host gesture
apf.bind(root)bind the data- attributes under an element you added later
apf.learn(key | null), apf.forget(key)start or cancel MIDI learn, remove a mapping
apf.onSession(fn(session))the session changed; see below
apf.log(...)write to the plugin’s stderr
apf.keys.claim([...])take shortcuts such as "cmd+d" before the host does (“cmd” means Ctrl on Windows)
apf.sizeLimits, apf.resize(w, h), apf.maximize(), apf.restore()resizing
apf.poller(fn, opts)adaptive polling; see The web view

session holds the current preset, the parameter waiting for MIDI learn, the learned controllers, and controllers: what the player’s mod wheel (0 to 1), sustain pedal and pitch wheel (-1 to 1) are doing, so a page can show them.

Presets

apf.presets.list() resolves to {presets: [{id, name, factory, type, subtype, mood: [..], description, author}], dir}: factory presets in the plugin’s order, then the user’s by name. apf.presets.load(id), apf.presets.save(name, overwrite, {type, subtype, mood, description, author}) (every member optional), apf.presets.remove(id), apf.presets.current, apf.presets.on(fn). The preset bar groups factory presets by type, shows the description as a tooltip, and a sound saved from a typed preset keeps its type, subtype and mood.

The plugin’s own requests

These reach the plugin’s Controller:

Function
apf.plugin.get(path, query)GET; query an object or a string; resolves to the parsed JSON
apf.plugin.post(path, body)POST with the body as JSON (a string is sent as it is)
apf.plugin.raw(path, {method, query, body})resolves to an ArrayBuffer; the body may be an ArrayBuffer, a typed array, a string or an object
apf.plugin.revision, apf.plugin.onChange(fn(revision))the controller’s state changed: an edit, a preset, a session loaded by the host

A reply with an error status rejects with an Error carrying .status and .body. Without a controller the plugin answers 404. WKWebView delivers POST bodies to wry’s protocol handler intact (wry reads HTTPBody or the body stream); apf-host --gui-test checks a 1 MiB binary and a UTF-8 JSON body.

Endpoints

The script API sits on HTTP-style endpoints that the framework serves to the page:

Endpoint
GET /__apf/presets{"presets": [...], "dir"} as above
/__apf/preset/save?name=&overwrite=1&type=&subtype=&mood=a,b&description=&author=save a user preset
GET, POST /__apf/plugin/<path>?<query>the controller’s request

Resizing in detail

The web view fills the host’s view whenever its frame changes, in whichever order the host resizes the view and calls set_size, and keeps its minimum when a host lets the window get smaller than the web view’s limits. The size the user chose is saved with the session (an EDSZ chunk). fit_screen(margin) on the WebView opens the web view at the size of the screen less margin (room for the host window’s title bar), within the size limits, for hosts that let the web view propose its size but not change it afterwards; a size the user chose in a session still wins. apf.maximize asks for the largest size the screen allows.

In Logic, Audio Unit v3 windows have no resize edge, so the page’s grip is the way to resize them; see Known issues for how Logic sizes AUv2 and AUv3 windows.

Development switches

VariableEffect
APF_UI_DIR=path/to/uiserve the page from disk instead of from the plugin, to edit without rebuilding
APF_UI_INJECT_JS=file.jsrun a script in the page first (how the tests drive the UI)

Both are for development and tests. Making sure they do nothing in a release build is on the to-do list (docs/todo.md in the repository).

Windows

On Windows keys, scale and WebView2’s data folder behave as described in Windows.

DSP building blocks

apf::dsp holds the small pieces most plugins need. It is deliberately basic: it is not a DSP library, and anything bigger (reverbs, oversampling, FFT, ladder filters) you write or bring in yourself. Every piece is allocation-free after construction, and every piece has tests and a rustdoc example (cargo doc -p apf --open).

PieceWhatUsed in
Smootherglides a value to its target (one-pole), landing exactly on it01, 02, 03, 05, 06, 07, 09
Adsr, AdsrShapeattack/decay/sustain/release envelope; per-voice state, shared times04, 08, 09
Oscillator, Waveformphase accumulator: sine, and PolyBLEP saw and square, PolyBLAMP triangle01, 04, 05, 08, 09
poly_blep, sine, saw, square, trianglethe waveform functions of a phasedemo synth
Lfo, LfoShapesine, triangle, saw, square, sample & hold; unipolar output05
OnePole6 dB/octave low-pass and its high-pass03, 09
Svf, SvfCoeffsstate-variable filter (Simper’s trapezoidal form): low, band, high, notch at once06
DelayLinefixed maximum length, fractional (linearly interpolated) delay03
flush_denormalflush tiny values to exactly zero06 (through Svf::flush)
db_to_gain, midi_to_hzunit conversionseverywhere

The idea behind the oscillators

An oscillator keeps a phase t in [0, 1) and adds dt = frequency / sample_rate after every sample; the waveform is a function of the phase. A naive saw wave jumps from 1 to -1 at the end of each cycle, and a jump contains frequencies far above half the sample rate, which alias: they fold back as inharmonic whistles. PolyBLEP corrects the two samples around each jump with a small polynomial, which removes most of the aliasing for almost no cost.

Moving to your own types

The building blocks are plain structs with public constructors and no hidden state, so you can wrap or copy them. The demo synth, for instance, keeps the filter state in each voice and computes the coefficients itself; that is the same arithmetic as Svf, inlined for speed.

What is missing

Reverb, chorus, compressor, oversampling, FFT, resampling, wavetables, a ladder filter, noise generators and sample playback. If you write one that is generally useful and MIT-licensed, it is welcome as a contribution.

Windows

APF builds CLAP and VST3 plugins for Windows (x64). The web view uses WebView2. Audio Units do not exist on Windows. The standalone app is not verified on Windows.

What you need

  • Rust with the MSVC toolchain.
  • Visual Studio 2022 Build Tools (the C++ workload and a Windows SDK).
  • CMake 3.24 or newer.
  • The WebView2 runtime, which Windows 10 and 11 ship.

Build and install

The same commands work in Git Bash, PowerShell and cmd:

cargo xtask bundle demo-synth --formats clap,vst3          # -> dist/release/
sudo cargo xtask install demo-synth --formats clap,vst3    # -> C:\Program Files\Common Files\{CLAP,VST3}
cargo run --release -p apf-host -- "dist/release/APF Demo Synth.clap" --gui-test

Installing writes to Program Files, so it needs an elevated shell (sudo, which Windows 11 has built in). A host that has the plugin loaded keeps the file locked. In cmd, write set CARGO_TARGET_DIR=...&& with no space before &&.

Details

  • The VST3 is a bundle folder (APF Demo Synth.vst3/Contents/x86_64-win/), the CLAP a single DLL. Both link the C runtime statically (no Visual C++ redistributable is needed) and WebView2’s loader statically (no WebView2Loader.dll beside the plugin).
  • Ninja is used only inside a Visual Studio developer shell (it needs cl.exe on the PATH); otherwise CMake’s Visual Studio generator is used, which finds the compiler by itself.
  • Web view sizes stay logical pixels for the plugin. The framework converts them to the physical pixels the host’s window uses, with gui.set_scale (VST3 hosts: through clap-wrapper) or else the window’s DPI. The session keeps the logical size, so a web view opens alike at another scale or on a Mac.
  • WebView2’s user data lives in %LOCALAPPDATA%\<vendor>\<plugin>\WebView2. Its default, beside the host’s executable, is not writable. Browser keys (F5 reload, Ctrl+F find, zoom) are off, and so is the browser’s context menu outside debug builds.
  • Keys: WebView2 gets every key while the web view has the focus, and the host none. A key the page did not claim (apf.keys.claim), or claimed but did not take (preventDefault), goes on to the host’s window, so the host’s own shortcuts work over the web view (the space bar plays and stops in REAPER). Keys typed into text fields stay in the page. “cmd” in a claim means Ctrl.
  • A resizable web view follows the host’s window (its client area, within the web view’s limits) whether or not the host calls set_size. fit_screen and apf.maximize measure the monitor’s work area less the window frame.
  • apf-host --gui-test types its keys into the page through WebView2’s DevTools port (no operating-system input, so the keyboard and mouse stay free) and uses a throwaway WebView2 profile.

What was checked on Windows is listed in Validation; open problems are in Known issues.

Repository and tools

PathWhat
crates/apfcore: plugin API, parameters, queue, state, CLAP backend
crates/apf-webviewweb view (wry) and the apf.js bridge
crates/apf-hosta minimal CLAP host for tests: renders audio and drives the web view end to end
crates/apf-hotthe hot-reload shell: runs a plugin from a published dynamic library and swaps in new builds
crates/apf-testtest a plugin through the CLAP interface in memory, with the counting allocator
examples/01-sine to 10-synththe example ladder
examples/demo-synth16-voice synth: sine, saw and square (PolyBLEP), resonant state-variable low-pass, ADSR, sustain pedal, pitch bend, mod wheel (cutoff, vibrato, resonance, gain), factory presets, web view
examples/tuning-demomicrotuned sine synth: the controller example (tuning kept outside the parameters, in sessions and presets, edited through apf.plugin) with a resizable web view
wrapper/CMake project building every format with clap-wrapper, and its patches
xtask/cargo xtask bundle / install / hot / validate / new / notices (cargo xtask --help)
third_party/option-extMIT re-implementation of an MPL-2.0 transitive dependency (see Licences)
docs/bookthis book (mdBook sources)
docs/known-issues.mdopen problems and host quirks

The build commands and the packaging settings in a plugin’s Cargo.toml ([package.metadata.apf]) are in Hosts and formats.

Test and validation tools

PathWhat
scripts/reaper-render-test.shloads the installed CLAP, VST3 and AU in REAPER, renders and compares
scripts/au-automation-check.swiftwhile a host plays automation, the AUv3’s parameter tree (what the host’s own knobs read, such as Logic’s Smart Controls) follows it (wrapper patch 0006)
scripts/au-legato-check.swiftan AUv3 instrument keeps sounding when a held note is struck again legato (a note-off and a note-on of the key on one sample; wrapper patch 0007); --preset NAME loads a factory preset first
scripts/au-state-check.swifta restored session sounds the same with the web view closed and opened, in seven restore orders (AUv3 out of process, AUv2 with --in-process, an uninstalled AUv2 build with --bundle, a host’s saved state with --state)
scripts/au-probe.swiftan installed AU as Logic sees it: parameters, groups, factory presets (--preset N selects one); --gui S --resize [--in-process] checks that the web view follows its window; --gui S --keys [--in-process] that keys the page leaves reach the host whole (key-down, key-up, modifiers)
tools/vst3-state-checkthe same session check for VST3, headless and portable (C++17, VST3 SDK hosting): a restored session sounds as saved in seven restore orders, the controller shows the restored values and the processor holds them; --view adds the web view opened (off screen) against closed, --state a host’s saved .vstpreset, --inject-stale checks the check. Built and run by cargo xtask validate
scripts/clap-validator-0.4.1-preset-deadlock.patchfix for a deadlock in clap-validator (see Validation)

Wrapper patches

wrapper/patches/ holds the changes APF applies to clap-wrapper before building:

PatchFixes
0001AUv3: parameter value names (choices shown as numbers) and group order
0002AUv3: factory presets in the host’s preset menu
0003presets keep the plugin’s order (VST3 program lists, AU lists)
0004web view resizing in Audio Unit hosts
0005a restored session keeps its parameter values (AUv2 and AUv3)
0006the AUv3 parameter tree follows host automation
0007AUv3: a note struck again legato keeps its key
0008the CLAP, VST3 and Audio Unit SDKs are downloaded once per machine into CPM’s source cache, not into every build folder

Validation

What was run, with which tools, and what came out. The results are a snapshot: they say what was true on the date given, not that every later change was checked the same way. Re-run cargo xtask validate <plugin> for your own plugin and version.

macOS (2026-10-24 and 2026-10-25; macOS 26.6, Apple silicon)

CheckResult
cargo test (parameters, queue, state, presets, MIDI learn, ADSR; CLAP interface: pedal, bend, mod wheel, envelope, presets, an old session sample-exact, random playing, no allocation in process; controller: session chunk, presets with JSON and text data, request routing, mark_dirty off the main thread, parameter gestures, web view size in the session; sub-block song position; preset metadata read, written and kept on overwrite, saved through /__apf/preset/save, reported to CLAP indexers; 250 factory and 250 user presets of 6 KB listed under 20 ms)pass
clap-validator 0.4.1 (with the deadlock fix below)38 passed, 0 failed, 6 skipped; the preset-discovery tests pass, also with 250 extra user presets (list presets: 256, with features and descriptions)
apf-host on the built .clap: render and web view round trip (page to host to page); claimed shortcuts reach the page, other keys reach the host with their key-up and modifier changespass
Steinberg VST3 validator (SDK 3.8.0)47 passed, 0 failed
vst3-state-check (demo synth, and a larger modular synth built on APF): seven restore orders, web view closed against openedpass; the demo synth bit-identical to the saved session (largest sample difference 0); the modular synth within 0.22 dB per band, and like its same-timed twins; --inject-stale fails all eight scenarios (2026-10-25)
auval AUv2 (aumu ApDs MiSm)AU VALIDATION SUCCEEDED
auval AUv3 (aumu ApD3 MiSm)AU VALIDATION SUCCEEDED
REAPER 7.62: CLAP, VST3 and AU loaded, rendered offlineall three identical (-14.8 dBFS RMS)
Logic Pro: AUv2 and AUv3 load, the web view shows, edits reach Logicpass (open points in Known issues)
Logic Pro, AUv3: a project saved before new parameters were added loads with its sound; Smart Controls follow the first eight parameters; factory presets in Logic’s menu; mod wheel, bend and pedal reach the pluginpass (2026-10-22)
au-probe: AUv2 and AUv3 list the factory and user presets, selecting one loads it (also from the sandboxed AUv3)pass
Preset bar with 256 presets in apf-host (WKWebView): menu grouped by type, description as tooltip, the arrows step in that order and loadpass
Tuning demo (controller): clap-validator, apf-host (including a 1 MiB POST body, five resizes, apf.resize), VST3 validator, auval v2 and v3pass
xtask --project: a copy of the demo synth in a workspace outside APF, all formats, all validatorspass
Resizing in REAPER 7.62: CLAP, VST3 and AU windows resized to 1280, 900, 300 (below the minimum) and 1100 wide: the page follows and keeps its minimumpass
Resizing in Logic: AUv3 by the page’s grip (Logic gives AUv3 windows no edge); the size is kept when the window is reopenedpass
Audio input on an instrument (CLAP, VST3, AUv2, AUv3) and as aumf: REAPER renders, auvalpass
Effects (examples 02 and 06): cargo xtask validate with a test signal on the input of vst3-state-check and au-state-check (AUv2 build); --inject-stale and a session that is not restored are caughtpass (2026-10-30)

Hot reload (2026-10-30; macOS 26.6.2, Logic Pro 12.4)

CheckResult
Logic Pro 12.4, AUv3, a larger modular synth on APF with its window open, cargo xtask hot --watch running: a panel title changed in the Rust sourcethe watcher built and published in 3.3 s; the open window showed the new title 128 ms later; changed back in 2.4 s and 100 ms; one extension process throughout
apf-host --hot-test (CLAP): a held chord, the web view open, a copy of the build publishedswapped in 0.2 to 0.3 s while blocks kept playing; the moved parameter kept; the web view open again; sound after the swap
A real DSP change while running: the modular synth’s output at half gain (CLAP in apf-host, AUv3 out of process); example 10’s oscillator fixed to a square wave (CLAP)heard across the swap: −4.8 to −11.3 dBFS (CLAP), −5.1 to −11.2 dBFS (AUv3); −32.3 to −28.8 dBFS (example 10)
au-hot-check: the AUv2 in-process and the installed AUv3 out of process, web view openswapped while rendering, the parameter kept, sound after; the AUv3 loads the builds through its sandbox exception
cargo xtask validate --hot, example 10 and the modular synth: every validator above on the shell bundles, plus the three swap checkspass

Not verified for hot reload: VST3 in a host (the shell passes the VST3 validator and the state check; its swap runs the same way as the CLAP’s), REAPER, Windows and Linux.

Not verified on macOS: a controller’s own knobs, wheels and pedal played by hand in Logic; Bitwig (remote controls); mark_dirty lighting Logic’s or REAPER’s “changed” mark by a controller edit (checked only through the CLAP interface); an aumf and an input-taking aumu in Logic; Logic resizing an AUv2 web view by its window edge (see Known issues). The standalone app has an open problem: do not leave it running unattended.

Windows (2026-10-25; Windows 11, x64, 125 % scale, REAPER 7.83)

CheckResult
cargo test --workspacepass
apf-host --gui-test on the CLAP of the demo synth, the tuning demo and a larger modular synth: render, page-host-page round trip with a 1 MiB POST, Ctrl+D and Backspace into the page, an unclaimed Ctrl+S on to the host’s window, apf.maximize, five resizespass
REAPER 7.83: VST3 and CLAP scanned and loaded, offline render, project saved and reloaded (state restored without opening the web view, renders equal to -98 dB), web view opens, resizes, closes; the space bar over the web view plays in REAPERpass
cargo xtask bundle and install from Git Bash, PowerShell and cmdpass
cargo xtask validate on a larger modular synth: clap-validator 0.4.1 (37 passed, 0 failed, 7 skipped), apf-host, Steinberg VST3 validator (47 passed), vst3-state-checkpass

Not verified on Windows: the standalone, other hosts (Cubase, Bitwig, Ableton Live), a second monitor with another scale, ARM64.

Audio input on an instrument, and MIDI-controlled effects

AudioLayout { main_input: 2, main_output: 2, note_input: true } gives a stereo input next to the notes in every format; the output starts as a copy of the input, and the plugin adds its voices to it. Checked on 2026-10-24 with a copy of the demo synth that adds its voices to the input: CLAP and VST3 show two input pins in REAPER, the AUv2 and AUv3 an input bus (auval: 2-2 channels). A 220 Hz tone reaches the output unchanged (gain 1.000) with the chord on top.

With au-type = "aumf" and audio-effect as the first CLAP feature, the same plugin is a MIDI-controlled effect: auval passes it as aumf (v2 and v3), and REAPER loads it as an effect that takes MIDI. How Logic routes audio into an aumu that has an input (its side-chain menu, by Apple’s documentation) and how it lists an aumf is not checked.

The tools

clap-validator is built from source, tag 0.4.1, with one fix. As released it deadlocks on the second preset of a preset container (the factory presets inside a plugin are one), so its preset-discovery tests never finish. The patch is scripts/clap-validator-0.4.1-preset-deadlock.patch:

git clone --branch 0.4.1 https://github.com/free-audio/clap-validator && cd clap-validator
git apply ../apf/scripts/clap-validator-0.4.1-preset-deadlock.patch
cargo install --path . --locked      # the results above used Rust 1.95.0: cargo +1.95.0 install ...

cargo xtask validate stops a validator run after five minutes instead of waiting forever.

The VST3 validator and tools/vst3-state-check are built by cargo xtask validate from the VST3 SDK that the wrapper build downloads. By hand (any OS, CMake 3.25 or newer):

cmake -S tools/vst3-state-check -B build/vst3-state-check -DCMAKE_BUILD_TYPE=Release \
      -DVST3_SDK_ROOT=<target dir>/apf-wrapper/cpm-cache/vst3sdk/<hash>
cmake --build build/vst3-state-check --config Release
build/vst3-state-check/vst3-state-check "dist/release/APF Demo Synth.vst3" [--preset NAME | --preset-program N]

auval is Apple’s Audio Unit validator, part of macOS. apf-host is APF’s own small CLAP host (see Testing without a host).

Licences

The framework is MIT (LICENSE). Everything a shipped plugin contains is permissively licensed (MIT, BSD, Zlib, Apache-2.0, Unicode-3.0, public domain): no GPL, LGPL, AGPL or MPL, no per-seat or revenue licence. This page lists every component with its licence, says how that is checked, how the licence notices that these licences require are shipped with a plugin, and which trademark lines apply. Checked on 2026-10-28 against Cargo.lock and the pinned wrapper dependencies.

How it is checked

cargo deny check licenses                                   # all four targets in deny.toml
cargo deny --target x86_64-pc-windows-msvc check licenses   # one target
cargo deny list                                             # every crate and its licences

deny.toml allows only MIT, Apache-2.0 (also with the LLVM exception), BSD-2-Clause, BSD-3-Clause, ISC, Zlib, Unicode-3.0 and CC0-1.0, and rejects everything else, copyleft first. A crate with a choice (“MIT OR Apache-2.0”) passes when one alternative is allowed. The check covers aarch64-apple-darwin, x86_64-apple-darwin, x86_64-pc-windows-msvc and x86_64-unknown-linux-gnu, the dev-dependencies and the build tools too. Result on 2026-10-28 (cargo-deny 0.20.2): licenses ok for each target alone and for all together. cargo deny check sources also passes: only crates.io is a source.

The one MPL-2.0 crate in the tree, option-ext (through wry, dirs and dirs-sys), is replaced by a clean MIT implementation of its small API, third_party/option-ext, patched in with [patch.crates-io] in the root Cargo.toml. cargo-deny sees the replacement. Without the patch the real crate would be flagged, because MPL-2.0 is not in the allow list.

Components the wrapper build downloads

cargo xtask bundle builds the VST3, Audio Unit and standalone formats with clap-wrapper (wrapper/CMakeLists.txt), which fetches the rest at configure time. The versions are pinned; the fetched sources are compiled into the plugin.

ComponentVersionLicenceEnds up in
clap-wrapper0.17.0 (commit 806bba0a5f79, with the patches in wrapper/patches)MITVST3, AUv2, AUv3, standalone
CLAP SDK1.2.10MITall formats (headers)
VST 3 SDK3.8.0 (v3.8.0_build_66)MIT (since 3.8; the VST name and logo have their own rules, see below)VST3
AudioUnitSDK1.1.0Apache-2.0AUv2
fmt, bundled in clap-wrapper11.1.4MIT, with an exception: object code needs no noticeVST3, AU, standalone
PreSonus plugin extension header, bundled in clap-wrapperpublic domainVST3, AU, standalone
WebView2 loader, WebView2LoaderStatic.lib inside webview2-com-sysBSD-3-Clause (Microsoft.Web.WebView2 NuGet package)Windows CLAP and VST3, linked statically

At build time only, never shipped: CMake, Ninja, Xcode, the VST3 SDK’s hosting helpers in tools/vst3-state-check (MIT), the clap-validator and the VST3 validator that cargo xtask validate runs.

Not used: VST2 (Steinberg discontinued the SDK; new VST2 plugins cannot be licensed) and AAX (Avid’s SDK is GPLv3 or a separate Avid agreement; clap-wrapper can build AAX, the wrapper build never asks for it). nih-plug (ISC) was studied for ideas; no code was taken from it, and none of its GPL-licensed VST3 bindings are used.

The operating systems’ own webviews (WebKit on macOS, the WebView2 runtime on Windows) are not shipped with a plugin and not covered here. The Rust standard library is linked into every plugin (MIT OR Apache-2.0, with a few bundled third-party parts); its notice is the toolchain’s own share/doc/rust/COPYRIGHT-library.html, which cargo xtask notices does not embed.

Rust crates in a shipped plugin

Everything reachable through normal dependencies of apf and apf-webview on macOS and Windows, 81 crates; build scripts’ tools and dev-dependencies are not in it. (cargo xtask notices computes the same set and prints each crate’s licence text.) The crates of this repository (apf, apf-webview, apf-test, the examples, third_party/option-ext) are MIT.

CrateVersionLicencePlatform
bitflags2.13.2MIT OR Apache-2.0macOS
block20.6.2MITmacOS
bytes1.12.1MITmacOS, Windows
clap-sys0.5.0MIT OR Apache-2.0macOS, Windows
cookie0.18.2MIT OR Apache-2.0macOS, Windows
deranged0.5.9MIT OR Apache-2.0macOS, Windows
dirs7.0.0MIT OR Apache-2.0macOS
dirs-sys0.5.0MIT OR Apache-2.0macOS
dispatch20.3.1Zlib OR Apache-2.0 OR MITmacOS
displaydoc0.2.7MIT OR Apache-2.0macOS
dpi0.1.2Apache-2.0 AND MITmacOS, Windows
dunce1.0.5CC0-1.0 OR MIT-0 OR Apache-2.0Windows
form_urlencoded1.2.2MIT OR Apache-2.0macOS
http1.5.0MIT OR Apache-2.0macOS, Windows
icu_collections2.4.0Unicode-3.0macOS
icu_locale_core2.4.0Unicode-3.0macOS
icu_normalizer2.4.0Unicode-3.0macOS
icu_normalizer_data2.4.0Unicode-3.0macOS
icu_properties2.4.0Unicode-3.0macOS
icu_properties_data2.4.0Unicode-3.0macOS
icu_provider2.4.0Unicode-3.0macOS
idna1.1.0MIT OR Apache-2.0macOS
idna_adapter1.2.2Apache-2.0 OR MITmacOS
itoa1.0.18MIT OR Apache-2.0macOS, Windows
libc0.2.191MIT OR Apache-2.0macOS
litemap0.8.3Unicode-3.0macOS
num-conv0.2.2MIT OR Apache-2.0macOS, Windows
objc20.6.5MITmacOS
objc2-app-kit0.3.2Zlib OR Apache-2.0 OR MITmacOS
objc2-core-foundation0.3.2Zlib OR Apache-2.0 OR MITmacOS
objc2-encode4.1.0MITmacOS
objc2-exception-helper0.1.1Zlib OR Apache-2.0 OR MITmacOS
objc2-foundation0.3.2MITmacOS
objc2-web-kit0.3.2Zlib OR Apache-2.0 OR MITmacOS
once_cell1.21.4MIT OR Apache-2.0macOS, Windows
percent-encoding2.3.2MIT OR Apache-2.0macOS
potential_utf0.1.6Unicode-3.0macOS
powerfmt0.2.1MIT OR Apache-2.0macOS, Windows
proc-macro21.0.107MIT OR Apache-2.0macOS, Windows
quote1.0.47MIT OR Apache-2.0macOS, Windows
raw-window-handle0.6.2MIT OR Apache-2.0 OR ZlibmacOS, Windows
serde1.0.229MIT OR Apache-2.0macOS
serde_core1.0.229MIT OR Apache-2.0macOS, Windows
smallvec1.16.3MIT OR Apache-2.0macOS
stable_deref_trait1.2.1MIT OR Apache-2.0macOS
syn2.0.119MIT OR Apache-2.0Windows
syn3.0.9MIT OR Apache-2.0macOS, Windows
synstructure0.14.0MITmacOS
thiserror2.0.21MIT OR Apache-2.0macOS, Windows
thiserror-impl2.0.21MIT OR Apache-2.0macOS, Windows
time0.3.55MIT OR Apache-2.0macOS, Windows
time-core0.1.9MIT OR Apache-2.0macOS, Windows
time-macros0.2.32MIT OR Apache-2.0macOS, Windows
tinystr0.8.5Unicode-3.0macOS
unicode-ident1.0.27(MIT OR Apache-2.0) AND Unicode-3.0macOS, Windows
url2.5.8MIT OR Apache-2.0macOS
utf8_iter1.0.4Apache-2.0 OR MITmacOS
webview2-com0.39.1MITWindows
webview2-com-macros0.8.1MITWindows
webview2-com-sys0.39.1MITWindows
windows0.62.2MIT OR Apache-2.0Windows
windows-collections0.3.2MIT OR Apache-2.0Windows
windows-core0.62.2MIT OR Apache-2.0Windows
windows-future0.3.2MIT OR Apache-2.0Windows
windows-implement0.60.2MIT OR Apache-2.0Windows
windows-interface0.59.3MIT OR Apache-2.0Windows
windows-link0.2.1MIT OR Apache-2.0Windows
windows-numerics0.3.1MIT OR Apache-2.0Windows
windows-result0.4.1MIT OR Apache-2.0Windows
windows-strings0.5.1MIT OR Apache-2.0Windows
windows-threading0.2.1MIT OR Apache-2.0Windows
windows-version0.1.7MIT OR Apache-2.0Windows
writeable0.6.4Unicode-3.0macOS
wry0.57.0Apache-2.0 OR MITmacOS, Windows
yoke0.8.4Unicode-3.0macOS
yoke-derive0.8.4Unicode-3.0macOS
zerofrom0.1.8Unicode-3.0macOS
zerofrom-derive0.1.8Unicode-3.0macOS
zerotrie0.2.5Unicode-3.0macOS
zerovec0.11.9Unicode-3.0macOS
zerovec-derive0.11.7Unicode-3.0macOS

Build-time only, in xtask or in build scripts, not shipped: cc 1.9.0, find-msvc-tools 0.1.15, shlex 2.0.1, version_check 0.9.5 (all MIT OR Apache-2.0), serde_json 1.0.154 and zmij 1.0.23 (MIT OR Apache-2.0 and MIT), memchr 2.8.3 (Unlicense OR MIT).

Linux

Linux is designed for but not built yet. wry would pull in the gtk-rs and webkit2gtk crates (MIT) and these others; all pass cargo deny check licenses for x86_64-unknown-linux-gnu:

  • Apache-2.0 OR MIT: equivalent, indexmap, pin-project-lite
  • MIT: atk, atk-sys, cairo-rs, cairo-sys-rs, gdk, gdk-pixbuf, gdk-pixbuf-sys, gdk-sys, gdkx11, gdkx11-sys, gio, gio-sys, glib, glib-macros, glib-sys, gobject-sys, gtk, gtk-sys, gtk3-macros, javascriptcore-rs, javascriptcore-rs-sys, memoffset, pango, pango-sys, slab, soup3, soup3-sys, webkit2gtk, webkit2gtk-sys, winnow, x11, x11-dl
  • MIT OR Apache-2.0: bitflags, field-offset, futures-channel, futures-core, futures-executor, futures-io, futures-macro, futures-task, futures-util, hashbrown, heck, proc-macro-crate, proc-macro-error, proc-macro-error-attr, serde_spanned, syn, thiserror, thiserror-impl, toml_datetime, toml_edit
  • Unlicense OR MIT: memchr

Those crates link GTK 3 and WebKitGTK (LGPL-2.1+) dynamically from the system. That is how every Linux GUI program uses them, and it needs no source offer from a plugin that does not bundle the libraries; decide it again before the first Linux release.

Shipping the notices

MIT, BSD, Zlib and Apache-2.0 all require the copyright notice and the licence text to accompany copies of the software, binaries included. The plugins contain the Rust crates above and the wrapper components, so a plugin you distribute needs a notices file.

cargo xtask notices                                  # in APF: the framework (apf and apf-webview);
                                                     # in a plugin's own project: its plugin
cargo xtask notices my-synth                         # one plugin of the workspace
cargo xtask notices my-synth --platform windows      # macos (default with windows), windows, linux, all
cargo xtask notices my-synth --out dist/release/THIRD_PARTY_NOTICES.txt

It writes dist/THIRD_PARTY_NOTICES.txt (or --out) with: the project’s own licence; the wrapper components of the table above with their licence texts and copyright lines; and the licence text of every shipped crate, read from the crate’s sources in cargo’s registry, with the crates that share one text listed together. The default platforms are macOS and Windows. Two licence texts that the crates’ published packages lack come from third_party/notices/ (objc2 and webview2-com; its README says where each file was taken from).

What to do with the file:

  • Put it in whatever you hand out beside the plugin: the download archive, the installer (as a file installed with the plugin, or shown in its licence step), and a “Licences” or “Credits” page of the product’s website.
  • Add the trademark lines below where the product names the formats.
  • Run it again for every release: the dependency set changes with Cargo.lock.
  • A crate whose sources have no licence file is named in a closing section of the file and on stderr; its notice has to be added by hand. Today there is none.

cargo xtask bundle does not write the file by itself.

Trademarks

APF’s licence covers its code, not the names of the plugin formats. These lines come from the vendors’ own guidance, read on 2026-10-28; the sources are named so that they can be checked again.

VST. “VST is a registered trademark of Steinberg Media Technologies GmbH.” Steinberg’s VST Usage Guidelines (shipped with the VST 3 SDK 3.8 as VST3_Usage_Guidelines.pdf) make the use of the name and the logo optional under the MIT licence, and strict once used:

  • the first use of “VST” in product material carries the registered-trademark symbol, and the sentence above goes into the credits and documentation;
  • wherever “VST” is used (packaging, every web page that names it, manuals, an About box) the VST Compatible Logo has to appear with it; a notice with the sentence above is accepted where space does not allow the logo;
  • compatibility may be claimed (“works as a VST3 plug-in”) only for a product that is compatible and was built with the VST SDK; no “VSTi” or other variants; no “VST” in a company name, and in a product name only as plain text such as “for VST”.

A page that says “VST3” in running text therefore either carries the logo and the sentence, or avoids the word.

Audio Units. Apple’s trademark list (apple.com/legal/intellectual-property/trademark) does not list “Audio Units” as a word mark; it does list Apple, Mac, macOS and Logic Pro, and asks for this credit line where they are named: “Mac, macOS and Logic Pro are trademarks of Apple Inc., registered in the U.S. and other countries and regions.” Apple’s Audio Units Logo Guidelines cover the Audio Units logo, which APF does not use; whoever uses it takes the artwork from Apple unaltered, keeps it clearly subordinate to their own identity and adds Apple’s credit line “The Audio Units logo is a trademark of Apple Inc.” (the 2005 text says Apple Computer, Inc.). Apple does not want its names and logos to suggest endorsement. Hosts that show the logo have their own licence agreement with Apple.

CLAP. The CLAP project publishes logo guidelines (v1.0, 2022, in the CLAP repository under artwork/) that describe the logo variants, free choice of colour, and print and digital files. It publishes no trademark attribution wording, and the CLAP SDK is MIT. APF uses the name “CLAP” as the format’s name and does not use the logo; whoever does follows those guidelines.

Others. Product and company names in these pages (Microsoft, Windows, WebView2, Logic, REAPER, Bitwig) belong to their owners and are named only to say what APF works with.

Keeping this page right

When Cargo.lock or the pins in wrapper/CMakeLists.txt change: run cargo deny check licenses, regenerate the crate table (the shipped set is what cargo xtask notices prints as “Rust crates”), update the versions above, and refresh third_party/notices/ if a pinned component moved.

Comparison with JUCE, nih-plug, iPlug2 and DPF

This page says what APF is for, and what it is not. It is written from the projects’ own descriptions as read on 2026-10-28; check their pages before deciding, they move.

What APF is for

APF’s job is narrow: ship one plugin, written in Rust, to every common consumer format, with a web page as its web view, with sessions, presets and host automation that restore correctly, and with MIT licences all the way down. The parts that decide whether a plugin feels finished are built in and tested:

  • a web view as a first-class feature: data-param bindings, MIDI learn, a preset bar, a controller channel for the plugin’s own requests, a resizable window, and the same page in CLAP, VST3, AUv2, AUv3 and the standalone app;
  • state: host sessions, factory and user presets, and plugin-defined state saved together, with the wrappers’ restore bugs fixed and covered by validators;
  • the real-time rule (no allocation in process) checked in every example by a counting allocator;
  • hot reload on macOS: a new build swaps into the running host in every format, with its state and window kept, checked in Logic Pro (Hot reload);
  • MIT code and permissively licensed dependencies throughout.

What APF lacks

Said plainly, so you do not find out later:

  • Linux. It is designed for but not built or tested. macOS (Apple silicon) and Windows (x64) are what is tested.
  • AAX and LV2. (VST2 is not offered either.) Other frameworks list them; APF’s build does not.
  • A DSP library. apf::dsp has the basics (oscillators, an LFO, two filters, an envelope, a delay line, smoothing). There is no reverb, FFT, oversampling or sample playback.
  • Native widgets. The web view is a web page. If you want a native or GPU-drawn UI toolkit, APF’s web view is not it (a Rust View trait exists, but there is nothing bundled for it).
  • An ecosystem. Few users, few tutorials besides this book, no plugin templates from the community, a young codebase (pre-1.0, one author so far).
  • Hosts beyond Logic Pro and REAPER are not yet verified; see Hosts and formats.

Others, as their own pages describe them

ProjectLanguageWhat its page says (2026-10-28)
JUCEC++Version 8 offers WebView UIs (WebBrowserComponent with a C++/JavaScript bridge and parameter binding; WebView2 on Windows). A far larger ecosystem than APF’s.
iPlug2C++Supports a UI that is a full web view (IWebViewEditorDelegate); its own wiki warns of challenges.
nih-plugRustWeb views exist only as third-party experimental crates (for example httnn/nih-plug-webview, whose README says work in progress, tested on macOS).
nice-plugRustVST3 and CLAP; GUI adapters for egui and iced (Slint in progress, Vizia third-party).
couplerRustVST3 and CLAP; early in development, little documentation.
truceRustCLAP, VST3, AU v2/v3, AAX, LV2 and VST2 (some opt-in) on macOS, Windows and Linux; built-in widgets, egui, iced, Slint and Vizia. More formats and platforms than APF. Experimental hot reload of DSP and its built-in GUI (its page, read 2026-10-30).
beamerRustMIT; built around a web UI (beamer-webview on WKWebView), AU and VST3; macOS only so far, Windows in progress.
DPFC++Not covered here: not checked for this book.

What follows from that: a web UI inside a plugin is not unique to APF. JUCE 8 and iPlug2 have one, Beamer (Rust, MIT) is built around one on macOS, and other Rust projects are working on it. Choose APF if you want Rust, a web view as the normal way to build the interface, the five formats it builds on macOS and Windows, and MIT end to end. Choose another project if you need Linux, AAX or LV2 today, a native widget toolkit, a large DSP library, or a big community to lean on.

Licences

APF is MIT. Its dependencies are MIT, Apache-2.0, Zlib, BSD-3-Clause or Unicode-3.0 (the full list). The VST3 SDK is MIT since version 3.8. APF never requests AAX from the wrapper build, and uses no GPL or LGPL code. nih-plug was studied for ideas; no code was taken from it.

Known issues

Problems and host quirks found while testing APF, grouped by area. Each entry says what you see, why (if known), its status (open, workaround, or fixed) and where it was seen. Hosts and operating systems are named with the versions that were tested: Logic Pro on macOS 26.6 (Apple silicon), REAPER 7.62 on macOS and 7.83 on Windows 11 (x64).

If you hit something not listed here, report the host, its version, the plugin format and the steps.

macOS Audio Unit hosts and Logic Pro

Notes cut out while playing the AUv3 live in Logic

  • Symptom: the sound stops and returns only with the next note.
  • Cause: every such moment, Logic tears the AUv3 instance down and creates a new one (fullState save, deallocate, new instance, setFullState). It happened every 1 to 3 minutes while playing and never while idle. A new instance has no sounding voices. What triggers it inside Logic is not known. It does not happen when a recorded region plays for five minutes, so it is tied to something done while playing live (recording takes, arming, or the Loop browser query Logic logs two seconds before each teardown), not to playback.
  • Not the plugin: under stress the plugin itself never goes silent (the test random_playing_never_drops_out plays random notes against the demo synth).
  • Status: open.
  • Seen in: Logic Pro, macOS 26.6, AUv3 of the demo synth.

Logic does not resize an AUv2 web view by dragging its edge

  • Symptom: the AUv2 web view window cannot be resized by hand. (Only a size set through Accessibility, which bypasses the window’s limits, changes it.)
  • Cause (likely, not proven in Logic): Logic hosts a third-party AUv2 out of process, in AUHostingServiceXPC_arrow, and shows Apple’s stand-in view (AUv2ContainerView from _RemoteAUv2ViewFactory, autoresizing mask 0) instead of the plugin’s own (mask 18, width and height sizable). Resizing that stand-in from the host side does reach the plugin and the page fills it (out-of-process probe, 1000 x 700). So Logic probably reads the mask and keeps the window at its size. The page’s grip cannot help: the hosting service sends the plugin’s size to the host once (sendInitialSize).
  • Status: open. The AUv2 could not be loaded in the Logic run that looked at this, so it is not confirmed there.
  • Seen in: Logic Pro, macOS 26.6, an APF plugin with a resizable web view.

Logic resizes an AUv3 web view at one aspect ratio

  • Symptom: the AUv3 window resizes, but always at one aspect ratio (4:3), and Logic’s View menu shows a zoom percentage.
  • Cause: Logic’s doing, not AppKit’s: the window has no contentAspectRatio, and the view really gets the new sizes (clap-wrapper’s log: 1192 x 894, 1182 x 886, down to 952 x 714). Logic’s XAUViewFactory getiOSStyleViewSizesIfSupportedByVersion3AudioUnit: offers three view configurations (any size, 1024 x 768 and 1366 x 1024). If the AU supports 1024 x 768 the web view is set up at that size, which is where the 4:3 comes from. Logic also called newCustomCocoaViewForVersion3AudioUnit:unlimitedResizable: with unlimited = 1 and never read the preferredMinimumSize or preferredMaximumSize that wrapper patch 0004 provides.
  • Status: workaround. The page’s own resize grip works (through preferredContentSize) and the size is kept when the window is reopened. Not tried yet: supporting only the any-size configuration (width 0, height 0), so Logic does not choose the iOS-style fixed size.
  • Seen in: Logic Pro, macOS 26.6, AUv3.

Reinstalling an AUv3 unloads it from open Logic projects

  • Symptom: after installing a new build of an AUv3, the instance Logic had loaded is killed.
  • Status: expected. Replacing the plug-in in the track’s slot brings it back with the project’s sound.
  • Seen in: Logic Pro, macOS 26.6.

Host quirks

  • Pitch bend through the Audio Units arrives from clap-wrapper as a channel-wide tuning expression fixed at plus or minus 2 semitones. Status: handled. The framework turns it back into the wheel position, so the plugin’s bend range applies; a native CLAP host’s channel-wide tuning is treated the same.
  • Logic hands values back with less precision (32-bit floats through the Audio Unit API). Status: handled. A preset counts as unchanged when every parameter shows the same text, not only when the numbers are equal.
  • REAPER shows an AU’s raw value (0.6000) instead of the text (1.26 kHz) from TrackFX_GetFormattedParamValue, although the AU reports ValuesHaveStrings. Status: open, cosmetic. Not checked what Logic shows. Seen in REAPER 7.62.
  • A VST3 lists about 2100 parameters in REAPER. clap-wrapper’s VST3 adds MIDI controller mapping parameters for plugins that accept MIDI (VST3 has no controller events). Status: open, harmless. Declaring only the CLAP note dialect, or hiding them, may fix it.
  • AUv2 and AUv3 side by side: the AUv3 is built with its own subtype (ApD3 for the demo synth), so hosts list two entries when both are installed. Status: by design.
  • Objective-C classes of wry are shared per wry version. Two plugins built with the same wry version share the class registered first (objc2 reuses an auto-named class). macOS never unloads images with Objective-C classes, so this is safe; keep it in mind if two plugins built with different framework versions misbehave together. Status: note.

Windows

The first VST3 instance lists no presets

  • Symptom: in REAPER, the first VST3 instance of a plugin in a session shows only the MIDI “Program Changes” lists (16 x 128 = 2048 “Program N” entries) instead of the presets. Instances created afterwards list the presets, and selecting by name works from the second instance on. The plugin’s own preset browser always works.
  • Cause: clap-wrapper fills the VST3 program list from its preset crawl, on a thread. The first instance is created before the crawl ends. The wrapper grows the selector and calls notifyProgramListChange from onIdle once the crawl is done, but REAPER does not read the list again. Probably the same on macOS (not checked).
  • Status: open. Possible fixes: hide the MIDI program lists, or finish the crawl of the plugin’s own factory presets synchronously.
  • Seen in: REAPER 7.83, Windows 11, VST3.

Ctrl+Z and Ctrl+Shift+Z stay in the page

  • Behaviour: shortcuts the page claims with apf.keys.claim do not reach the host, so the page’s own undo runs, not the host’s.
  • Status: by design. Unclaimed keys (and the space bar) reach the host.

Web views and windows

Windows reported as occluded when a host is started from a shell (macOS, test host)

  • Symptom: every newly opened window of a process started from a shell or Terminal was reported occluded by WindowServer (NSWindow.occlusionState without .visible) although it was in front. A plain AppKit window did the same, with the screen unlocked. WebKit takes the page of such a window for hidden and suspends its WebContent process about 3 seconds after it loads: the web view goes blank while the page lives on. The same happens behind a locked screen.
  • Cause: not known; it is in macOS.
  • Status: workaround, test host only. Real hosts were not affected (REAPER’s web views and Logic’s windows painted throughout). apf-host turns WebKit’s occlusion detection off in the plugin’s web views (SPI _setWindowOcclusionDetectionEnabled:) and --screenshot takes WebKit’s own snapshot of the page instead of a window capture.
  • Seen in: macOS 26.6, apf-host and au-probe.

The standalone app makes a constant low beat

  • Symptom: the standalone app (clap-wrapper’s standalone around the demo synth) ran for about 16 minutes with no notes played, and a constant low beat came from the Mac’s output the whole time. It stopped the moment the app was quit.
  • What is known:
    • process() wrote exact zeros throughout (a diagnostic build logged the peak of every block for 15 seconds: 0.000000). Only MIDI clock and active sensing arrived, no notes. So the plugin’s DSP did not make the sound, but the app as a whole did.
    • The standalone opens the default input and output (here a 4-in, 4-out USB audio interface, duplex), sets the device’s sample rate and buffer size, and with different input and output devices builds a private aggregate device with drift compensation (clap-wrapper/src/detail/standalone/macos/coreaudio_backend.cpp).
    • The app’s system log shows the I/O being paused, reconfigured and resumed, once with _StartIO(): Start failed ... error 35.
    • The kernel log shows the interface dropping off USB (“hardware connection lost”) and re-enumerating 22 seconds after the standalone started and configured it, and twice more later. So the standalone’s reconfiguration of the interface (sample rate, buffer size, duplex) most likely knocked it into a reset, and the beat may be the interface’s own state after it, ending when the app released the device.
  • To research: (1) reproduce with the output captured instead of played (a CoreAudio process tap with CATapDescription.muteBehavior = .muted, or a loopback device) and look at what reaches the device: period, channels, level. (2) Test each suspect alone: input disabled, built-in speakers instead of the interface, 48 kHz against 44.1 kHz, other audio programs closed, MIDI devices unplugged. (3) Check the standalone’s handling of devices with more channels than the plugin (4 against 2), of device-change notifications, and of the restart after the failed start. (4) Report upstream (free-audio/clap-wrapper) with a reproduction if it is theirs.
  • Status: open. Do not leave the standalone running unattended and do not use it as a test step.
  • Seen in: macOS 26.6, a laptop with a USB audio interface as default input and output and a MIDI keyboard sending MIDI clock.

Wrapper patches

APF patches clap-wrapper (wrapper/patches/). These problems were found and fixed there; the entries keep what is worth knowing if you meet something similar.

A restored session played with old parameter values (fixed, patch 0005)

  • Symptom: in Logic (AUv3) a plugin sometimes sounded different from the saved sound after a project load, until its window was opened. Reproduced without a host window from a saved Logic project: restored as Logic restores it, the sound came out 39 dB quieter (a cutoff macro at 0 instead of 0.51).
  • Cause, AUv3: fullState saves two copies of the parameter values: the CLAP state and the base class’s data (what the AUParameter objects last held). A plugin that changes values itself (a preset from its own web view, a session load) leaves the objects behind, so data was stale. setFullState called [super setFullState:] first, which sets every parameter from data; with render resources allocated each setting is queued for the next render, which comes after the CLAP state has loaded, so the stale values won.
  • Cause, AUv2: SaveState and RestoreState refused an uninitialized unit (kAudioUnitErr_Uninitialized), so a state set before initialization was lost.
  • Fix: with a CLAP state, setFullState ignores data and afterwards publishes the plugin’s values to the cache and the tree without echoing them back; fullState publishes them before the base class saves data; the AUv2 saves and restores while uninitialized. Checked with scripts/au-state-check.swift (seven restore orders, web view closed against opened, and saved host states with --state) and, for VST3, tools/vst3-state-check. The VST3 wrapper never had the bug.
  • Build trap found on the way: clap-wrapper keeps its source location in the CMake cache (CLAP_WRAPPER_CMAKE_CURRENT_SOURCE_DIR), which is never overwritten. A build folder configured when builds shared one clap-wrapper checkout went on compiling that old, less patched checkout. wrapper/CMakeLists.txt now drops that cache entry before clap-wrapper is added and stops the configure if it would compile any checkout but the patched one.
  • Seen in: Logic Pro, macOS 26.6, AUv2 and AUv3.

Other patched behaviour (fixed)

  • AUv3 parameter names and order (0001): choice parameters showed as numbers (the AUv3 tree had no value strings) and groups were alphabetical. Now value strings are provided and groups keep the plugin’s order. The AUv2 parameter order is handled by the framework implementing clap-wrapper’s AUv2 param-ordering extension (checked with scripts/au-probe.swift).
  • AUv3 factory presets (0002) and their order (0003): the presets show in the host’s menu, factory presets first, in the plugin’s order.
  • Web view resizing in Audio Unit hosts (0004): the patch answers supportedViewConfigurations: with every configuration within the web view’s limits.
  • AUv3 parameter tree follows host automation (0006): what the host’s own knobs read, such as Logic’s Smart Controls, follows automation (scripts/au-automation-check.swift).
  • AUv3 legato repeats keep their key (0007): clap-wrapper reorders a same-sample note-off and note-on of one key into on-then-off when it thinks the key is not playing (a fast tap the AU scheduler reordered). It forgot the keys held from earlier render cycles, so every legato repeat of a held key (two touching notes of one pitch in a DAW) ended with the key released. A voice that pairs note-offs by note id survived (the late off names the earlier note); a part that goes by the key alone, such as an arpeggiator’s held keys, lost the key. Checked by scripts/au-legato-check.swift.

Validators

clap-validator 0.4.1 hangs on preset containers

  • Symptom: its preset-discovery tests never finish.
  • Cause: its metadata receiver keeps a mutex locked in begin_preset and locks it again in flush_preset, so the second preset of any container (load keys, for example presets inside the plugin) deadlocks for good. Still present on its master branch when last checked.
  • Status: workaround. scripts/clap-validator-0.4.1-preset-deadlock.patch fixes it locally (worth reporting upstream), and cargo xtask validate stops a validator run after five minutes.
  • Seen in: clap-validator 0.4.1.

clap-validator 0.4.1 fails on a plugin with no parameters

  • Symptom: a division by zero inside the validator’s parameter test.
  • Status: workaround. Give the plugin at least one parameter (example 01 has a Level knob for this reason). The test host’s web view check needs at least three parameters.

Fixed

  • Blank web view in every Audio Unit in Logic: Logic gives the plugin an NSView that is not in a window yet, and wry panicked. The web view is now built in a hidden staging window and moved into the host’s view.
  • Blank web view in the AUv3 only: the AUv3 sandbox stopped WebKit’s helper processes; the entitlement com.apple.security.network.client (wrapper/auv3.entitlements) fixes it.
  • AUv3 Undo in Logic did not light up after an edit in the web view; it does now, and moves the web view back.
  • Logic’s Musical Typing held every note forever with the keyboard focus in the web view: the key-up and modifier changes of keys the page left to the host were dropped. The web view now forwards them. When testing with synthetic key events, check CGEventSource.flagsState(.hidSystemState) afterwards: a synthetic Command key a tool left down made a later A act as Command+A in the page, which looked like a plugin fault. Logic Pro, macOS 26.6, AUv2 and AUv3.
  • A resizable web view was pinned to its first size (wry’s behaviour), and REAPER’s CLAP host calls set_size before it resizes the view, which added the growth twice. A resizable WebView now lays its web view out on every frame change of the host’s view, and keeps its minimum when a host lets the window get smaller.
  • A restored session differed from the saved one in the last bits: Smoother stalled just short of its target (its step falls below float resolution); Smoother::tick now lands on the target, so a value sounds the same whatever its history.
  • The space bar over the web view on Windows did not reach REAPER’s transport; unclaimed keys now go on to the host’s window.
  • A session restored before the plugin was initialized (AUv2) was lost; see patch 0005 above.