Skip to content

SaturnsGithubSaga/potatosynth

Repository files navigation

PotatoSynth OS

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.


The Key Insight (read this first)

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 install

Once 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. Setting has_ui = true tricks the audio thread into flushing the atom queue on every cycle, which is the mechanism that actually delivers the value change to the plugin.


What It Does

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.


Prerequisites

System packages (tested on antiX 23 i386, SysVinit):

sudo apt install jackd2 a2jmidid tmux python3-pip

Python:

pip3 install mido python-rtmidi pyyaml textual

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

Building the Plugin

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

This clones midilab/jc303, compiles it, and installs the bundle to ~/.lv2/.


Building the Patched jalv

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 matters

On 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=release

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

Configuration

Everything 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
  # ... etc

port_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:

  1. Run jalv <plugin_uri> and type controls to list all settable symbols.
  2. Create a new config/<PluginName>/mapping.yaml with your CC→symbol assignments.
  3. Update MAPPING_FILE in engine.py to point at the new file.

Boot Sequence

SYSTEM_BOOT.sh runs the full startup in order:

chmod +x SYSTEM_BOOT.sh
./SYSTEM_BOOT.sh

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


Runtime

Once booted, the system is self-contained:

  • TUI (tui.py): Reads /dev/shm/synth_state.json at 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.

File Map

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

Why tmux send-keys and Not stdin Injection?

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.


Hardware

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.

About

Personal project running software synths on a headless 32 bit Packard Bell netbook

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors