A headless LV2 synth engine for low-spec 32-bit Linux hardware. MIDI in, audio out, no display required.
Runs a Midilab JC-303 acid bass synth on a Packard Bell netbook (Intel Atom N270, 1 GB RAM) controlled by a USB MIDI hardware controller. The machine has no X server — everything lives in a TTY.
Status: complete and archived. The code is clean and the patterns are reusable for any LV2 plugin that exposes patch:writable Atom parameters.
Version note: The patch in this repo targets jalv 1.6.8 (released September 2022). If you are using jalv 1.8.0 or later, you do not need this patch — the 1.8.0 changelog explicitly lists "Add support for advanced parameters in console frontend", which is the upstream fix for the same bug. Use 1.8.0 if you can.
This project used 1.6.8 because it was incorrectly identified as the latest stable release at the time of development. A pull request was submitted upstream with this fix and was rejected.
Stock jalv 1.6.8 cannot set parameters on JUCE-style LV2 plugins via its console interface. JUCE bundles parameters as patch:writable Atom properties, not lv2:ControlPort inputs. The jalv_process_command() function in jalv_console.c only searches jalv->ports (ControlPorts) when you type SYMBOL = VALUE at the prompt — it never reaches jalv->controls, where the Atom parameters actually live.
The fix is two lines in jalv_console.c, in the symbol-lookup fallback branch:
// jalv-1.6.8/src/jalv_console.c (lines ~244-261)
// After the port search fails, fall through to patch:writable controls:
ControlID* control = NULL;
for (size_t i = 0; i < jalv->controls.n_controls; ++i) {
ControlID* c = jalv->controls.controls[i];
if (c->is_writable &&
!strcmp(lilv_node_as_string(c->symbol), sym)) {
control = c;
break;
}
}
if (control) {
jalv->has_ui = true; // <-- critical: without this, jalv_apply_ui_events()
// returns immediately every audio cycle and
// silently discards all queued atoms
jalv_set_control(jalv, control, sizeof(float), jalv->forge.Float, &value);
fprintf(stderr, "%s = %f\n", sym, value);
}The patched source is included in jalv-1.6.8/. Rebuild with:
ninja -C ~/jalv-1.6.8/build
sudo ninja -C ~/jalv-1.6.8/build installOnce patched, you can type JC303_cutoff = 0.75 at the jalv prompt and hear the filter move. This is what makes the entire system possible.
Why
jalv->has_ui = true? The atom queuing path in jalv is gated on whether a UI is attached. Without a UI,jalv_apply_ui_events()is a no-op. Settinghas_ui = truetricks the audio thread into flushing the atom queue on every cycle, which is the mechanism that actually delivers the value change to the plugin.
Hardware MIDI controller
│ (USB → CH345 cable)
▼
ALSA MIDI input
│
a2jmidid -e (ALSA→JACK bridge)
│
▼
engine.py (Python, mido)
│
├── channel filter (midi_channel from mapping.yaml)
│
├── note_on / note_off
│ └── mido virtual output (PotatoSynth)
│ └── jack_connect → JC303:in
│
└── control_change (CC)
└── tmux send-keys → jalv stdin
└── "JC303_cutoff = 0.750\n"
└── patched jalv → patch:Set atom → plugin
jalv (in tmux session "jalv")
└── JC303 LV2 plugin
├── JC303:audio_out_1 → system:playback_1
└── JC303:audio_out_2 → system:playback_2
tui.py (optional, passive display)
└── reads /dev/shm/synth_state.json at 10 Hz
engine.py is the only process that writes to jalv. tui.py is a read-only Textual dashboard — killing it has no effect on audio.
System packages (tested on antiX 23 i386, SysVinit):
sudo apt install jackd2 a2jmidid tmux python3-pipPython:
pip3 install mido python-rtmidi pyyaml textualJACK realtime priority (add to /etc/security/limits.d/audio.conf):
@audio - rtprio 99
@audio - memlock unlimited
Swap (essential on 1 GB RAM):
sudo dd if=/dev/zero of=/swapfile bs=1M count=4096
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# add to /etc/fstab: /swapfile none swap sw 0 0The JC-303 must be compiled for your target architecture. The build script handles i386/i686 with SSE3-only flags (no AVX — the Atom N270 doesn't have it):
chmod +x build_jc303.sh
./build_jc303.shThis clones midilab/jc303, compiles it, and installs the bundle to ~/.lv2/.
The source is already patched in jalv-1.6.8/. This is only necessary if you are stuck on 1.6.x — jalv 1.8.0 fixed this upstream. If you can install 1.8.0 from your distro or build it from source, do that instead and skip this section entirely. Build with meson:
cd jalv-1.6.8
meson setup build --buildtype=release
ninja -C build
# binaries land in build/jalv (console), build/jalv.gtk (GTK UI)
# for headless use, only the console binary mattersOn i386, you may need to pass architecture flags:
CFLAGS="-m32 -march=i686 -msse3 -mno-avx" \
CXXFLAGS="-m32 -march=i686 -msse3 -mno-avx" \
meson setup build --buildtype=releaseVerify the patch is working:
~/jalv-1.6.8/build/jalv http://github.com/midilab/JC303
# at the > prompt:
> JC303_cutoff = 0.75
# you should hear the filter move and see: JC303_cutoff = 0.750000Everything is driven from config/JC303/mapping.yaml:
synth_profile:
name: "JC-303 Acid Engine"
lv2_uri: "http://github.com/midilab/JC303"
io:
tracker_input_port: "CH345" # substring match against mido port names
midi_channel: 1 # 1–16; only this channel passes through
parameters:
cutoff:
cc: 74
port_symbol: "JC303_cutoff"
name: "Cutoff"
default: 0.0
# ... etcport_symbol is the exact string you type at the jalv prompt. Find the available symbols by typing controls at the jalv interactive prompt after loading the plugin.
To adapt this for a different LV2 plugin:
- Run
jalv <plugin_uri>and typecontrolsto list all settable symbols. - Create a new
config/<PluginName>/mapping.yamlwith your CC→symbol assignments. - Update
MAPPING_FILEinengine.pyto point at the new file.
SYSTEM_BOOT.sh runs the full startup in order:
chmod +x SYSTEM_BOOT.sh
./SYSTEM_BOOT.shInternally, it does:
1. Kill any existing jackd / jalv / a2jmidid / python3 / tmux processes
2. Start jackd: 44.1 kHz, 256-frame buffer, 2 periods, ALSA-MIDI bridge (-X seq)
3. sleep 3
4. Start a2jmidid -e (exposes ALSA MIDI ports as JACK ports)
5. sleep 2
6. Start jalv inside detached tmux session named "jalv"
7. sleep 2
8. jack_connect JC303:audio_out_1/2 → system:playback_1/2 (audio only)
9. Start engine.py in background (handles MIDI wiring at runtime)
10. sleep 1
11. exec python3 tui.py (foreground — kills on Ctrl+C, audio continues)
MIDI wiring is intentionally not done at boot. engine.py opens a virtual MIDI output port (PotatoSynth), waits for a2jmidid to bridge it into JACK, then calls jack_connect at runtime. This lets engine.py enforce channel filtering before any MIDI reaches jalv. A static jack_connect at boot would bypass the filter for note messages.
Once booted, the system is self-contained:
- TUI (
tui.py): Reads/dev/shm/synth_state.jsonat 10 Hz. Safe to kill and restart without affecting audio. - Engine log:
tail -f /dev/shm/engine.log - jalv console:
tmux attach -t jalv— the patched prompt is always accessible for manual parameter entry. - Audio continues as long as jackd + jalv are running. Losing the SSH connection or killing the TUI has no effect.
.
├── SYSTEM_BOOT.sh Full boot script
├── engine.py MIDI router + state writer (main process)
├── router.py Earlier, simpler router (reference only)
├── tui.py Textual TUI dashboard (passive display)
├── widgets.py Pure render functions for TUI widgets
├── build_jc303.sh Compiles JC-303 for i386
├── jalv-1.6.8/ Patched jalv source (the important part)
│ └── src/
│ └── jalv_console.c ← patch is here
└── config/
└── JC303/
├── mapping.yaml CC→symbol map, MIDI channel, defaults
└── tui_layout.yaml Widget grid layout for tui.py
Writing to jalv's stdin via /proc/<pid>/fd/0 goes to the terminal buffer, not jalv's fgets() read loop. The two are different file descriptors. tmux send-keys directly inputs characters into the tmux pane's pseudoterminal, which is what jalv's blocking fgets actually reads. This is why the tmux session is a required part of the architecture, not a convenience.
| Component | Detail |
|---|---|
| Machine | Packard Bell EasyNote netbook |
| CPU | Intel Atom N270 @ 1.6 GHz (32-bit, SSE3, no AVX) |
| RAM | 1 GB + 4 GB swapfile |
| OS | antiX 23 net (i386, SysVinit, no X server) |
| Audio | Internal motherboard DAC via ALSA (hw:0) |
| MIDI | CH345 USB-to-MIDI cable |
| Controller | Polyend Tracker (used as hardware MIDI sequencer) |
The Atom N270 can run jackd at 256-frame buffer (≈5.8 ms latency) without XRuns under normal load. Rapid MIDI CC sweeps are debounced at 10 Hz in engine.py to prevent CPU spikes from back-to-back tmux send-keys calls.