![]() |
uvi-script
Musical event scripting with Lua
|
A Sample oscillator plays one sample. A SampleMappingOscillator plays a whole sample set: it holds a list of zones and picks one per note, along four axes — key, velocity, and two extra dimensions the script controls directly. That makes it the oscillator to use for multi-articulation instruments, multi-microphone sets, and round-robin variations, where the alternative would be one keygroup per articulation and a script juggling layers.
The zone list comes from a mapping file on disk (.dmap, .xml or .sfz), which the script loads at run time. This guide covers both halves: the file format, then the script API that loads it and dispatches notes into it.
Every zone in a mapping declares a key range, a velocity range, and a position on two further axes:
| axis | meaning |
|---|---|
key | the zone's MIDI key range, lowKey … highKey |
velocity | the zone's velocity range, lowVel … highVel |
dim1 | the logical layer — articulation, microphone, dynamic level |
dim2 | the round-robin variant inside that layer |
A note-on carries key and velocity as usual, and may carry dim1 and dim2 as extra fields — see Dispatching notes. Exactly one zone is selected, and the voice plays that zone's sample.
When a note-on carries no dimensions at all, dim1 is 0 and dim2 is chosen by the round-robin policy. A single-layer mapping therefore plays from plain MIDI notes with no dispatch code at all, leaving the script nothing to do but load it. The dimensions come in once one oscillator has to cover several articulations or several takes.
dim1. Dimensions are only ever what the script puts in the event.A zone is a candidate only when it is loaded and not purged. Both conditions matter in practice:
Neither case is an error and nothing is reported — the note simply produces no voice. When a mapping seems mute, check the load state first.
When several zones match (key, velocity, dim1) — that is, when the layer holds several dim2 variants — the SampleMappingOscillator::RoundRobinMode parameter decides which one plays:
| value | mode | behaviour |
|---|---|---|
| 0 | First | always the lowest dim2 |
| 1 | Cycle | successive notes walk the variants in order (default) |
| 2 | Random | a variant is drawn at random |
Passing an explicit dim2 bypasses the policy entirely: the script has already named the variant it wants.
The extension selects the parser: .dmap and .xml are read as the UVI mapping format described below, .sfz as the SFZ subset. Any other extension is rejected.
A mapping file is a layers root holding layer elements, each holding zone elements. The common shape is a single layer of key-split zones — a multisampled instrument, no dimensions involved:
Layers and round-robin variants are what the extra dimensions address. Adding a second layer and a second take per zone:
dim1 0 is now the sustain layer, split into two velocity zones; dim1 1 is the staccato layer, with two round-robin variants (dim2 0 and 1) over the full velocity range.
A layer's dim1 is its position in the file**, counted from 0 in order of appearance. The name attribute is documentation for whoever reads the file — nothing dispatches on it.
zone attributes:
| attribute | type | default | notes |
|---|---|---|---|
path | string | — | required; relative to the mapping file's own folder, or absolute |
lowKey / highKey | 0…127 | 0 / 127 | key range |
lowVel / highVel | 1…127 | 1 / 127 | velocity range |
baseNote | 0…127 | 60 | the sample's root note |
rr | ≥ 1 | 1 | round-robin index; dim2 is rr - 1 |
tune | number | 0 | fine tune in cents, on top of baseNote |
gain | number | 0 | zone gain in dB |
purged | 0 / 1 | 0 | when 1, the zone is not loaded with the mapping |
streaming | 0 / 1 | 1 | stream the sample from disk rather than hold it in memory |
maxSampleStart | number | 0 | ms; how far into the sample a voice may start, see Starting a note inside the sample |
Out-of-range values are clamped to the ranges above. A zone whose key or velocity range is inverted (lowKey above highKey) or whose path is missing is skipped silently, so a typo costs you one zone rather than the whole file.
tune is where a tuning table belongs: per-zone cents are applied once when the zone is picked, which is cheaper than correcting pitch on every note the script plays.
The optional channelMax attribute on layers declares the widest channel count in the set, so the oscillator can size its output bus before the first sample arrives. It can be left out.
maxSampleStart can be written at any of the three levels and each inherits the one above: on layers it sets the default for every layer, on a layer for every zone in it, and on a zone for that zone alone. Declaring it once at the top is usually enough.
Loading fails outright — no zones, previous mapping kept — when the file cannot be parsed, when there is no layers root, or when not a single valid zone survives.
.sfz files are read with a deliberately small opcode set, enough to consume mappings exported by common sampler tools:
| opcode | maps to |
|---|---|
sample | the zone's sample path |
lokey / hikey | key range |
key | shorthand: sets lokey, hikey and pitch_keycenter at once |
lovel / hivel | velocity range (0 is clamped to 1) |
pitch_keycenter | baseNote |
tune | fine tune in cents |
volume | zone gain in dB |
seq_position | round-robin index; dim2 is seq_position - 1 |
Headers behave as SFZ implementations expect: <global> opcodes are inherited by every group, <group> opcodes by every region below it, and <region> produces one zone. <control> is read for default_path, which prefixes every relative sample path. Both // line comments and C-style block comments are stripped. Unrecognised opcodes are ignored rather than treated as errors.
Each <group> becomes a dim1 layer**, in order of appearance, so a single-group file — the common case — stays at dim1 0 and a multi-group file becomes a multi-layer mapping with no extra markup. Zones from an SFZ file are always streamed and never start purged.
Two resolutions happen, and it is worth keeping them apart.
The zone paths inside a mapping file resolve against the folder holding that mapping file. A mapping in Mappings/violin.dmap with a zone path of Samples/vln_C4.wav reads Mappings/Samples/vln_C4.wav. Absolute paths are used as they are.
The mapping path passed to the script is tried against several bases, and the first one that exists wins:
Sounds/Samples folder.So a script shipped next to its mappings, and a script shipped next to the program that loads them, can both just say "Mappings/violin.dmap".
A path saved in a program is a third case, and the one that applies when a patch carries its mapping instead of loading it from script. It resolves against the program's root — but a plain-XML .uvip does not set that root, so a relative path saved in one is tried against the working directory and will normally miss. Store an absolute path, or a $Volume-prefixed one, which resolves on every load route. A program that cannot find its saved mapping still loads: silently, with no zones, and with MappingPath still reporting the stored string. An oscillator that is mute on a freshly opened patch is worth checking against that.
The oscillator is reached by walking the engine hierarchy, like any other (see Engine):
type before calling anything. Every method below exists only on this class, so on any other oscillator the call is not a no-op — the member is nil, which raises an error and kills the script. The guard is mandatory whenever the oscillator's type is not certain, such as a keygroup a user could have edited.osc:loadMapping(path) starts a load and returns immediately — the samples arrive on a background thread. What you wait on is getLoadingStatus: it drops to 0 the moment a load is requested and climbs back to 1 when every zone has finished, so the same value both paces the wait and feeds a progress bar.
Whether the mapping actually loaded is a separate question, and a second argument answers it. Pass a function and it is called with the task once the load finishes, the same shape loadSample takes:
Pass true instead of a function to get the AsyncTask without a callback, for a script that would rather poll task.success itself. So the status is how you wait, and the callback is how you learn whether it worked.
Before writing either, be clear about when a script loads a mapping. Not in onInit — a program carries its own mapping and the engine restores it with the preset, so at init there is nothing to wait for and nothing to report. A script loads a mapping when it switches one at run time — the player picks another instrument, another layer, another sound — and that is where the progress readout earns its place, because the samples are arriving while the player waits.
Which makes the guard the first thing in the function. A persistent widget's changed callback also fires while a preset is being restored, and re-loading then would re-hit the disk for a mapping the program already holds — the same userReady flag IRLoader uses:
The poll has to live in a spawn or a run — a widget callback is not a script thread, so it cannot wait itself.
loadMapping's return value is not a success indicator: it says the request was accepted and nothing more, so if osc:loadMapping(p) then is a test that never fails. Pass a callback when you want the outcome.Loading a mapping replaces whatever the oscillator held before. Notes already sounding keep playing on the old zones until they end, so a swap under the player's fingers will not glitch — which is why the example above still silences the layer explicitly: the aim is to stop new notes from landing on a mapping that is only half there. clearMapping drops everything instead, cutting sounding voices.
Set like any other element parameter (see Element):
MappingPath — the mapping file the oscillator holds, as a string. Note that this is the resolved absolute path, not whatever relative string you passed to the load call. Read-only in practice: see the warning below.InterpolationMode — resampling quality: 0 Lo-fi, 1 Standard (default), 2 Best.RoundRobinMode — the variant policy described in Round robin: 0 First, 1 Cycle (default), 2 Random.SampleStartMillisecond — per-voice sample start, covered in Starting a note inside the sample below. Reach it with setSampleOffset rather than by name; it shares its identifier with Sample, so uvi.SamplePlayer.SampleStartMillisecond and uvi.SampleMappingOscillator.SampleStartMillisecond are the same parameter.MappingPath does not load a mapping. The parameter's setter only records the string — it exists so a saved program can restore its path before the load runs. Assigning it leaves the oscillator playing whatever it held before while reporting the new path, which is worse than doing nothing. Load with osc:loadMapping, which sets the parameter itself. Do not read it back as a completion signal either: the worker writes it when it starts the load, and only rolls it back if the file turns out to be unparseable.setSampleOffset works on this oscillator, addressing whichever zone the voice resolved to:
How far in you may start depends on how the zone is held in memory, and this is where a mapping has to opt in. A zone the engine streams from disk only has its opening resident, so a voice may start no further in than the zone's maxSampleStart — which is 0 unless the mapping says otherwise, meaning a streamed zone ignores the offset entirely until you declare one. A zone held whole in memory has no such limit and honours any offset inside the sample.
streaming attribute you wrote: the loader downgrades some samples to memory on its own, and such a zone then accepts any offset regardless of what the mapping declared. Do not read a working offset as proof that maxSampleStart was set correctly.Declaring maxSampleStart widens the preload to cover that window, so it costs RAM per zone — the preload is shared by every voice of the zone, so the figure to declare is the furthest in you ever intend to start, not a round number. An over-budget request is not an error: the voice starts as far in as the preload allows rather than dying. The offset is also bounded by the playable window, which ends at the loop end on a looping sample rather than at the sample end.
The value is consumed once, when the voice renders its first slice, so it has to be set in the same block as the note-on — which is what setSampleOffset does, it writes without smoothing. Setting it later will not scrub a sounding voice. To move future notes rather than one voice, set the parameter element-wide with osc:setParameter(uvi.SamplePlayer.SampleStartMillisecond, ms): every new voice is seeded from it.
Zones are purged and reloaded a layer at a time, which is how you keep a large multi-articulation set out of memory until the player needs it. Pass a negative dim2 to mean "the whole `dim1` layer":
Unpurging is asynchronous and reports nothing back, so a layer becomes audible some time after the call. Poll getLoadingStatus if you need to know when.
success == true, so a script that trusts it concludes a purge happened. purged always reads false, whatever you purged. loadSample does not silently fail: it raises and kills the script, because this oscillator is not a SampleBasedOscillator. Use purgeZones / unpurgeZones, and load through the mapping.dim1 and dim2 are extra fields on a note-on event table handed to postEvent —
Adding the field to the incoming event and forwarding it is usually all it takes. Building the event from scratch works the same way:
Either field may be given on its own. dim1 alone selects a layer and leaves the variant to the round-robin policy — the common case. dim2 alone stays on layer 0 and names the variant. Negative values are treated as 0.
The usual note-on fields still apply: vol, pan, tune, oscIndex and id behave exactly as they do for a plain note-on.
dim1 / dim2 — it has no parameters for them, and a note played through it lands on layer 0. Dispatching to a layer requires postEvent.Incoming events can also carry dimensions: when the engine generates a note-on that targets specific zones, onNote sees e.dim1 and e.dim2. Both are absent on ordinary MIDI notes, so test before reading them.
These all come from the same place — loading is asynchronous, and the calls that look synchronous are weaker than they appear.
getNumZones read taken right after a load call still counts the previous mapping's zones, or returns 0 when none was loaded. Read it after the wait, not before. getLoadingStatus is the exception and is safe to wait on: it reports 0 from the moment the load is requested.getLoadingStatus simply returns to 1 with getNumZones unchanged, and notes go on playing the previous mapping. task.success is the only way to see it.getLoadingStatus instead; there is no good reason to call it from a script.MappingArticulations in the example gallery puts the whole guide together: it loads a mapping asynchronously with a progress readout, selects the articulation with a menu, routes notes to it through dim1, and purges the layers that are not in use.