Skip to content
 
 

Repository files navigation

SC-55 iPlug2

CI

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.

Formats

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.

Building

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

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

Releases

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

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

Continuous integration

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

Tests

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/ROMs

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

ROM files

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:

  1. 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)
  2. The SC55_ROM_PATH environment 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.
  3. ROMs/ inside the plugin bundle's Resources
  4. Nuked-SC55-Resources/ROMs/ beside the plugin — the CLAP build's layout
  5. ~/Library/Application Support/NukedSC55/ROMs

What the plugin does

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.

Architecture

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

Threading

Three threads touch SC55Engine, and the split is the load-bearing part of the design:

  • AudioQueueMidi() and ProcessBlock() 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.

Front panel buttons

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.

Two-button chords

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.

Rapid clicking

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.

DC blocker

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 corner R ≈ 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%.

Known limitations

  • 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 Resources for 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 because FORMATS ALL was asked for; they are not expected to work as-is.
  • Not built on Windows or Linux.

Licence

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.

Credits

  • nukeykt — the Nuked-SC55 emulator core
  • J.C. Moyer — the fork this backend comes from
  • John NovakNuked-SC55-CLAP, the plugin this was ported from
  • Oli Larkin and contributorsiPlug2
  • Xiph.Org / Jean-Marc ValinSpeexDSP

About

SC-55 MIDI sound module emulation as an iPlug2 plugin: AU, AUv3, VST3, CLAP and standalone, with a working emulated front panel. Ported from Nuked-SC55-CLAP. Not affiliated with or endorsed by Roland Corp.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages