Turn an existing SFZ instrument into a Volta sample bank you can play by name.
What it is#
SFZ is a plain-text instrument format: a list of <region>s, each naming an audio file and the keys and velocities it answers. SFZ import converts one into a zoned bank in the Multisampled instruments manifest schema: it copies the referenced audio into your song's samples/<bank>/ folder, merges one entry into samples/index.json, writes a ready instrument module samples/<bank>/<bank>.volta (as Capture an instrument as samples does), and reports every opcode it did not map, with its file and line. Conversion happens once, on the host; the engine never reads SFZ.
Usage#
volta import sfz "Salamander Grand.sfz" --out mysong/
volta import sfz strings.sfz --name strings_ensemble
| Flag | Meaning | Default |
|---|---|---|
--out DIR / -o | the song folder; audio goes to DIR/samples/<bank>/, the entry to DIR/samples/index.json | the current directory |
--name | bank name | the .sfz file stem |
Bank names are made URL-safe: lowercase letters, digits, _ and - (Salamander Grand.sfz → salamander-grand). The output lists the bank, its zone and region counts, every ignored opcode as file:line <header> name=value, and every warning. The same text is saved as samples/<bank>/sfz-import.txt.
Desktop Studio: drop a .sfz file, or a folder with .sfz files at its top level (the usual layout of an SFZ library), anywhere on the workbench. Each instrument becomes one bank under the song's samples/, the bank reloads, and a toast gives the summary, for example imported tinypiano (tinypiano: 7 zones from 8 regions, 2 opcodes ignored, 1 warning). The full report is in sfz-import.txt. A folder that contains no .sfz imports as a plain variant bank, as before. The browser workbench does not accept drops yet.
Play the result through the generated module, or like any zoned sample:
import "samples/salamander-grand/salamander-grand" (salamanderGrand)
play salamanderGrand !"c2 e3 g4 b5"
keys = patch { source = sample "salamander-grand", ampEnv = adsr 2ms 0ms 1 600ms }
play keys !"c2 e3 g4 b5"
The generated module (it compiles standalone; the bank name resolves at run time) records the source file and what the bank holds, and exposes one patch — named after the bank as a binding, salamander-grand → salamanderGrand, with an sfz prefix when the name would start with a digit — plus a demo track over the recorded span, so opening it as a song makes sound:
-- tinypiano — imported from tinypiano.sfz (generated by volta import sfz)
-- Bank "tinypiano": 7 zones, roots C3..C5, 2 velocity layers, 2 round-robin slots, 1 release zone.
-- Use it from a song beside samples/: import "samples/tinypiano/tinypiano" (tinypiano)
tinypiano = patch {
source = sample "tinypiano",
ampEnv = adsr 2ms 0ms 1 400ms,
gain = vel * 0.7 + 0.3
}
demo = play tinypiano !"c3 g3 c4 c5"
A re-import rewrites the module. The CLI prints its path and the import line; the report file ends with it too.
What converts#
The parser handles // and /* */ comments, #define $NAME value, #include "file" (looked up beside the including file, then beside the root .sfz), values with spaces (sample=Grand C4.wav), and backslash paths. Headers follow SFZ v2 inheritance: <global> → <master> → <group> → <region>, with the most specific level winning. A new <global> clears master and group, and a new <master> clears the group. key= sets lokey, hikey and pitch_keycenter at its own level. Note names use C4 = 60 (c#4, db4, c-1), shifted by <control> octave_offset / note_offset. Sample paths are <control> default_path + sample, relative to the root .sfz file's folder.
| SFZ | Manifest zone field |
|---|---|
sample | file — copied to <bank>/<its relative path>, each component made URL-safe (# → s, other unsafe characters → -; .. dropped; name collisions get -2) |
pitch_keycenter (or key) | rootPitch as a note name; default C4. pitch_keycenter=sample reads the audio file's WAV smpl chunk MIDI unity note (dwMIDIUnityNote); a file without one (or not a WAV) keeps C4 with a warning |
lokey, hikey | loKey, hiKey. Omitted when the region gives no key range, so the loader's Voronoi split applies |
lovel, hivel | loVel, hiVel (clamped to 1..127). Omitted when the region covers every velocity |
seq_length > 1 + seq_position | rr (1-based) |
lorand / hirand | rr slots numbered in lorand order within each key/velocity cell, plus "rrMode": "random" on the entry |
trigger=release (release_key too) | "trigger": "release" |
xfin_lokey/xfin_hikey, xfout_lokey/xfout_hikey | xfadeKeys = the wider span |
xfin_lovel/xfin_hivel, xfout_lovel/xfout_hivel | xfadeVel = the wider span |
volume, group_volume, master_volume, global_volume, amplitude | gain as a dB string ("-9.02dB"), summed |
tune (alias pitch), transpose | tune in cents (tune + 100 × transpose) |
offset, end | offset, end — sample frames, as SFZ writes them (end is the last frame played) |
loop_mode (alias loopmode) | loopMode: "continuous", "sustain", "oneShot"; no_loop writes nothing |
loop_start, loop_end (aliases loopstart/loopend) | loopStart, loopEnd — frames; loopEnd is SFZ's inclusive loop_end + 1 (the manifest's end is exclusive) |
ampeg_attack, ampeg_decay, ampeg_release (seconds), ampeg_sustain (percent), on a release region | ampAttack, ampDecay, ampRelease, ampSustain (0..1): the release one-shot's own envelope. On attack regions they are consumed with one warning: attack zones play through the patch's ampEnv |
Zones are ordered attack-first, then by root, then loudest layer first, because the loader keeps the first zone under the flat key used by sound hits and the waveform view.
Reported, not converted: every other opcode (envelope stages with no zone field such as ampeg_hold/ampeg_delay, fileg_*/pitcheg_*, filters, amp_veltrack, group/off_by choke groups, keyswitches sw_*, CC modulation, <curve>/<effect>/<midi> headers and their opcodes). Each one is listed once at its source line, however many regions inherit it. Warnings cover approximations and skips:
- A region is skipped if it has no
sample, uses a built-in generator (*sine,*noise), names a missing audio file, haslokey > hikeyorlovel > hivel, has aseq_positionoutsideseq_length, hasend ≤ 0oramplitude=0, or has an emptylorand/hirandrange. - Unequal
lorand/hirandweights are approximated as uniform. Ranges that do not cover [0, 1) still always sound one slot. - Asymmetric crossfades are written as the wider span.
pitch_keycenter=sampleon a file with nosmplunity note: C4 is used.ampeg_*on attack regions (once per instrument).trigger=first/legatoare treated as attack.- An entry mixing
seq_lengthandlorandround-robins getsrrMode "random"for both. - A missing
#include, an include cycle, or unparsable text.
An instrument with no playable region is an error and writes nothing. A re-import reuses audio files already present with the same length and rewrites the entry (last write wins per bank name, the loader's rule). The merge edits only that bank's entry in the TEXT of index.json (volta_sfz::upsert_entries): every other entry keeps its formatting and order — one-line hand-written entries stay one line — and a new bank is appended at the file's own indentation. A file that is not a JSON object is refused, not overwritten.
What plays today#
Every field above is written now. Which ones sound depends on the engine stages:
- Now (Sampling Workflow Stage 5):
file,rootPitch,loKey/hiKey,loVel/hiVel,rrandrrMode,xfadeKeys/xfadeVel,trigger: "release"with itsamp*envelope,gain,tuneandoffset/end, on desktop, in the browser and in every export (Multisampled instruments).engine/tests/multisample_depth.rsplays the convertedtinypianofixture through the zone table. - Loops (Stage 3):
loopStart/loopEndbecome the zone buffer's loop points, andloopModeturns the loop on without a|> loopin the song:continuousloops through the release,sustainleaves the loop at note-off;oneShotplays through. An explicitloopwins.
How it's implemented#
sfz/(cratevolta-sfz, dependency-free apart fromserde_json):parse.rsturns text into regions with merged opcodes plus every opcode occurrence with its level.convert.rsis pure (no filesystem): regions →Zones → the §6 manifest entry, with the ignored/warning report.import.rsdoes the copy, theindex.jsonmerge, thesmplunity-note read (resolve_sample_roots,smpl_unity_note), the generated module (module_source,patch_name) and the report file.- CLI:
cli/src/sfzimport.rs, dispatched before the MIDI/DAWprojectimporthandler incli/src/main.rs. - Desktop:
app-native/src/samples.rs::import_filesroutes.sfzfiles, and folders with top-level.sfzfiles, throughvolta_sfz::import_sfzon the existingvolta-importworker. The bridge resultImportedSamples { names, notes }(ui/src/bridge.rs) carries one summary line per instrument into the toast (ui/src/app/dropimport.rs). - Tests: parser and mapping unit tests in
sfz/src/{parse,convert}.rs. The fixture instrumentsfz/tests/fixtures/tinypiano/has groups, two velocity layers, round-robins, a release group, an#includeand a#define; its WAVs are generated in-test, and it must convert totinypiano.expected.json(sfz/tests/suite.rs;SFZ_BLESS=1rewrites the file) and write a module that compiles; the suite also reads a WAV with a hand-builtsmplchunk (unity note A3) beside one without, and mapsampeg_*on a release region.cli/tests/sfz_cli.rsdrives the binary. The app-native testa_dropped_sfz_folder_imports_as_one_zoned_bank_the_loader_readsloads the converted bank through the desktop loader into its (root, buffer) zone table.