DUET docs: libduet, the engine as a library (summary)
docs/dev/libduet.md — everything in duet that makes sound is the Zig module duet, with the C ABI libduet putting a C header over the same engine, "in libopenmpt's shape". Bytes in, samples out: the library opens no file, creates no thread, prints nothing, owns no audio device — "the tracker (duet, duet-gui) is only its first user." For programs in C, C++, Rust or Zig: players, plugins, games, demos, render tools.
Key claims#
Shape and laws
- Two objects: a song (
duet_song, the document — loadable/savable, nothing that renders) and an engine (duet_engine, the graph over a song at a sample rate). A song can be edited while an engine plays it, under the threading contract. - Three laws: a render allocates nothing, takes no lock, does no I/O; register writes land on span boundaries (a note starts on the exact sample its tick starts on, wherever that falls in the buffer); what renders is what the tracker renders —
tools/capi-ab.shrenders 8 corpus songs (one per family) throughcplay,zigplayandduet --renderandcmps the 16 pairs, barALL IDENTICAL. - Byte identity across buffer sizes: one ten-second call or a thousand 480-frame calls render the same bytes for every family — "the call pattern is not a property of the sound".
- GPL-2.0-or-later (reSID is inside, compiled in; the linking program needs the C++ runtime). The header is C89-clean through C++17 under
-pedantic -Werror. Inside the tree everything imports the module; nothing butsrc/app/is the app. - The library never sees a path: a
.zonproject names its song files, and the host supplies their bytes through a synchronous resolver callback, called once per file with the spelling as written. - The loop law:
loop = 0ends where a render ends (order list out, or a row repeats with the same loop state — howduet --renderknows when to stop); −1 = forever. A SID-Wizard module "has no such map: it ends when every track has halted, and most never do" — players use a cap (600 s default in the examples). - Length = "what libopenmpt's is": a dry run of a private engine to the exact end frame; never on the render thread; cache it.
- Threading contract: exactly one render thread; one other thread for the value verbs (transport, mute/solo, keyboard, position, ring) — each an atomic taken at the next span head; structural verbs (
new/free/rebuild, subtune/model/quality setters) require no render running.
The event ring and the sync markers (the demo/game road)
- Polling the position only says where the transport was; a row can start anywhere inside a buffer. So the render thread writes an event at every dispatch, stamped with the output clock at the tick's own boundary, into a 4096-event SPSC ring (24 bytes/event) the host drains from one thread. Kinds: ROW (the sync workhorse), ORDER, PATTERN_LOOP, SONG_LOOP, SONG_END, NOTE_ON (note+instrument), NOTE_OFF, EFFECT (first-tick dispatch, spelled in its dialect), VOLCOL, MARKER. A
dialectbyte says whose cell it was (IT / SID column / DUST / DUSK / SW track — a SW event carries the module's own 1-based pattern number and its own orderlist position); a co-play carries both dialects on one clock. - Undrained rings drop the newest events and count them; drain once per video frame and you never drop.
framesin the position struct is the output clock — never reset by transport verbs, not a playing time; the events are stamped in it. - Sync markers: inaudible points with a 16-bit value, set on the grid with
ALT+G, living only in the.zon(.markerstable) — no.itplayer can hear or lose them. Addressed by pattern/row/column; a reused pattern fires its markers at every occurrence (the event'sordertells occurrences apart). "A demo syncs on the rows' frames … and reads the markers for the moments the song's author placed by hand; a game reads only the markers." Comparing a marker's frame with the output clock of audio already handed to the device "is what makes the picture land on the beat rather than a buffer late." - Instruments as sound effects:
duet_engine_note_onby family — PCM (IT instrument), SW (a SID-Wizard instrument from the project's pool through a chip;jam = 1builds a chip whatever the song), DUST, DUSK — "a game's instruments-as-sound-effects road". Keyboard notes are not on the ring. - Hosts that draw the song: pure-read pattern access; a SID-Wizard module's tracks are separate order lists; SW patterns live above number 256 keeping SW's own 1-based numbering;
duet_song_format_cellspells a cell exactly as DUET's grid draws it (IT face 14 chars, SID/SW faces 11). - Mute targets include one chip voice — muted by holding its gate shut while the chip runs on, "so a neighbour that syncs to it keeps its source" (ring-modulation-and-sync).
The web player (duet.wasm)
- The same library compiled for the browser:
wasm32-wasireactor, the 53 C exports unchanged plus a six-verb file table (the resolver becomes a table lookup — "JavaScript never hands the library a C callback"). Single-threaded by design: anAudioWorkletowns the instance, renders 128-frame quanta and takes the verbs on the audio thread; noSharedArrayBuffer; ring events are re-stamped into theAudioContextclock "so a row is drawn the moment it is heard". - Numbers (2-chip SW module, V8): ReleaseSmall 2.4 MB (253 KB gzipped) renders 22× real time, ReleaseFast 26×, native 34×;
resamplequality 9× in wasm. tools/wasm-ab.shrenders the 8 corpus songs through the wasm module againstduet --render: six byte-identical — reSID's whole sample path is integer,resampleincluded — and two (float DSP usingsin/exp/cos: DUST, the send room) within 1 LSB at correlation 1.00000, the difference being Apple's libm vs wasi-libc's.- The page: drop a song, open files or a whole folder, a 90-song gallery (2.6 MB: DUET's demos GPL, all 70 SID-Wizard example modules from hermit's WTFPL folder credited by their
AUTHOR:TITLEheaders, and third-party.its only under PD/CC0/CC BY, each with author, licence and source;gallery/MANIFEST.mdis the provenance in prose). A live note strip, one column per sounding channel/track coloured by dialect — a SW track scrolls at its own row clock beside the IT grid — and a movy-style visualiser (VU/spectrum/scope/plasma/copper). - Real-time trap for hosts: a Debug build renders 6–10× slower, and a starving device "plays silence while the counters keep moving — which looks exactly like a silent library"; the examples print
render NN% of real time, the same meter as the tracker's CPU number.
Practical takeaways#
- A demo or game that wants tracker-synced visuals over an
.it,.swmor co-play gets frame-exact rows and author-placed markers with three calls: open, render, drain the ring (sync). - SID-Wizard instruments become game SFX through the jam road — no pattern needed.
- The Zig module also exposes the writers (
duet.it.writer,duet.sid.swwriter/swiwriter,duet.project.serialize) — a program can produce.it/swm-format files as bytes. - The wasm A/B is indirect evidence about resid itself: its sample path is fully integer — bit-identical across native and wasm even at
resamplequality.
Notable quotes#
- "Bytes go in, samples come out."
- "A render allocates nothing, takes no lock, does no I/O."
- "The call pattern is not a property of the sound."
- "A program built on them keeps working. A changed signature … is a break and a version conversation, never a quiet edit."
Relevance to the wiki#
The demoscene-facing half of duet: music-to-code sync as a first-class engine feature (the ring + markers), the libopenmpt-shaped ABI as the C world's entry to swm-format playback, and the browser as a full playback target for SID-Wizard modules.
Pages touched#
Updated: duet (libduet/web-player section), sync (syncing visuals to music), resid (integer sample path evidence), sound-effects.