|
Tabula Sonora 0.1.0
A native C++20 implementation of the Roland Sound Canvas VA synth voice
|
A legally obtained SCCore.dll from a Sound Canvas VA installation. The library pins one exact build and refuses any other, because a different build moves every table offset:
| field | value |
|---|---|
| size | 27,347,456 bytes |
| SHA-256 | 117e6aa147a96fbde5e10d2caf16c89965acc1e44235fd245992216cc620bdb1 |
| PE timestamp | 2019-10-30 |
That build is the one shipped in SOUND Canvas VA 1.1.6. The release number is how you find the right installer and nothing more — the DLL carries no version resource, so the hash, the size and the timestamp are what identify it.
You also need CMake 3.24 or newer, a C++20 compiler, and vcpkg with VCPKG_ROOT set.
Presets: debug, release, asan (ASan and UBSan), tsan, player. On Windows there are also debug-vs, release-vs and player-vs, which use the Visual Studio generator and so configure from any shell — the Ninja presets expect a compiler already on the PATH, which means a Developer prompt.
If you know the C# engine, this is the part that changed. That project needed a one-off prepare run on Windows x64 to harvest the reverb and chorus coefficients by executing SCCore.dll and reading the running engine's state, because those numbers were believed to exist nowhere in the file. It wrote a presets.json you then had to keep alongside the binary.
They were in the file all along, encoded. ts::EffectProgrammer decodes them from your own copy — ring taps stored relative to a base the loader adds, and coefficients in a signed-14-bit floating format — matching a live harvest exactly, across all eight reverb macros, all eight chorus macros and both GM defaults, with no residual.
So nothing here ever loads the DLL as code, on any platform, for any purpose. There is no preset file to ship, no Windows-only step, and no preparation at all: point the engine at a DLL and render.
Every front end finds the ROM the same way — --dll, then $TS_SCCORE_DLL, then ./SCCore.dll. Pin it once and the path stops appearing in commands:
Two subcommands tell you where you stand. info verifies a DLL and describes it; manifest reports the build the embedded offset map is pinned to, and needs no DLL at all.
extract-tables writes every static table out as a .bin slice, which the test suite can use as a cache. It reads the file and never runs it.
| option | meaning |
|---|---|
| --map 1..4, --map xg | SC-55, SC-88, SC-88Pro, SC-8820 — the same program resolves to different tones. xg is not a fifth vintage: it starts the engine in XG mode, below. Names work too (--map sc88pro) |
| --mute 1,2 / --solo 5,6 | channels as a mixer labels them, 1–16 |
| --tail SEC, --end SEC | release tail, and truncation |
| --volume G | linear gain on the finished mix |
| --drum-map 0..5 | drum map row, when you want one the vintage would not pick |
| --no-reverb, --no-chorus, --no-delay | effects are on by default, as the module has them |
| --stream | limit polyphony to the hardware's 64 voices |
| --polyphony N | voice limit outright; 0 grows the pool on demand, and is the default |
| --ports 1\|2\|4 | 16, 32 or 64 parts; two is the hardware |
Every render goes through the block loop — there is no second renderer to choose between, and --stream no longer selects one. What it selects is the module's own voice limit, so that the stealing can be heard as the module would do it. The default instead grows the pool, so every note in the file sounds; render says afterwards which of the two happened, because a file that never ran out renders identically at any limit.
--ports 4 is past what the module can do and wants a voice limit raised to suit — sixty-four parts sharing sixty-four voices would steal without pause. See Architecture.
Two more subcommands exist for analysis: render-note writes a single note as raw interleaved float32, dump-effect writes a send effect's impulse response, and bench times the render path stage by stage.
The two players are not built by default, since they are the only things that pull in an audio backend and a UI toolkit:
tabula-sonora-play is a one-line terminal transport and stays usable when stdin is a pipe. Space pauses, the arrow keys seek five seconds, , and . seek thirty, Home returns to the start, q quits. Playback starts immediately — the song is synthesised through the block loop as it plays, so there is nothing to wait for and a long file costs no more memory than a short one. --prerender renders the whole song first instead, which makes seeking exact.
tabula-sonora-tui is a full-screen mixer over the running engine: one strip per part the file actually addresses, with the tone each program resolved to, live volume, expression and pan, a per-channel voice count, and mute and solo that take effect on a note already sounding. It opens four ports rather than the hardware's two, and raises the voice limit to match — a player is handed whatever it is given, and a file whose parts the engine cannot reach is a silence a listener cannot diagnose.
Both build on Windows as well, and both drive the same ts::audio core, so the ring protocol and the transport exist once. The player takes every render option above, plus:
| option | meaning |
|---|---|
| --prerender | render the whole song before playing instead of streaming it |
| --list-devices | enumerate outputs and exit |
| --device NAME\|N | pick an output by name fragment or index |
| --latency MS | how far ahead of the device to run |
| --buffer FRAMES | device period, in frames |
| --gain G | linear gain on the way out |
If it stutters, raise --latency.
ts::ToneGenerator is the engine itself: MIDI in, blocks out, nothing known in advance.
Send events between render calls and they land on the block boundary, which is the grid the engine itself applies them on. Polyphony is the hardware's own 64 voices; past that the allocator steals, taking whole notes rather than half of one and fading what it takes. ts::ToneGeneratorOptions::polyphony raises that limit, and ts::ToneGeneratorOptions::unlimited_polyphony makes the pool grow rather than steal — right for an offline render, wrong on an audio thread, since growing allocates.
The engine has 32 parts over two ports — send_channel has an overload taking a port index, and ts::ToneGenerator::part is indexed port * 16 + channel. A host that never names a port drives port A and behaves exactly as a sixteen-part engine. ts::ToneGeneratorOptions::ports takes 1, 2 or 4, and four is an extension past the module rather than something it does; ts::ToneGenerator::max_port_count says so in the reference.
A file says which port a track belongs to with a meta event — FF 21 (MIDI Port) carries the number, and FF 09 (Device Name) names an output that ts::MidiEvent::port numbers in order of first appearance — so a sequence that addresses more than sixteen channels routes itself. An untagged file is all port 0, exactly as before, and a file asking for a port the engine does not have folds onto one it does rather than falling silent.
To play a file rather than drive it by hand, ts::SequencePlayer dispatches a parsed event list as it renders, and seek replays the file's controllers up to a position so that jumping into the middle sounds the way playing up to there would.
The library is the deliverable and the front ends are demonstrations of it, so ts::tabulasonora is packaged for import. Two ways in, both giving the same target name:
An importing project needs nothing else. nlohmann_json is a build dependency and header-only, so it does not appear in the installed package, and neither do this project's warning flags. What does come through is ts::numeric_semantics, deliberately: -ffp-contract=off is a correctness requirement for the inline DSP in the public headers, not a preference, so a consumer compiles those headers under it too. C++20 comes through the same way.
ts::SequencePlayer::render_to_end streams the whole file into memory from wherever the player currently is; ts::SequencePlayer::render fills a pair of buffers instead, which is the same work in whatever sized pieces the caller wants. Nothing about the engine changes between them.
To mute or solo parts — for a mixer UI, say — hold a ts::ChannelMask and mutate it freely. Its flags are atomics, so it is safe to toggle from another thread while a render runs, and it is read at the mix rather than at note-on, so a part goes quiet on the next block and comes back on the one after.
Writing the result out is ts::wav::write.
The module speaks a second SysEx dialect, and it is a mode rather than a set of extra messages: XG System On moves every part onto the XG tone and drum maps, and any Roland message or GM reset moves them back. A file that carries F0 43 10 4C 00 00 7E 00 F7 switches the engine over on its own and nothing needs configuring.
Many XG files send no System On at all, because a real XG module is already in XG when it powers on. For those, say so:
ts::ToneMap::xg is the switch, rather than a separate flag, because on the module the two are one thing. It is a starting state: a file may still change mode, and ts::ToneGenerator::reset returns to it. Nothing infers XG from the shape of a file's bank selects — a bank LSB of 18 is a legitimate GS map selector, so guessing would break the files that mean it.
What changes under XG is worth knowing if you display anything:
So the engine answers per part rather than leaving a caller to infer:
ts::ToneGenerator::part_lookup_bank is the one most easily missed: under XG it is the bank LSB, except for bank MSB 64, where the module substitutes the SFX voice column. Reading part().bank instead names a real instrument — the wrong one, silently.
ts::DrumKitTable::kit_name reads the kit's own name out of its ROM record. Three things not to assume about the result are documented on it; the shortest is that the casing is the ROM's, and an ALL-CAPS kit name on XG-flavoured material means the drum row is not following XG.
For a front end that needs to know what an XG address means without acting on it, ts::decode_xg_sysex classifies a message and remaps its part number, and ts::decode_xg_multi_part turns a Multi Part parameter into the same ts::ControlUpdate vocabulary a Control Change decodes to. Part numbers reach 0x3F; range-check them against ts::ToneGenerator::parts and ignore what does not fit, rather than masking it into range.
ts::tone_map_choices is the name/value list the command line validates against, if you are building one of your own.
The chain borrows downward. A ts::NoteRenderer keeps a reference to the image it was built over, ts::ToneGenerator keeps one to the renderer, and ts::SequencePlayer keeps a pointer to the engine — no layer owns anything below it, which is what every must outlive it in the headers is saying. The snippets above are stack locals in a single scope, and that is all a main needs.
A host built the other way cannot do that. A plugin with startup() and shutdown(), or a session object that outlives any one call, holds the chain as members and fills them in later — and ts::RomImage has no default constructor and no copy. It exists only as the return of ts::RomImage::open or ts::RomImage::from_memory, so at the point the host itself is constructed there is nothing to construct a member from.
std::unique_ptr is what to reach for, and std::make_unique<const ts::RomImage> is well formed: it move-constructs the heap object out of whatever the factory returned.
The pointer rather than the value, because the pointee does not move when the host object does. Hold the chain by value in a struct that is later moved and the renderer's reference to the image, and the engine's reference to the renderer, both go on addressing the moved-from shells. Nothing announces it. Behind a std::unique_ptr the addresses the layers above captured never change, so the question does not arise.
std::optional<ts::RomImage> works too — rom.emplace(ts::RomImage::open(dll_path)) — and is what this repository's own WebAssembly session uses, in apps/web/src/web_session.hpp. It is the lighter choice when the owner is pinned in place and never moved. The pointer is the safer default.
The worked example is Cog's MIDI plugin: its TSPlayer holds a std::unique_ptr<const ts::RomImage>, fills it with std::make_unique in startup() and calls rom.reset() in shutdown() — which is also what lets setSCCore point the player at a different DLL and rebuild the engine over it, without the player object itself going anywhere.
ts::NoteRenderer exposes the voice directly, which is useful for analysis or for driving your own scheduler:
voice.mono is the pre-pan sum of the partials — the pan-independent source the sends are taken from.