uvi-script
Musical event scripting with Lua
Loading...
Searching...
No Matches
Sample Mapping

Overview

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.

Note
The Sample Mapping oscillator does not appear in the element browser, so you cannot insert one by hand — it comes with the program that carries it. A script can then drive it fully: load a mapping, purge zones, route notes. It just cannot create one.

The dispatch model

The four axes

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, lowKeyhighKey
velocity the zone's velocity range, lowVelhighVel
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.

Note
The MIDI channel is deliberately not used as a fallback for dim1. Dimensions are only ever what the script puts in the event.

Which zones can sound

A zone is a candidate only when it is loaded and not purged. Both conditions matter in practice:

  • notes played while a mapping is still loading are silent, because no zone has finished loading yet;
  • notes routed to a purged layer are silent until the layer is unpurged.

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.

Note
This applies to real-time playing. An offline render is allowed to block, so a note triggered there waits for its zone instead of dropping it. A mapping that sounds full in a bounce but has a silent attack live is showing you the difference, not a bug in either.

Round robin

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.

Mapping files

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.

The .dmap format

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:

<?xml version="1.0" encoding="utf-8"?>
<layers channelMax="2">
<layer name="default">
<zone path="../../Samples/Strings/Strings-C2.wav" baseNote="48"
lowKey="0" highKey="51" lowVel="1" highVel="127"/>
<zone path="../../Samples/Strings/Strings-C3.wav" baseNote="60"
lowKey="52" highKey="63" lowVel="1" highVel="127"/>
<zone path="../../Samples/Strings/Strings-C4.wav" baseNote="72"
lowKey="64" highKey="127" lowVel="1" highVel="127"/>
</layer>
</layers>

Layers and round-robin variants are what the extra dimensions address. Adding a second layer and a second take per zone:

<layers>
<layer name="sustain">
<zone path="Samples/vln_sus_C4_p.wav" baseNote="60"
lowKey="58" highKey="62" lowVel="1" highVel="63"/>
<zone path="Samples/vln_sus_C4_f.wav" baseNote="60"
lowKey="58" highKey="62" lowVel="64" highVel="127"/>
</layer>
<layer name="staccato">
<zone path="Samples/vln_stac_C4_a.wav" baseNote="60"
lowKey="58" highKey="62" rr="1"/>
<zone path="Samples/vln_stac_C4_b.wav" baseNote="60"
lowKey="58" highKey="62" rr="2"/>
</layer>
</layers>

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.

The SFZ subset

.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.

Path resolution

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:

  1. the path exactly as written;
  2. the folder holding the script;
  3. the folder holding the program;
  4. the folder holding the performance;
  5. the program's original path, then its 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.

Scripting the oscillator

Reaching the oscillator

The oscillator is reached by walking the engine hierarchy, like any other (see Engine):

local osc = Program.layers[1].keygroups[1].oscillators[1]
if osc.type == "SampleMappingOscillator" then
print(osc:getNumZones(), "zones loaded")
end
A Patch that represents a monotimbral instrument.
Definition Engine.cpp:257
table layers
Layer list for this Program (1-indexed, use #Program.layers to get the count)
Definition Engine.cpp:266
Warning
Check 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.

Loading a mapping

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.

osc:loadMapping("Mappings/violin.dmap")
spawn(function()
while osc:getLoadingStatus() < 1.0 do
progressBar:setValue(osc:getLoadingStatus(), false)
wait(30)
end
progressBar.visible = false
end)
function wait(ms)
suspend the current thread callback execution for the given number of milliseconds.
Definition wrapper.lua:39
function spawn(fun,...)
Launch a function in a separate parallel execution thread (deferred execution)
Definition api.lua:1201

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:

osc:loadMapping("Mappings/violin.dmap", function(task)
if not task.success then print("mapping missing or unreadable") end
end)

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:

local userReady = false -- flipped in onInit, after preset state is restored
local function loadInstrument(name)
if not userReady then return end
-- The mapping is about to be replaced: silence the layer and stop
-- triggering, since notes played against a half-loaded mapping are silent.
postEvent{ type = Event.ControlChange, controller = 120, value = 0, layer = 1 }
loading = true
progressBar.visible = true
osc:loadMapping("Mappings/" .. name .. ".dmap", function(task)
if not task.success then print("could not load", name) end
end)
spawn(function()
while osc:getLoadingStatus() < 1.0 do
progressBar:setValue(osc:getLoadingStatus(), false)
wait(30)
end
progressBar.visible = false
loading = false
end)
end
instrumentMenu.changed = function(self)
loadInstrument(self.selectedText)
end
function onInit()
userReady = true -- the program's own mapping is already in place
end
Event types.
Definition Engine.cpp:723
void onInit()
initial callback that is called just after the script initialisation if the script was successfully a...
function postEvent(e, delta)
send a script event back to the script engine event queue.
Definition api.lua:857

The poll has to live in a spawn or a run — a widget callback is not a script thread, so it cannot wait itself.

Note
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.

Parameters

Set like any other element parameter (see Element):

osc:setParameter(uvi.SampleMappingOscillator.RoundRobinMode, 2) -- Random
osc:setParameter(uvi.SampleMappingOscillator.InterpolationMode, 2) -- Best
print(osc:getParameter(uvi.SampleMappingOscillator.MappingPath))
  • 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.
Warning
Writing 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.

Starting a note inside the sample

setSampleOffset works on this oscillator, addressing whichever zone the voice resolved to:

function onNote(e)
local id = postEvent(e)
setSampleOffset(id, 40) -- start 40 ms into the zone's sample
end
void onNote(table e)
event callback that will receive all incoming note-on events if defined.
function setSampleOffset(voiceId, value)
changes a sample starting point in milliseconds.
Definition api.lua:569

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.

Note
The limit follows how the sample is actually held, not the 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.

Purging zones

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":

osc:purgeZones(1, -1) -- free every sample of layer 1
osc:unpurgeZones(1, -1) -- load them back
osc:purgeZones(1, 3) -- free just the dim2 = 3 variant of layer 1

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.

Warning
None of the generic sample calls reach this oscillator, and they fail in three different ways — which is the trap. purge and unpurge do nothing at all, yet their task still reports 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.

Dispatching notes

dim1 and dim2 are extra fields on a note-on event table handed to postEvent

function onNote(e)
e.dim1 = currentArticulation
local id = postEvent(e)
end

Adding the field to the incoming event and forwarding it is usually all it takes. Building the event from scratch works the same way:

local id = postEvent{ type = Event.NoteOn, note = 60, velocity = 100, dim1 = 2 }

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.

Warning
playNote does not forward 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.

Pitfalls

These all come from the same place — loading is asynchronous, and the calls that look synchronous are weaker than they appear.

Warning
getNumZones describes the mapping that is currently live, not the one you just asked for. A requested mapping only becomes live once the worker thread has parsed it, so a 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.
A failed load is invisible without a callback. A missing file, a malformed mapping, or a mapping with no usable zone raises a "Sample Not Found" error in the host and restores the previous mapping path. Nothing about that reaches a script that did not ask: 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.
Note
waitForLoading blocks its calling thread, and script code runs on the audio thread — calling it from a script interrupts audio. It also returns immediately when the load it was meant to wait for has not been picked up yet. Poll getLoadingStatus instead; there is no good reason to call it from a script.

A complete example

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.

See also
SampleMappingOscillator, Oscillator, Asynchronous Operations, postEvent