Skip to content

Repository files navigation

douze

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.


Status

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.


Requirements

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 anything

The 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 pyusb

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


Install

1. Device access, without root

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 trigger

Make sure you are in the audio group (id -nG), then replug the card.

Check it worked:

python tools/sslctl.py info

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

2. Split the playback pairs (PipeWire)

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 pipewire

This 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.conf

Check it worked:

pw-play --target ssl12.pb34 /usr/share/sounds/alsa/Front_Center.wav

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

3. The GUI, as a user service

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 douze

On 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.py

Open 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 -f

Using sslctl

There 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 written
sslctl 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 does

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

Reassigning the CUT / ALT / TALK buttons

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 function

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


Desktop app (optional)

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

It 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-appindicator

With Nix, there's a packaged build and a desktop launcher:

tools/install-douze-app.sh

That 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 .desktop entry already does this.

Douze FX (plugin host)

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.


Troubleshooting

sslctl says the device is not found, or permission denied

  1. lsusb | grep 31e9 — you should see 31e9:0024 SSL Control I/F. If only 31e9:0005 appears, the card is in a mode that hides the control interface; replug it.
  2. id -nG | grep audio — you must be in the group named in the udev rule.
  3. Rule not applied? Rules only run on device events: reload and replug.
  4. The uaccess trap (NixOS). Do not put TAG+="uaccess" in services.udev.extraRules. That generates 99-local.rules, but uaccess is applied by 73-seat-late.rules — a uaccess rule numbered 99 is silently ignored, and you get a rule that looks right and does nothing. Use MODE/GROUP as shown above.
  5. Is the Douze daemon running? It holds the device. systemctl --user stop douze.

The four ssl12.pbXX sinks never appear

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

If 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 card is silent, or an application plays into the void

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"
done

An application lost its virtual microphone / virtual sink

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

A strip shows "Frozen — audio OK" in amber

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.

A strip will not start at all, and its log is empty

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 mic

The resolved path is cached in ~/.cache/douze-fx/jacklib and reused.

Windows plugins die as soon as they load

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

Only the Windows plugins fail, with "Unable to load VST-3 plug-in file"

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.

A plug-in's UI refuses to open ("this plug-in's UI froze the strip")

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.

Others hear the unprocessed microphone, and tweaking the plug-ins changes nothing

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 source

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

Audio breaks up as soon as the machine gets busy

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 to context.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-rt must print 1.
  • A desktop process scheduler (system76-scheduler and friends). Some demote every userspace SCHED_FIFO thread within seconds and drop user services to nice 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 loginLimits do not reach systemd --user. With LimitNICE=0 on user@.service, PipeWire logs mod.rt: could not set nice-level to -11: Permission denied at 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.

Meters do not move, although audio is flowing

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.

Waves (or other "shell") plugins do not appear after a scan

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

LADSPA plugins are missing from the picker

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

The binary breaks after nix-collect-garbage

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.


Tests

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.


Reverse engineering

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

Then 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 --summary

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


Trademarks and disclaimer

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.

License

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.

About

Control the Solid State Logic SSL 12 on Linux, without SSL 360 — reverse-engineered control protocol, CLI, mixer GUI and a PipeWire VST3 host.

Topics

Resources

Contributing

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages