Mental model#
Six concepts cover everything dart-yse does.
System#
System is the engine itself. Access the singleton through
System.instance. It owns the audio device, the engine’s
internal worker threads, and the scheduler that turns
Sound.play() calls into samples on the sound card.
The lifecycle is:
System.instance.init()once at startup.System.instance.update()once per frame (orstartUpdateTimer()to drive it from a periodicTimer).System.instance.close()once at shutdown.
Between init and close the engine is alive and accepting commands.
Threading constraint: every call into yse must happen on the
same isolate that called init(). The audio callback runs on a
private thread the wrapper never exposes.
Rendering. The engine renders audio in fixed blocks of
System.blockSize frames (128), whatever the device’s
callback size; System.renderOffline() takes a count of these
blocks. Each block runs through a task-graph scheduler: the audio
thread renders, and worker threads pick up independent parts of the mix
(channels, sounds) in parallel. System.renderThreads sets how
many workers there are — -1 (default) for auto (physical cores
minus one, at most 8), 0 to render on the audio thread alone (the
right choice on Android and other constrained hardware), or an exact
count. Set it before init(); during a device session a new value
waits for the next init, and it survives close(). The worker count
never changes how the mix is summed, so the output is bit-identical at
any setting. System.activeRenderThreads reports the workers
running now.
Callbacks and threads. update() is where most engine-to-host
traffic reaches Dart, but not all of it, and the wrapper bridges each kind
according to the thread the engine calls it on:
Callback |
Engine calls it on |
Reaches Dart |
|---|---|---|
|
the thread running |
synchronously, inside |
|
the control thread: inline for a host publish, otherwise during
|
as stream events, a microtask later |
|
whichever engine thread emitted the message (the control thread for
|
asynchronously, through the isolate’s event loop |
|
whichever thread logged, or RtMidi’s input thread |
asynchronously, through the isolate’s event loop |
|
the thread running |
as stream events |
Audio-thread hooks (a synth’s note-rewrite callback, custom voices, custom
DSP sources) are deliberately not surfaced: the audio thread cannot safely
re-enter Dart. Sessions and devices covers what update() does for
devices (the auto-reconnect watchdog runs inside it).
Randomness. The engine’s random generator (behind .random,
granulator jitter and the LFO random shapes) starts from a fixed seed,
so a patch renders the same output every run. Call
System.randomSeed() for a different repeatable stream or
System.randomize() to seed from the clock, before building the
patch or sounds.
Listener#
Listener is the singleton “ear” in the virtual scene.
Update its Listener.position every frame; the engine
computes panning, distance attenuation, and doppler shift relative
to it.
Listener.instance.position = Pos(playerX, playerY, playerZ);
Listener.instance.orient(facingDirection, up: Pos(0, 1, 0));
Sound#
Sound is a playable instance — one Sound object per voice
in the scene. The source can be a file
(Sound.fromFile()), an in-memory buffer
(Sound.fromBuffer()), or a patcher graph
(Sound.fromPatcher()). Sounds can move, loop, fade, stop,
seek; the engine de-duplicates the underlying decoded buffer when the
same file is loaded into multiple sounds.
Channel#
Channel is a node in a mixing tree. Every sound is attached
to a channel; channels can themselves be attached to a parent channel,
forming a tree rooted at Channel.master. The render
scheduler (see System) can mix independent channels on different
worker threads, so spreading sounds across channels lets the engine
parallelise mixing across CPU cores.
A small set of pre-built channels (Channel.master,
Channel.music, Channel.ambient,
Channel.voice, Channel.gui,
Channel.fx) is created for you. Use them as-is or as roots
for your own subtrees:
Channel.master (all output flows here)
├── Channel.music ← background tracks
├── Channel.ambient ← environment loops
├── Channel.voice ← dialogue
├── Channel.gui ← UI feedback
└── Channel.fx ← short SFX
Reverb#
Reverb is a positioned reverb zone — a sphere in the scene
that lends its parameters to any nearby listener. Multiple reverbs
can overlap; the engine blends them by proximity so the listener
transitions smoothly between cave, hall, and bathroom as they walk.
A “global” reverb (System.globalReverb) acts as the
fallback everywhere no positioned zone reaches.
Patcher#
Patcher is a Max/MSP-style modular DSP graph: a collection of
small objects (oscillators, filters, math, MIDI, GUI controls) wired
together by inlets and outlets. Use it when a sound is not a file to
play but a network to evaluate — procedural synthesis, parameter
mapping, generative MIDI.
final p = Patcher(mainOutputs: 1);
final osc = p.createObject(Obj.dSine, args: '440');
final out = p.createObject(Obj.dDac);
p.connect(osc, outlet: 0, to: out, inlet: 0);
final sound = Sound.fromPatcher(p); // play the patcher as a sound
Graphs can be serialised with Patcher.dumpJson() and rebuilt
with Patcher.parseJson() — pair the engine with an external
editor or ship presets as plain text. The full set of registered
object types — every inlet, outlet, parameter, and accepted message
type — is on the Patcher object reference reference page,
generated directly from the C++ engine source so it can never drift.
Putting it together#
On every frame the interaction looks like:
The application calls
System.instance.update()to advance engine state.The application updates
Listener.positionto wherever the player / camera is.Sounds attached to channels render through the channel tree.
Positioned reverbs nearest the listener are blended into the output.
That is the whole picture. Everything else — DspObject
effects, MidiOut, MidiIn, the generative
Player, Patcher — is built on top of these six
concepts.