An SC-55 MIDI sound module emulation, built as an iPlug2 plugin — AU, AUv3, VST3, CLAP and a standalone app from one codebase, with a working emulated front panel.
Ported from johnnovak/Nuked-SC55-CLAP, which builds on J.C. Moyer's fork of nukeykt's Nuked-SC55.
This is a real port, not a wrapper: the CLAP entry point, plugin factory and
clap_process glue are gone, replaced by iPlug2 equivalents. The emulator
backend under src/nuked-sc55/ is vendored unmodified.
This project is not affiliated with or otherwise endorsed by Roland Corp.
No ROM images are included. See ROM files.
iplug_add_plugin(... FORMATS ALL), so every format iPlug2 supports is
configured. What actually builds depends on what is available:
| Format | Status |
|---|---|
| AU (v2) | Builds; passes auval |
| AUv3 | Builds (framework + appex) |
| VST3 | Builds |
| CLAP | Builds |
| Standalone app | Builds |
| AAX | Configured; needs the proprietary AAX SDK + PACE signing |
| VST2 | Configured; needs the VST2 SDK, which Steinberg withdrew |
| WAM / WASM | Configured; needs an Emscripten toolchain — see Limitations |
Only macOS has been built and tested. The CMake setup is not platform-specific, but Windows and Linux are unverified.
Requires CMake 3.14+, Ninja, and a C++23 compiler.
./scripts/setup-deps.sh # clones iPlug2, fetches VST3/CLAP SDKs, patches
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build --target NukedSC55-au NukedSC55-vst3 NukedSC55-clap NukedSC55-appBundles land in build/out/ and are also deployed to the user plugin folders.
cmake -G Xcode gives an Xcode project instead.
setup-deps.sh applies one patch to iPlug2: upstream passes the
Objective-C prefix header as an unquoted -include flag, so any checkout whose
path contains a space fails to build every target.
Push a v* tag and the release workflow builds universal binaries, checks both
architecture slices are present, packages each format and publishes a GitHub
Release:
git tag v1.0.0 && git push origin v1.0.0Released binaries are not signed or notarised, so macOS quarantines them. After installing, clear the flag:
xattr -rd com.apple.quarantine ~/Library/Audio/Plug-Ins/Components/NukedSC55.componentCI builds every format on macOS — native arm64 and universal — on each push and
pull request, runs auval against the AU, and compiles the smoke test. It also
fails the build if a ROM image or an unexpected trademark reference is ever
committed.
The smoke test is compiled but not run in CI: it needs ROM dumps, which are not in this repository. CI only asserts it exits cleanly on a missing ROM directory.
Windows and Linux are not in CI because they have not been built or tested at all; adding them is a good first contribution.
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DSC55_BUILD_TESTS=ON
cmake --build build --target sc55_smoke_test
./build/sc55_smoke_test /path/to/ROMsBoots a romset and checks rendering, sample-accurate event placement, sysex,
ragged block sizes, LCD rasterisation, panel buttons, chords, the DC blocker and
model switching. Pass a model index 0–5 as a second argument. It links no iPlug2
code — SC55Engine has no dependency on the plugin framework, so the DSP path is
testable without a host.
None are included, and none will be. The emulation needs dumps of the original hardware's ROM chips, which are copyrighted and not ours to distribute. You must supply your own.
Each model needs its own subfolder, named exactly as below, inside a ROMs
directory:
ROMs/
SC-55-v1.00/ sc55_rom1.bin sc55_rom2.bin sc55_waverom1.bin sc55_waverom2.bin sc55_waverom3.bin
SC-55-v1.10/ (same five files)
SC-55-v1.20/ (same five files)
SC-55-v1.21/ (same five files)
SC-55-v2.00/ (same five files)
SC-55mk2-v1.01/ rom1.bin rom2.bin rom_sm.bin waverom1.bin waverom2.bin
SHA-256 hashes are verified on load; see src/nuked-sc55/backend/rom_io.cpp for
the accepted set.
Searched in this order, first match wins:
- The folder chosen with Choose ROM folder… in the UI (remembered across
instances in
~/Library/Application Support/NukedSC55/romdir.txt, and saved into the session state) - The
SC55_ROM_PATHenvironment variable — absolute directories, separated by the OS path separator (;on Windows,:elsewhere). The variable the CLAP build used is also still honoured, so an existing setup keeps working. ROMs/inside the plugin bundle'sResourcesNuked-SC55-Resources/ROMs/beside the plugin — the CLAP build's layout~/Library/Application Support/NukedSC55/ROMs
One plugin, six models. The CLAP build exposes six separate plugin
descriptors, one per ROM revision, from a single binary. iPlug2 is
one-plugin-per-config.h, so this is a single plugin with a Model parameter
selecting v1.00 / v1.10 / v1.20 / v1.21 / v2.00 / mk2 v1.01. The model is saved
in the plugin state, and switching triggers a background reload.
ROM loading and boot are asynchronous. Bringing up a model means reading the
ROMs, resetting the MCU and stepping it 700k times (mk1) or 9.5M times (mk2) to
get past the boot delay of the real hardware. The CLAP build does this inline in
activate(), which stalls the host. Here it runs on a loader thread: the audio
thread emits silence until the new instance is published, and when only the
model changes, the previous model keeps playing throughout the boot so the only
gap is the swap itself. Measured boot: ~105 ms for mk1, ~790 ms for mk2.
There is a GUI, and the front panel works. The CLAP build has none. The
emulator has always exposed an LCD_Backend and a mutex-guarded ARGB framebuffer
that the CLAP build left unused; this port renders it into a faceplate, so you
get the real emulated display — part, patch name, level, pan, reverb, chorus, key
shift, MIDI channel and the 16-part meter.
The panel's buttons are live. ALL, MUTE, PART, INSTRUMENT, LEVEL, PAN, REVERB, CHORUS, KEY SHIFT and MIDI CH are wired to the MCU's own button bitfield, so the module responds exactly as the hardware does, including press-and-hold repeat and two-button chords. The VOLUME knob drives output gain. Below the panel sits everything the hardware has no control for: model selector, ROM folder picker with load status, GS/GM reset, DC blocker, level meter and a test keyboard.
POWER is deliberately not wired. It is a real input on the hardware model, but "powering off" inside a plugin only produces a module that looks broken with no obvious way back.
No vcpkg. The SpeexDSP resampler is vendored (see src/speexdsp/README.md),
so the build has no external package dependency.
| File | Role |
|---|---|
NukedSC55.{h,cpp} |
iPlug2 plugin: params, state chunks, MIDI/sysex, UI layout |
src/SC55Engine.{h,cpp} |
Emulator + resampler ownership, loader thread, RT rendering |
src/SC55Lcd.{h,cpp} |
LCD_Backend capturing panel frames as RGBA8 |
src/SC55LCDControl.h |
IControl uploading those frames as a NanoVG texture |
src/SC55PanelControls.h |
Faceplate layout, button hotspots, VOLUME knob |
src/SC55PlatformKeys.{h,cpp} |
Live shift-key query, for mid-press chords |
src/SC55DCBlocker.h |
Opt-in high-pass for the emulator's DC offset |
src/SC55RomPaths.{h,cpp} |
ROM discovery and the remembered directory |
src/SC55Model.h |
The six models and their romset metadata |
src/nuked-sc55/ |
Vendored emulator, unmodified |
src/speexdsp/ |
Vendored resampler, unmodified |
resources/img/ |
Faceplate artwork — read its README before re-rendering |
Three threads touch SC55Engine, and the split is the load-bearing part of the
design:
- Audio —
QueueMidi()andProcessBlock()only. No allocation, no locks, no blocking. Render buffers are fixed-capacity and sized at load time. - Loader — builds and boots instances, then swaps them in.
- Main/UI — setters, status, and
RenderLcdFrame().
The audio thread and the loader hand off through two atomics (mReady /
mAudioActive) in a Dekker handshake rather than a mutex, which is what keeps
the audio side lock-free; sequential consistency on both sides is required for it
to be correct. The UI thread uses mInstanceMutex, which the audio thread never
takes and which is never held across the boot loop.
Everything that reconfigures the engine is funnelled through OnIdle() on the
main thread, because OnParamChange() can arrive on the audio thread during
automation or state restore and the engine's setters take locks.
MIDI is queued with sample offsets and dispatched inside ProcessBlock(),
interleaved with rendering, preserving the sample accuracy of the CLAP build.
SysEx bytes are copied on the way in — ISysEx does not own its data.
Panel presses are not interpreted in the UI; they are written into the emulator's
own mcu_t::button_pressed bitfield, so the firmware reacts to them exactly as it
would to the physical switches.
Two details make that reliable:
- The UI never touches the emulator. It sets an atomic mask on the engine, and the audio thread copies it into the MCU — so a press can never race the loader thread swapping instances.
- A press is latched for 60 ms of audio. The MCU only sees the button port when its firmware polls it, so a click shorter than a buffer would otherwise be missed entirely; a separate sticky mask catches presses that begin and end between two blocks. The smoke test covers both cases.
The hardware gives extra functions to holding a pair of adjacent buttons at once. Every panel button has exactly one partner — the two INSTRUMENT buttons, the two halves of each ◄/► pair, and ALL with MUTE. A mouse has one pointer, so there are two ways to ask for a pair:
| Gesture | Result |
|---|---|
| Right-click while holding left | Partner held for as long as the right button is |
| Shift, at any point during a press | Partner follows the shift key, pressing and releasing with it |
| Right-click on its own | Presses both at once, as a shortcut |
Shift behaves like the second mouse button and can be pressed after the mouse
is already down. That needs a poll rather than an event: iPlug2 only reports
modifier state as part of a mouse or key event, and pressing shift on its own
produces neither. SC55PlatformKeys queries the key directly
(CGEventSourceFlagsState on macOS, GetAsyncKeyState on Windows — neither needs
any permission) and the plugin's idle timer pushes changes to the panel buttons.
Linux and Web have no cheap equivalent and return false there, where shift still
works when it arrives as part of a mouse event.
The two sources are deliberately not symmetrical. The mouse half latches on until
release, because while both buttons are held macOS interleaves mouseDragged
(L set, R clear) with rightMouseDragged (L clear, R set) and reconciling both
ways makes the partner flicker. The polled shift state has no such ambiguity, so
it tracks the key in both directions.
Buttons draw their pressed state from the engine's bitfield rather than from their own click state, so the partner lights up too; a control cannot draw outside its own bounds, so it has to light itself.
One caveat, forced by the framework. IGraphics::OnMouseUp() erases the mouse
capture whichever physical button was released, and there is no public API to
re-establish it. Releasing the right button therefore ends the whole gesture
rather than dropping back to just the left button. Releasing something still
physically held is the safe failure here — the alternative is a button latched
down with no event left to clear it.
Panel buttons set mDblAsSingleClick. Without it every second click of a fast
sequence is silently lost, which matters a lot here — stepping through instruments
or parts means clicking the same spot over and over.
The platform layer routes clicks by (clickCount - 1) % 2, so alternate ones
arrive as OnMouseDblClick rather than OnMouseDown. IGraphics then calls the
control's double-click handler — whose default is SetValueToDefault(), a no-op
for a control with no parameter — and releases the mouse capture. Net effect: no
press at all. Setting the flag makes IGraphics convert the event into an
ordinary OnMouseDown instead, keeping the capture so the matching OnMouseUp
still arrives.
src/SC55DCBlocker.h is a one-pole high-pass (y = x - x₁ + R·y₁) at a 5 Hz
corner, applied per channel ahead of the output gain stage. It is off by
default: the offset is part of the reference signal path, so removing it is
opt-in rather than something the port does behind your back. The real hardware is
AC-coupled through output capacitors, so switching it on is arguably closer to the
physical unit than leaving it off.
Two details that matter:
- State is
double. At a 5 Hz cornerR ≈ 0.99935, and a float accumulator loses too much resolution in the recursive term to settle cleanly. - The filter primes from its first input sample rather than from zero, so engaging it mid-stream does not produce a step the size of the offset. The smoke test asserts exactly this.
It lives outside the plugin class and has no iPlug2 dependency, so the smoke test exercises it on the engine's real idle output: mk1's 0.0313 mean falls to ~5e-6, mk2 (already 0) is a no-op, and a 440 Hz tone keeps its amplitude to within 0.03%.
- The emulator has a DC offset. SC-55 mk1 revisions idle at a constant +1/32 (about −30 dBFS); the mk2 idles at 0. This comes from the hardware model itself and is present identically in the CLAP build and upstream Nuked-SC55 — it is not introduced by this port. The DC Blocker switch removes it, and is off by default so that the default signal path stays faithful to the reference build.
- No emulator state save/restore. The state chunk stores the model, ROM directory and parameters, not the emulator's internal state, so a reloaded session restarts the module from a GS reset. Upstream does not implement this either.
- SysEx larger than the emulated UART. The MCU has an 8 KB UART buffer that drains at MIDI's 31250 baud, so a very large bulk dump delivered in one block is truncated by the hardware model. The plugin's own queue holds 64 KB and reports anything it drops in the status line. Same behaviour as upstream.
- AUv3 sandboxing. An AUv3 app extension cannot read arbitrary paths, so ROMs
need to be inside the bundle's
Resourcesfor the AUv3 target. The other formats are unaffected. - WAM/WASM are configured but impractical. The emulator needs multi-megabyte
ROM images and
std::filesystem, neither of which maps onto the browser targets without a separate ROM-delivery mechanism. The targets are declared becauseFORMATS ALLwas asked for; they are not expected to work as-is. - Not built on Windows or Linux.
Distributed under the original MAME licence — see LICENSE. In short: no commercial use of any kind, redistributions may not be sold, and modified redistributions must include complete source. Intended for personal and research use.
The vendored SpeexDSP resampler is BSD-3-Clause; see
src/speexdsp/COPYING.speexdsp.
- nukeykt — the Nuked-SC55 emulator core
- J.C. Moyer — the fork this backend comes from
- John Novak — Nuked-SC55-CLAP, the plugin this was ported from
- Oli Larkin and contributors — iPlug2
- Xiph.Org / Jean-Marc Valin — SpeexDSP