Solid State Logic SSL 12 on Linux, without SSL 360.
SSL ships no Linux software for the SSL 12. The audio side works out of the box
(UAC2, snd-usb-audio), but everything the SSL 360 application controls — the
internal mixer, monitoring, phantom power, loopback, routing — is unreachable.
The card behaves like a fixed-function box.
This project reverse-engineers the control protocol and gives that back:
sslctl— command-line control of the card: mixer matrix, monitoring, preamps, loopback, headphone buses.- Douze — a local web GUI on
http://localhost:1212: mixer matrix, live meters, monitoring section, persistent profiles. - Douze FX — a standalone VST3 host that inserts plugin chains into the PipeWire graph (one process per strip), driven from the same GUI.
It follows the path traced by Geoffrey Bennett's Scarlett driver work.
Note for contributors: source comments are in French. They carry most of the hard-won knowledge here — why a workaround exists, which failure it prevents — and were kept in the language they were written in rather than degraded by translation. Everything a user or bug reporter sees (this file, program output, the GUI) is English or bilingual. Ask if a comment blocks you.
The protocol is mapped and in production use. Full details in PROTOCOL.md.
| Area | State |
|---|---|
| Framing, handshake, checksums | done |
| Gain matrix (full map, dB formula) | done |
| Monitoring: dim / cut / mono / alt / talk, levels, ALT speaker enable | done |
| Preamps: 48V, HPF, inst, polarity | done |
| Loopback, clock readback, hardware button notifications | done |
| Front-panel CUT / ALT / TALK: assignable functions | done |
| Logical mixer: faders, pans, mutes, solos, profiles | done (emulated) |
| A few SETTINGS/clock controls | identified, not decoded |
The device itself knows nothing about faders, pans, mutes, solos or profiles.
SSL 360 emulates them and writes the resulting gain matrix; sslctl does the
same and keeps its state in ~/.config/sslctl/state.json.
Verified against firmware bcdDevice 1.44, SSL 360 V2.
| Needed for | Notes | |
|---|---|---|
| Linux with PipeWire | everything | tested on PipeWire 1.6 |
| Python 3.11+ and pyusb | sslctl, Douze GUI |
the only runtime dependency |
| The card in Pro Audio profile | multichannel routing | set it in pavucontrol / your sound settings |
| CMake, Ninja, a C++20 compiler | Douze FX only | JUCE 9 is fetched by CMake |
PipeWire's libjack.so.0 |
Douze FX only | from pipewire-jack; see troubleshooting |
Nix is not required, but if you have it, everything below is one command:
nix develop # Python + pyusb, cmake/ninja/gcc and the JUCE deps for
# Douze FX, plus tshark/wireshark/lsusb for captures
nix run .#douze-app # the desktop app, without installing anythingThe project was developed on NixOS and ships a flake, but nothing depends on it
— sslctl and the GUI need only Python and pyusb:
# Debian/Ubuntu
sudo apt install python3-usb pipewire-audio # + cmake ninja-build g++ for Douze FX
# Fedora
sudo dnf install python3-pyusb pipewire-jack-audio-connection-kit
# Arch
sudo pacman -S python-pyusb pipewire-jack
# anywhere
pip install --user pyusbOnly two things are known to be Linux/PipeWire-specific by design: the USB control protocol (it is the same on any OS, but this implementation uses libusb/pyusb) and the routing, which is built on PipeWire. There is no Windows/macOS target — SSL 360 already covers those.
The control interface is a vendor-specific USB device with no kernel driver, so it needs a udev rule.
# NixOS
services.udev.extraRules = ''
SUBSYSTEM=="usb", ATTRS{idVendor}=="31e9", ATTRS{idProduct}=="0024", MODE="0660", GROUP="audio"
'';Other distributions: copy udev/99-douze.rules to /etc/udev/rules.d/, then
sudo udevadm control --reload && sudo udevadm triggerMake sure you are in the audio group (id -nG), then replug the card.
Check it worked:
python tools/sslctl.py infoIt should print the bus, address and firmware version of the control interface. If it says permission denied, see troubleshooting.
Run it before starting the daemon, or stop the daemon first: it owns the device, and the CLI can't share it.
Set the card's profile to Pro Audio first (pavucontrol → Configuration).
No other profile exposes the 8 playback channels, and without it there is
nothing to split.
tools/install-pipewire.sh
systemctl --user restart pipewireThis turns the single 8-channel sink into four stereo sinks, so each application can target one playback pair.
The shipped config is a template: it addresses the card by its ALSA node name, which contains the card's serial number and therefore differs on every machine. The script finds yours and fills it in. To do it by hand:
pw-link -o | grep -oE 'alsa_output\.usb-Solid_State_Logic_SSL_12_[^:]*\.pro-output-0' | head -1
sed "s|@SSL12_NODE@|<that name>|g" pipewire/99-ssl12-sinks.conf \
> ~/.config/pipewire/pipewire.conf.d/99-ssl12-sinks.confCheck it worked:
pw-play --target ssl12.pb34 /usr/share/sounds/alsa/Front_Center.wavRestarting PipeWire drops the connections of apps that were using it (Discord clients and Easy Effects are the usual casualties). Do this before a session, not during one.
mkdir -p ~/.config/systemd/user
sed "s|%h/douze|$PWD|" systemd/douze.service > ~/.config/systemd/user/douze.service
systemctl --user daemon-reload && systemctl --user enable --now douzeOn Nix, run the daemon through the devShell so it gets pyusb, by replacing the
ExecStart line with (adjust the path):
ExecStart=/run/current-system/sw/bin/nix develop %h/douze --command python tools/douze.pyOpen http://localhost:1212. If the card is absent at login the unit retries every 10 s.
The unit is WantedBy=graphical-session.target, not default.target, and that
matters: a daemon started at boot runs before the compositor publishes
DISPLAY / WAYLAND_DISPLAY into the systemd --user environment. Audio does
not care, so everything sounds right — but no plug-in editor can ever open (see
below). Waiting for the session costs nothing; the card is still configured a
second after you log in.
The daemon owns the USB device. The sslctl CLI cannot talk to the card
while the daemon runs — stop the service first, or drive everything from the
GUI.
Check it worked:
systemctl --user status douze
journalctl --user -u douze -fThere is no installed binary — it's a script in the checkout. Either call it directly, or make yourself an alias:
python tools/sslctl.py status
alias sslctl='python /path/to/douze/tools/sslctl.py' # then the lines below work as writtensslctl status # what the device itself reports
sslctl show # logical mixer state
sslctl master monitor -12 # monitor bus level, in dB
sslctl channel 1 --db -6 --pan L30 # a channel's level and pan
sslctl fader 1 -6 # logical fader, compiled into the matrix
sslctl route 1 hpa -3 # send channel 1 to headphones A
sslctl 48v 1 on # phantom power, per channel
sslctl hpf 1 on / inst 3 on / phase 2 on
sslctl dim on / cut on / mono on / alt on / talk on
sslctl altspk on # ALT speakers — required for `alt` to do anything
sslctl loopback pb34 # loopback source
sslctl bus hpa follow # bus mode: follow mix 1-2 / cut / mono
sslctl user talk mono-sum # what a front-panel button doessslctl --help lists everything. For key bindings, use relative moves:
sslctl master monitor --rel -3.
sslctl sync pushes the whole logical state back to the card — useful after a
power cycle, since the device starts blank.
The three buttons on the front panel are assignable, exactly like the USER page of SSL 360. Pick the function in the GUI, at the bottom of the Monitoring panel — or from the CLI:
sslctl user cut mono-sum # the CUT button now sums the monitor bus to mono
sslctl user alt dim
sslctl user talk talkback # back to its factory functionThe button is cut, alt or talk (where it sits on the panel — 1, 2,
3 still work), and the function is one of dim, cut, mono-sum, alt,
invert-l, talkback. A seventh function exists in SSL 360, "360° GUI", which
opens the SSL 360 window; it is gui here, but it has nothing to open on Linux
and its value is the one part of the menu we never saw on the wire — so the GUI
does not offer it.
The assignment is stored with the rest of your mixer state, and pushed back to
the card by sslctl sync and on daemon startup: unplugging the SSL 12 does not
lose it.
A button set to alt will seem dead until you enable the ALT speakers. ALT
switches monitoring over to outputs 3-4, so with those outputs not enabled the
firmware has nothing to switch to: pressing the button does nothing, and it does
not even light up — the card sends no notification at all, so Douze has nothing
to light. Tick ALT speakers (outputs 3-4) in the Monitoring panel, or run
sslctl altspk on. SSL 360 has the same prerequisite. This is stored and
re-pushed like the rest of the state.
The GUI is a web page, so a browser tab is enough. If you'd rather have a real
window and a tray icon, tools/douze-app.py is a thin GTK/WebKit client around
it.
It is only a client. The daemon keeps the card and the FX strips — closing the window never cuts your microphone. The tray icon changes when the daemon stops answering, closing the window minimises to the tray, and "Quit" leaves the daemon running.
Run it directly:
python3 tools/douze-app.pyIt needs PyGObject plus GTK 3, WebKit2GTK 4.1 and (optionally) libayatana-appindicator for the tray icon — without the last one it still runs, window only.
# Debian/Ubuntu
sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1 gir1.2-ayatanaappindicator3-0.1
# Fedora
sudo dnf install python3-gobject gtk3 webkit2gtk4.1 libayatana-appindicator-gtk3
# Arch
sudo pacman -S python-gobject gtk3 webkit2gtk-4.1 libayatana-appindicatorWith Nix, there's a packaged build and a desktop launcher:
tools/install-douze-app.shThat builds .#douze-app, installs the icons into your icon theme and writes a
douze.desktop entry. It does not copy the code: the launcher runs
tools/douze-app.py from the checkout, so editing it takes effect without
rebuilding — only a dependency change needs one.
If you launch it from a Nix devShell, clear the environment first:
env -u LD_LIBRARY_PATH -u PYTHONPATH -u GI_TYPELIB_PATH python3 tools/douze-app.py. Inherited library paths from a different nixpkgs make it die on importing Pango. The generated.desktopentry already does this.
One process per strip: a source, a chain of VST3 plugins, a destination. A strip can create its own virtual microphone or virtual sink, so other applications pick it like any device — a noise suppressor on a mic, an EQ on a voice-chat return.
cmake -S fx -B build-fx -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build-fx
python tools/douzefx.py list|start <id>|stop <id>|state <id>Strips are configured from the Douze GUI (create, wire, reorder plugins, save
sets of strips as profiles). Config lives in ~/.config/douze-fx/.
Plugins are scanned one throwaway process per file, so a plugin that hangs or crashes cannot take the scan — or your audio — with it. Windows VST3 through yabridge works.
Design decisions, and the failures behind them: docs/DOUZE-FX-BRIEF.md.
lsusb | grep 31e9— you should see31e9:0024 SSL Control I/F. If only31e9:0005appears, the card is in a mode that hides the control interface; replug it.id -nG | grep audio— you must be in the group named in the udev rule.- Rule not applied? Rules only run on device events: reload and replug.
- The uaccess trap (NixOS). Do not put
TAG+="uaccess"inservices.udev.extraRules. That generates99-local.rules, but uaccess is applied by73-seat-late.rules— a uaccess rule numbered 99 is silently ignored, and you get a rule that looks right and does nothing. UseMODE/GROUPas shown above. - Is the Douze daemon running? It holds the device.
systemctl --user stop douze.
Almost always the card profile. Only Pro Audio exposes the 8 playback channels; in the default duplex profile the ports the config targets do not exist, so PipeWire loads four loopbacks that connect to nothing.
pw-link -o | grep -i solid # should list …pro-output-0:playback_AUX0..7If the node name changed (different card, or you re-ran with another unit),
re-run tools/install-pipewire.sh — the name is baked into the config.
The ssl12.pbXX sinks are loopbacks that must stay linked to the card. If the
ALSA node is recreated (boot, hot-plug, USB reset) they do not reconnect on
their own — the symptom is a card that reports "suspended" and total silence.
The daemon repairs this at startup; if you are not running it:
card=$(pw-link -o | grep -oE 'alsa_output\.usb-Solid_State_Logic_SSL_12_[^:]*\.pro-output-0' | head -1)
for i in 0 1 2 3 4 5 6 7; do
pair=$(( i / 2 )); names=(pb12 pb34 pb56 pb78)
pw-link "ssl12.${names[$pair]}.out:output_AUX$i" "$card:playback_AUX$i"
doneA strip's virtual node is destroyed and recreated every time the strip starts.
Applications keep their target.object but lose the actual link, and the
session manager quietly relocates them to the default device. Nothing reports an
error: the app still plays, the strip still runs, only the meters stay at zero.
The daemon re-adopts those streams automatically at strip start (look for
N application(s) rebranchée(s) in the journal). If you wired something by
hand, note that pw-link -d by name disconnects every stream sharing that
port name — use port IDs (pw-link -lI) instead.
Its control thread is stuck, almost always inside a plugin's native editor that never returned (Waves plugins under Wine are the usual case). Your audio is still being processed — that is why it is amber and not red. The strip's editor is lost until you restart it, at a moment of your choosing.
Douze remembers editors that hung and greys them out afterwards, so you do not walk into the same one twice.
The launcher (fx/tools/run-douze-fx.sh) has to find PipeWire's
libjack.so.0. If it cannot, and falls back to querying Nix, a slow or blocked
Nix evaluation leaves the launcher hanging before it prints anything — the
supervisor then sees a live process that never answers.
Point it straight at the library:
DOUZE_FX_JACK_LIB=/path/to/pipewire-jack/lib python tools/douzefx.py start micThe resolved path is cached in ~/.cache/douze-fx/jacklib and reused.
Almost always an inherited LD_LIBRARY_PATH — libraries from a different
toolchain than the system's, which kills yabridge's Wine host. The launcher
deliberately replaces the variable rather than extending it. If you wrapped
it in your own script or service, do not re-export a broader path.
Symptoms: strip dies with code -6, terminate called without an active exception in the strip log, or "The Wine host process has exited
unexpectedly".
Different problem, on NixOS. A .vst3 installed by yabridgectl sync holds no
yabridge code: only a chainloader, which looks the real
libyabridge-vst3.so up at run time in the directories listed by
$NIX_PROFILES. When that variable is missing, every bridged plugin fails with
the message JUCE also uses for a corrupt file — while native plugins keep
loading, so nothing points at the environment.
A user service does not necessarily have the variable: systemd --user only
receives it when the desktop session imports it, which can happen after
douze.service has started at boot. Douze now rebuilds NIX_PROFILES itself
when it does not lead to yabridge — for the strips and for the plugin scanner
alike — and says so in the journal:
[fx] NIX_PROFILES ne menait pas à yabridge : ajout de /run/current-system/sw
Plugins scanned while the variable was missing were recorded as broken: rescan them once it is fixed.
Douze FX remembers editors that hang, because some really do: a Waves editor
under Wine can take the strip's control thread and never give it back. The
memory lives in ~/.cache/douze-fx/editor_hang.txt, and it used to be final.
It could also be wrong. With no DISPLAY (a daemon started at boot, before
the graphical session), Wine falls back to its null driver — nodrv_CreateWindow: the explorer process failed to start in the strip log — and every editor
hangs, healthy ones included. They were then blacklisted for good.
Three things now prevent that. The unit waits for the graphical session; the
launcher recovers the display from systemctl --user show-environment when it
can; and the engine refuses to even try without a display, saying so instead of
freezing:
aucun affichage : cette bande a démarré avant ta session graphique
And the verdict is no longer final: the ▣ button of a blacklisted plug-in shows
▣! and asks whether to try again. If it hangs for real, the watchdog restarts
the strip and it goes back on the list — where it belongs.
Look for an amber pill on the strip: " is also capturing the raw input".
A capture stream that names no device follows the system default source. If that default is the sound card itself, the app records the raw microphone — around the strip, through no plug-in at all. Nothing reports it: the strip is healthy, the app is healthy, and the two capture paths coexist. Discord is a frequent case, because it opens more than one capture stream and only one of them carries an explicit target.
Two fixes, and you want both:
wpctl status # find the strip's virtual mic
wpctl set-default <its-id> # make it the default sourceand pick that same microphone explicitly in the application. The default covers every app that does not choose; the explicit choice survives the strip being stopped and restarted.
Douze watches for this on its own — every 12 s it compares who else is wired to
the hardware input a strip is processing, and names them in /fx
(raw_capture) and in the GUI. It does not rewire them: a stream you pointed
somewhere on purpose is yours to keep.
Look for an amber pill on the strip: "not realtime".
Audio only meets its deadline if the threads carrying it are scheduled ahead of everything else. When they are not, everything still looks healthy — the strip runs, the plug-ins process, the meters move — and the sound falls apart the moment anything else wants the CPU. Sharing a 4K screen is the classic trigger, and it makes the problem look like a bug in whatever app you were streaming with.
Check the whole graph, not just Douze:
ps -eLo cls,rtprio,comm | grep -E ' FF | RR 'You want PipeWire's data-loop.0, plus one data-loop.N per running engine.
FF is SCHED_FIFO and RR is SCHED_RR; either is fine. If the only hits are
kernel threads (irq/…, migration/…), nothing in your audio path is realtime.
The cause is outside Douze. Things that have really done it:
- A second
libpipewire-module-rt. PipeWire drop-ins append tocontext.modules, so a "low latency" preset that re-declares the module gets it loaded twice — and the second instance can fail to acquire privileges while still governing thread promotion.pw-cli list-objects Module | grep -c module-rtmust print1. - A desktop process scheduler (system76-scheduler and friends). Some demote
every userspace
SCHED_FIFOthread within seconds and drop user services tonice 9; their exception lists do not always hold. - A realtime watchdog such as musnix's
das_watchdog, which suspends realtime whenever it believes the machine is stuck — that is, under load. - No nice budget for user services. PAM's
loginLimitsdo not reachsystemd --user. WithLimitNICE=0onuser@.service, PipeWire logsmod.rt: could not set nice-level to -11: Permission deniedat every start.
To get sound back right now — no root, nothing to restart:
for t in $(ps -eLo tid,comm --no-headers | awk '$2 ~ /^data-loop/ {print $1}'); do
chrt -R -f -p 88 "$t"
done-R is not optional. A thread that already carries SCHED_RESET_ON_FORK — one
that was promoted and then demoted — refuses a plain chrt with a thoroughly
misleading Operation not permitted. Threads belonging to other users are
refused too; that is harmless, skip them.
Douze reports this by itself, in the same 12 s sweep as the raw-capture check,
as no_rt in /fx. It does not promote anything: repairing this silently would
hide the regression instead of showing it. Only data-loop* threads are
counted — the pw-<node> ones are pipewire-jack's control loops and are
legitimately not realtime.
The engine resets its peaks on every read — reading is consuming. The daemon
serves one shared reading to all clients, so the GUI is fine. But if you poll
/fx or a strip's /state yourself in a loop, you are stealing peaks from the
GUI. Read the daemon's event stream (/events) instead.
One binary can hold hundreds of sub-plugins, and instantiating them one by one overflows a Wine thread's stack. Douze detects this and retries with a factory-only enumeration. If they are still missing, scan that file directly:
build-fx/douze_fx_artefacts/RelWithDebInfo/douze_fx --scanshell "/path/WaveShell….vst3" /tmp/out.txtDeliberate. The host does not load LADSPA, and LADSPA has no state saving, so a saved chain could not restore its settings. Only formats the engine can actually host are offered.
Douze FX loads libjack via dlopen, which is invisible to ldd and therefore
to Nix's dependency scan. Run tools/gcroots.sh after each rebuild.
python tools/test_douzefx.py # supervisor, scanner, profiles — no hardware needed
fx/tools/run_tests.sh # engine checks — build Douze FX first (see above)
python fx/tools/nulltest.py # audio path is bit-transparent (daemon must run)The null test is the one that matters for an audio tool: it injects a known signal through a passthrough strip, re-records it, realigns and subtracts. The residual must be exactly zero — and it also measures the residual one sample off, to prove the measurement is capable of failing.
PROTOCOL.md is the source of truth. To extend it, capture SSL 360
talking to the card: run it in a Windows VM with USB passthrough of both
devices, and capture from the host with usbmon — the host sees everything the
VM exchanges.
sudo modprobe usbmon
lsusb | grep 31e9 # note bus and address; it changes on every replug
tshark -r NN-x.pcapng -Y 'usb.device_address == ADDR' -w NN-x.ctl.pcapngThen decode:
python tools/usbdump.py captures/NN-x.ctl.pcapng --addr 13 # raw frames
python tools/ssldecode.py captures/NN-x.ctl.pcapng --addr 13 --no-noise # SSL messages
python tools/ssldecode.py captures/NN-x.ctl.pcapng --addr 13 --summaryWhat made this tractable: one capture per action, log every session in
captures/JOURNAL.md (including the USB address), and leave ~5 s of silence
before and after so periodic traffic stands out. Raw captures are huge (~250 MB
per 30 s, audio included) — keep only the filtered .ctl.pcapng.
Never send invented frames to this firmware. Replay only sequences observed in captures until the encoding is understood.
This project is not affiliated with, endorsed by, sponsored by or connected to Solid State Logic in any way.
"Solid State Logic", "SSL", "SSL 12" and "SSL 360" are trademarks of their respective owners. They are used here only to identify the hardware this software works with — there is no other way to say which device a driver drives. No claim is made to those marks.
This project contains no code, firmware, driver or asset belonging to Solid State Logic. Nothing was decompiled. The protocol was determined by observing USB traffic between the manufacturer's own application and hardware the author owns, and by writing down what was observed — the same method used for every other unsupported audio interface on Linux.
It talks to firmware for which no documentation exists. Use at your own risk. The card is not known to have been damaged by any of this, and the tools only replay message shapes seen in captures, but there is no warranty of any kind (see sections 15 to 17 of the licence). If you experiment, read CONTRIBUTING.md first: never send invented frames to the device.
If you are with Solid State Logic and something here concerns you, please open an issue — this exists because your customers want to use your hardware on Linux, and would rather do it with your blessing than without it.
Two licences, on purpose — see COPYING.md.
- Code (
tools/,fx/,pipewire/,systemd/,udev/): AGPL-3.0-or-later. Douze FX embeds JUCE 9 under its AGPLv3 option, which is what fixes the choice. - Protocol documentation and captures (
PROTOCOL.md,captures/,docs/): CC0-1.0, public domain.
The second one matters more than the first. A documented protocol is a fact about a piece of hardware, not a literary work, and the best outcome for it is to end up in a kernel driver or someone else's tool. So it carries no attribution requirement at all — take it, no need to ask.