Turn an instrument into a reusable sample bank by recording notes across its range and keeping their root pitches organized.
What it is#
Autosample captures a synth into a keymapped multisample instrument: it sends note-ons out a MIDI port (or into a hosted plugin), records the audio return per (note, velocity), trims silence, normalizes the set with one global gain, and writes WAVs plus a generated .volta module and a manifest.json zones fragment (Multisampled instruments schema).
Implementation: the capture/ crate (volta-capture) — the async pipeline over three seams (MidiSink, AudioSource, CaptureSink), with the real midir/cpal seams behind its devices feature and the offline plugin rig behind plugins. Three front-ends drive the same core: the volta autosample CLI (cli/src/autosample.rs, arg grammar + terminal progress), the desktop workbench's File ▸ Autosample Instrument… dialog, and the browser workbench (see below).
Usage#
volta autosample --list
volta autosample --device "minilogue" --notes C1:C6 --step 3 \
--vel 100,64 --dur 2s --tail 1s --out mysynth/
# hosted-plugin capture — no hardware, offline, faster than realtime
# (build with: cargo run -p volta-cli --features plugins -- autosample …):
volta autosample --plugin "Kontakt 8" --preset "grand piano" \
--notes C1:C6 --step 3 --vel 100,64 --dur 2s --tail 1s --out grand/
| Flag | Meaning | Default |
|---|---|---|
--device / -d | MIDI output port, case-insensitive substring | required |
--input | audio input device substring | system default input |
--notes LO:HI | note range — names (C1:C6, F#2:a4) or MIDI numbers (24:84) | required |
--step N | semitones between captured roots (off-grid HI is appended) | 3 |
--vel a,b,… | velocity layers (1–127), one pass per value | 100 |
--dur | note-on hold (2s, 500ms) | 2s |
--tail | post-note-off ring captured | 1s |
--channel | MIDI channel 1–16 | 1 |
--out DIR | output folder | required |
--name | instrument name (Volta identifier) | --out basename |
--dry-run | print the plan, touch no hardware | — |
--resume | skip (note, vel) files that already exist | — |
--list | list MIDI outputs + audio inputs and exit | — |
--plugin | capture a hosted instrument instead of hardware: a path, au: ref, or bare name resolved against the VST3/CLAP dirs (plugins cargo feature) | — |
--preset | named preset from the Host instrument and effect plugins store, loaded before capture | plugin default |
--settle | silent pre-roll after activation/preset load (Kontakt streams its library asynchronously) | 1s |
--sr | render sample rate for --plugin capture | 48000 |
With --plugin the MIDI/audio device flags don't apply (--device is rejected); notes go straight to the in-process instance and advance renders 128-frame engine blocks instead of sleeping, so a big capture set takes seconds. Between shots the rig renders 250 ms un-captured so a ringing release never bleeds into the next file. The rest of the pipeline — trim, one-gain normalize, WAV/manifest/module emit — is identical to the hardware path.
Per shot the tool records from just before note-on through dur + tail, trims leading silence (onset at −34 dB of the shot's peak, 5 ms pre-pad) and the sub-noise-floor tail (−40 dB, 20 ms post-pad), and warns if a capture looks silent. Normalization is one gain for the whole set (loudest peak → 0.95), so velocity layers keep their true relative levels. Files are written atomically as they're captured — an interrupted run keeps every completed shot (unnormalized), and --resume picks up where it stopped (a resumed run's gain covers only its own captures; re-run without --resume for a perfectly uniform set).
Output#
mysynth/
mysynth_c1_v100.wav -- URL-safe names: cs4 not c#4 (fetched under /samples/)
mysynth_ds1_v100.wav …
mysynth_c1_v64.wav … -- each velocity layer
manifest.json -- ONE zoned entry, every layer in it
mysynth.volta -- one patch + a demo track
manifest.json holds one entry under the instrument name; with several --vel values each zone carries its layer's loVel/hiVel (neighbouring layers split at their midpoint, capture::velocity_ranges), loudest layer first, so the engine switches layers by note velocity (Multisampled instruments).
{
"mysynth": { "zones": [
{ "file": "mysynth/mysynth_c1_v100.wav", "rootPitch": "C1", "loVel": 83, "hiVel": 127 }, …
{ "file": "mysynth/mysynth_c1_v64.wav", "rootPitch": "C1", "loVel": 1, "hiVel": 82 }, …
]}
}
Older captures' per-velocity banks (mysynth_v64) still load unchanged.
The generated module (compiles standalone; sample names are resolved by the runtime, not the compiler):
mysynth = patch {
source = sample "mysynth",
ampEnv = adsr 2ms 0ms 1 400ms,
gain = vel * 0.7 + 0.3
}
demo = play mysynth !"c3 e3 g3 c4"
The workbench dialog (desktop)#
File ▸ Autosample Instrument… (ui/src/app/autosample.rs) is the same pipeline in-process: hardware pickers (MIDI out + audio in) or an installed-plugin + workspace-preset pair, the grid controls with a live shot-count preview, per-shot progress rows with peak meters, and cancel — which keeps every finished WAV and seeds resume so the next start finishes the set. Device lists come from a probe thread. The run happens on a worker thread (app-native/src/autosample.rs); a completed capture lands itself, on a second worker (only adopting the decoded bank touches the UI thread):
- WAVs are captured straight into
~/Documents/Volta/samples/<name>/; - the zones fragment is merged into
~/Documents/Volta/samples/index.json(the workspace sample library every song resolves), editing only the instrument's own entry so the rest of the file keeps its formatting (app-native/src/samples.rs::merge_manifest_fragment, the samevolta_sfz::upsert_entriesedit SFZ import and drop-import use); - the generated module moves to
~/Documents/Volta/lib/<name>.volta, the first-hit location forimport "<name>"; - the live engine reloads the bank, so
sample "<name>"plays zoned and velocity-layered immediately — no stop, no manual copying.
The browser workbench#
The same dialog works in the browser: the workbench's in-app File menu and the command palette carry the same Autosample Instrument… command. The pipeline itself runs in the UI wasm (autosample_drive, ui-wasm/src/web.rs); the page island (runtime/src/autosample-island.ts) executes the seams: Web MIDI output (substring port match), and a recorder worklet (runtime/public/worklets/capture-recorder.js) tapping a raw getUserMedia stream — echo-cancel/AGC/noise-suppression off, no limiter in the path. Onset trim absorbs Web MIDI + input latency (thresholds are relative to each capture's own peak by design). A completed run installs true keymapped, velocity-layered zones into the live wasm engine (stage_sample_zone with each zone's manifest object, then commit_sample_zones; ui-wasm/src/web.rs derives the land payload from the same manifest_fragment) and offers the whole set — WAVs, manifest.json, the module — as downloads. The browser keeps nothing across reloads: download the set to keep it (drop it into a checkout or the desktop workspace like any capture). Plugin capture is desktop-only (no native plugin hosting in wasm), and the first open's device probe is the permission gesture for Web MIDI + the microphone.
Using a CLI capture in a checkout#
- Copy the output folder to
runtime/public/samples/mysynth/. - Merge
manifest.json's entries intoruntime/public/samples/index.json. - Import the module (drop
mysynth.voltain your workspace /public/lib/) or paste its patch into a song. The runtime picks the nearest-root zone per note like any multisample.
(Or capture from the desktop dialog instead, which lands everything in the ~/Documents/Volta workspace automatically — see above.)
Verification#
The capture loop is factored over the seam triple; tests (capture/src/tests.rs) drive the whole pipeline against a fake synth on a virtual clock — a decaying sine that speaks 50 ms after note-on with velocity-scaled amplitude — asserting the file set, onset-trim within ±10 ms, the global (not per-file) normalization law, the one layered manifest entry (roots and loVel/hiVel per zone), that the fragment parsed by the engine's own manifest_zones selects the soft capture for a soft note and the loud one for a loud note at every root (layer edges included), the event stream's shape, cancel's Ctrl-C-equivalence (kept WAVs, no manifest, resume finishes), and that the generated module compiles. The workbench dialog has its own kittest suite (ui/tests/autosample_panel.rs) over a scripted bridge: caps gating, form→request validation, progress rows, and the cancelled-run resume seeding. End-to-end hardware capture has not been run in this environment (no synth attached); plugin capture was e2e-verified against real FM8/Kontakt via the CLI, and --list/--dry-run against real device enumerations.