Skip to content

Repository files navigation

aubounce

CI License Rust Platform

Play an AudioUnit instrument from the command line and write the result to a WAV file. No DAW, no window, no audio device — just a plugin, some notes, and a file.

$ aubounce bounce "DLSMusicDevice" --note C4 --velocity 100 -o note.wav

Why

Rendering a plugin to audio normally means opening a DAW and doing it by hand. That is fine once and unbearable a thousand times. aubounce makes it a command, so it can go in a script, a Makefile, or a CI job.

It renders offline, far faster than real time — a large sampler measured here runs at roughly 2500x, so a second of audio costs well under a millisecond.

Install

cargo install --git https://github.com/libraz/aubounce

Usage

List the instruments installed on this machine:

$ aubounce list
aumu:dls :appl  Apple: DLSMusicDevice
aumu:msyn:appl  Apple: AUMIDISynth
aumu:samp:appl  Apple: AUSampler

Inspect one — channels, presets, parameters and what it keeps in its state:

$ aubounce info "aumu:dls :appl"
$ aubounce info "aumu:dls :appl" --params
$ aubounce info "HALion 7" --params "Volume"

Render a note, a chord, a note shaped by a controller, or a whole MIDI file:

$ aubounce bounce "aumu:dls :appl" --note C4 --gate-ms 500 -o note.wav
$ aubounce bounce "aumu:dls :appl" --note C4 --note E4 --note G4 -o chord.wav
$ aubounce bounce "aumu:dls :appl" --note C4 --cc 11=0..127@2s -o swell.wav
$ aubounce bounce "aumu:dls :appl" --midi song.mid -o song.wav

A MIDI file brings its own channels, programs, controllers and tempo, so the arguments that set those do not apply to it.

Bend a note, or strike it more than once:

$ aubounce bounce "aumu:dls :appl" --note C4 --bend 0..+1@2s -o bend.wav
$ aubounce bounce "aumu:dls :appl" --note C4 --repeat 6 --repeat-interval-ms 600 -o roll.wav

A bend is written as a lever position rather than an interval — -1 fully down, 0 at rest, +1 fully up. How far an instrument moves for a given position is a setting of its own that it does not publish: the stock GM instrument measured here bends two semitones at +1, which is a common default and not a promise.

Striking one note repeatedly is how a round robin shows itself, since a sampler that alternates between recordings renders a different waveform each time and one that does not renders the same one. The interval is measured note-on to note-on and has to be at least the gate: a strike landing before the last one was released would be silenced by that one's note-off, and nothing in the recording would say why.

Choose a timbre by program change, by a preset the plugin advertises, by a saved state, by a VST3 preset file, or by setting one parameter:

$ aubounce bounce "aumu:dls :appl" --program 19 --note C4 -o organ.wav
$ aubounce bounce "aumu:dls :appl" --note C4 --param "Reverb Volume=12" -o wet.wav
$ aubounce bounce "aumu:dls :appl" --note C4 --state saved.aupreset -o restored.wav
$ aubounce bounce "The Grand 3" --note C4 --preset piano.vstpreset --settle-ms 8000 -o grand.wav

Parameters take a name or the identifier info lists, and a name only has to be unambiguous — which matters, because a large sampler can advertise well over a thousand of them.

--preset reads a .vstpreset file, and does so through one vendor's convention rather than a standard. The two chunks such a file carries are put into the plugin's own class-info dictionary under Processor State and Controller State, which is where Steinberg's audio-unit wrapper keeps them — so a sound saved from the plugin's own interface can be replayed here without opening it, for plugins built that way. An audio unit that keeps its state under other keys will accept the dictionary and ignore the chunks, and the render will be of whatever it was already set to. aubounce info <plugin> prints the keys a plugin actually uses, which is how to tell in advance.

Presets a plugin keeps inside its own content archive are not reachable this way either; those have to be saved out first.

Reading a multitimbral rack

A rack saved as one file answers to a different sound on each of its MIDI channels, and an audio unit has no way to say how many of them are filled or what is on them. info asks three different ways, none of which knows anything about a particular plugin:

$ aubounce info "HALion 7" --preset rack.vstpreset --settle-ms 8000 --probe-channels
$ aubounce info "HALion 7" --preset rack.vstpreset --settle-ms 8000 --probe-slots
$ aubounce info "HALion 7" --preset rack.vstpreset --settle-ms 8000 --values element
$ aubounce info "HALion 7" --preset rack.vstpreset --settle-ms 8000 --strings harpsi

--probe-channels plays a note down each of the sixteen channels and reports which ones made a sound. It assumes only that a filled channel answers a note, so it works on any multitimbral instrument. The silence before each note is measured too: a channel carrying a long reverb is still ringing when the next note starts, and without that the quiet channel behind it reads as empty.

--probe-slots goes further and says which slot owns which channel. A rack has a solo control per slot, so raising one and playing the channels answers it. The controls are found by searching the parameter names — Solo by default — rather than being known in advance, which is what keeps this from being about one plugin. The channels are narrowed first by probing with nothing soloed, and the search stops once every sounding channel is accounted for; the report says how many controls it got through, because the ones after that were not tried. A rack of eight took nineteen seconds, most of it the settle wait.

--values reads the plugin's class-info dictionary through, values and all. That is a standard property, so every plugin answers it — but what is worth reading there is the plugin's to decide. HALion 7, for one, puts its output bus names in it and nothing about its slots.

--strings reads the text out of the opaque chunks. A sampler saves its rack as a blob only it can parse, and the names of what is loaded are inside; this decompresses what is compressed and reports what is readable, so it finds those names without following anyone's private format. It assumes a chunk may be compressed and a name may be stored as text — both narrow enough to hold widely — and reads ASCII and UTF-16 alike. What comes back is unlabelled: it says what a rack contains, never what is on a given slot. Pair it with the probe, which answers the other half.

These have been tried against HALion 7 and The Grand 3. Nothing in them is specific to either, and nothing in them is guaranteed to find something in a plugin that stores its names another way.

--settle-ms gives the plugin the main thread for a while before anything is played. A sampler that streams from disk finishes loading on the main run loop, which a command-line host would otherwise never turn — without this it accepts a preset, reports itself correctly, and then renders silence for as long as you ask. Several seconds is usual for a large library, and the result is the same bytes every time once it has settled.

Such an instrument also needs --realtime. Driven at full speed it plays only the part it holds in memory and then goes silent mid-note, with the right peak, the right length and no error — a recording that looks fine and is missing most of the note. aubounce checks for this: silence inside a note that had already started is reported as dropout_ms, and warned about.

A note that simply ends while the key is still down is not that, and is not reported. Held long enough, plenty of instruments finish and write silence for the rest of the key press — a piano's top octave is over in about a second. The two are the same run of zeros, and what separates them is the level the sound had just before it stopped: a note that ended got quiet first, and a sampler that ran out was still playing.

By default the output is 48 kHz 32-bit float, is not normalized, and is not trimmed: note-on always lands at the same offset, so a batch of renders shares one time axis. The tail runs until the sound decays rather than to a fixed length. Float rather than an integer depth because a plugin can render past full scale, and clipping a recording quietly is the one thing this tool should never do.

Each render is its own process, and that is deliberate: a plugin is not necessarily identical to itself across instances, so rendering two timbres in one run risks charging the difference to the timbre.

Rendering a lot of them

A jobs file is an array of the arguments above, and introduces no vocabulary of its own: every key is the name of a bounce option. Only id belongs to the batch, and it names both the job and the file it writes.

defaults:
  plugin: "aumu:dls :appl"
  note: C4
  gate-ms: 500
jobs:
  - id: gm-000
    program: 0
  - id: gm-019
    program: 19
  - id: chord
    note: [C4, E4, G4]
    velocity: 90
$ aubounce batch --jobs gm.yaml -o ./out
$ aubounce batch --jobs gm.yaml -o ./out --dry-run   # check it first

Each job is rendered by its own bounce process, and each one that finishes is appended to a manifest before the next starts — so a run that is interrupted leaves an exact record of what it got through, and --resume picks up from there. Jobs that failed are tried again; jobs that worked are not.

--shard i/n renders part of a batch, and is how a batch is rendered in parallel: run n of them at once. Shards share the output directory and nothing else, each writing a manifest of its own, and merge puts those together afterwards.

$ for i in 1 2 3 4; do aubounce batch --jobs gm.yaml -o ./out --shard $i/4 & done; wait
$ aubounce merge -o ./out

Rendering a dataset

A sweep is the product of what varies: sources of timbre, stimuli, notes and velocities. The options inside it are the same bounce options a jobs file uses; what a sweep adds is only the grid and the naming.

plugin: "aumu:dls :appl"
options:
  gate-ms: 1000
  tail: "until-silence@-80dB"
sources:
  - id: gm
    timbres:
      - id: "000"
        options: { program: 0 }
      - id: "019"
        options: { program: 19 }
stimuli:
  - id: decay-basic
    notes: { from: 48, to: 84, step: 12 }
    velocities: [32, 80, 112]
$ aubounce sweep --config gm.yaml -o ./dataset --dry-run
$ aubounce sweep --config gm.yaml -o ./dataset

Each render lands at <source>/<timbre>/<stimulus>/n<note>_v<velocity>.wav, so a path says what it is. Alongside them goes dataset.json, holding the plugin version and the digest of the definition: an instrument that updates, or a definition that is edited, makes a different dataset out of the same command, and nothing in the audio would say so.

Sharding, resuming and merge work exactly as they do for a batch.

Checking a dataset

$ aubounce verify -o ./dataset
$ aubounce selftest -o ./dataset -n 20

verify reads every file back and looks for the failures that leave a plausible file behind — a preset that did not take, a sampler that stopped streaming, a velocity that went nowhere, a note number the plugin ignored. None of these raise an error when they happen; each one produces audio of the right length with a reasonable peak.

It separates what is certainly wrong from what is only suspicious. A file that is missing, has changed since it was recorded, or is digital silence is a fault, and verify exits non-zero. A recording that clips, is not silent before the note, gets quieter as velocity rises, or does not sound higher as the note number rises is a doubt — reported, but an instrument is allowed to surprise. Glockenspiels, harmonics and sound effects legitimately have no fundamental to rise.

selftest renders part of the dataset a second time and compares it byte for byte. Determinism is the assumption everything else rests on, and it belongs to the plugin rather than to this tool: an update can take it away.

Requirements

  • macOS. aubounce hosts AudioUnits, which exist only here
  • Rust 1.88 or later to build

aubounce instantiates a plugin synchronously, which covers version 2 audio units and the version 3 ones packaged to load into the calling process. A version 3 audio unit that can only run in an extension process is registered as requiring asynchronous instantiation; list marks it as one that cannot be hosted, and asking for it by triple says the same thing rather than failing with a status code.

A note on what you render

Audio produced from a plugin's content is covered by that plugin's licence, not by this one. aubounce is a tool for rendering instruments you have installed, on your own machine; whether you may pass the result on is between you and whoever made the instrument. Sample libraries in particular rarely allow it.

Status

Early, but usable. list, info, bounce, batch, sweep, verify, selftest and merge all work: enumeration, instantiation, MIDI, parameters, VST3 presets, offline rendering, state capture and restore, and WAV output — from a note on the command line, from a Standard MIDI File, from a jobs file rendered a shard at a time, or from a sweep definition expanded into a checked dataset.

Licence

MIT. See LICENSE.

About

Render macOS AudioUnit instruments to WAV from the command line — headless, offline, no DAW

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages