11 — Python guide
open_stomp for Python wraps the Rust library (10) with PyO3. Everything is the same code: the
protocol, DeviceState, the request queue and the connection engine. This guide shows the Python side; the Rust guide
has the background on each piece.
- Install
- Modules
- Quick start
- The device engine
- Following state
- Controlling the pedal
- Presets and settings
- The catalogue
- Bring your own transport
- Capture logs and replay
- Conventions
- Threads and Ctrl-C
- Testing and development
Install
pip install ./bindings/python # from a checkout of this repo (builds with maturin)
Needs Rust ≥ 1.88 and, on Linux, the ALSA and D-Bus headers (libasound2-dev libdbus-1-dev / alsa-lib-devel dbus-devel). Release wheels are
attached to the GitHub releases. Python ≥ 3.9.
Modules
The package mirrors the Rust crate. The most used types are also importable from open_stomp directly.
| Module | Contents | Rust |
|---|---|---|
open_stomp | Device, Update, DeviceState, Event, Origin, Module, ParamDef, Preset, ModuleState, Settings, Msg, Assembler, is_stomp_port, USB_ID, __version__ | crate root |
open_stomp.pack | pack, unpack | pack |
open_stomp.midi | Msg, Assembler, hex, frame, MFR, UID, PEDAL_CH, HOST_CH | midi |
open_stomp.midi.req | version(), product(), settings(), cur_state(), drum(), looper(), tuner(), edit_buffer(), ir_slot(i), preset(i), user_amp(i) | midi::req |
open_stomp.midi.host | cc, program, head, param, volume, page, looper_button, preset_name, preset_write, save_preset, settings_write, LOOP_* | midi::host |
open_stomp.catalog | Module, ParamDef, drum_pattern_name, NOTES, DRUM_STYLES, *_MODELS, MICS, POSITIONS | catalog |
open_stomp.records | Preset, ModuleState, Settings, preset_label, PRESET_COUNT, PRESET_LEN, DEFAULT_CHAIN | records |
open_stomp.state | DeviceState, Event, Origin, Looper, Drum, Tuner, looper_state_name | state |
open_stomp.queue | RequestQueue, Request, Done, handshake, follow_up | queue |
open_stomp.capture | parse, Entry | capture |
open_stomp.nam | Model, Converted, convert, excitation, TONE_BINS, FFT_SIZE | nam |
open_stomp.upload | Chunk, Reassembler, UserAmpInfo, user_amp_upload, user_amp_delete, user_ir_upload, user_ir_name, data_frames, check, floats_to_bytes, bytes_to_floats | upload |
open_stomp.device | Device, Update | device |
Quick start
from open_stomp import Device, DeviceState
state = DeviceState()
with Device() as dev: # finds and connects to the pedal by itself
for u in dev: # blocks; Ctrl-C to stop
ev = state.feed(u)
if ev and ev.category != "protocol" and not ev.redundant:
print(ev) # AMP Mar J800 · GAIN = 64
bindings/python/examples/monitor.py is the full version (--listen, --port, --replay, --all).
bindings/python/examples/shell.py is an interactive shell over the whole API: inspect presets and state, send
live edits, back up, and save presets/settings (with confirmation). -c CMD runs commands non-interactively.
The device engine
Device(...) starts the engine thread immediately; it scans the MIDI ports, connects to the pedal, runs the startup
sync and reports everything as Updates.
Device(*, client_name="Open Stomp", auto_connect=True, sync=True, reply_timeout=1.0, scan_interval=1.5, bluetooth=True)
| Argument | Meaning |
|---|---|
client_name | MIDI client name shown by the OS |
auto_connect | Connect to the first pedal-looking port, and again after it is replugged |
sync | May send anything at all (startup reads and edits). False = listen only |
reply_timeout | Seconds a SysEx request waits for its reply before the queue moves on |
scan_interval | Seconds between port rescans (hotplug) |
bluetooth | Also find the pedal over Bluetooth LE MIDI (its Wireless setting): it appears as a port "JME STOMPBOX (Bluetooth)" (is_ble_port(name)) and works like USB, with slower replies (sync about 19 s). Auto-connect prefers USB. See the library guide |
Receiving:
| Call | |
|---|---|
for u in dev: | Block for each update; ends when the device is closed |
dev.recv(timeout=None) | Next update, or None after timeout seconds |
dev.try_recv() | Next update if one is waiting, else None |
dev.drain() | All waiting updates as a list (for a UI loop) |
Controlling the connection: connect(port), disconnect(), resync(), set_sync(on), replay(path, speed=4.0),
stop_replay(). Stop the engine with close() or by leaving the with block; after that every call raises
RuntimeError.
Update.kind says which fields are set:
kind | Fields |
|---|---|
"ports" | ports: MIDI input port names (at start and on every hotplug) |
"status" | status = "disconnected" / "connected" (port, output) / "replaying" (file, progress) |
"sync" | done, total: startup sync progress; 0, 0 = none running |
"message" | at, origin, bytes: one complete MIDI message, Origin.PEDAL or Origin.HOST |
"mark" | at, text: a # MARK note in a replayed capture |
"info" | at, text, error: connected, unplugged, no reply, refused send… |
"reset" | A new session (connect, resync, replay): reset your DeviceState |
at is Unix time in seconds like time.time() (the original capture time during replay).
Following state
DeviceState is the pedal model. state.feed(update) applies "message" updates and returns their Event, clears
the model on "reset", and returns None for anything else. state.apply(data, origin) applies raw bytes directly.
ev = state.apply(b"\xb0\x12\x40", Origin.PEDAL)
ev.category, ev.module, ev.subject, ev.value # ('effect', Module.AMP, 'Black Twin · GAIN', '64')
Event | |
|---|---|
category | "preset", "effect", "volume", "navigation", "looper", "drum", "tuner", "settings", "device", "protocol", "unknown" |
module | The Module for effect events |
label, subject, value | Display text: "AMP", "Mar J800 · GAIN", "64"; str(ev) joins them |
coalesce | Same key on consecutive events = one gesture (knob sweep) |
redundant | Changed nothing |
origin, raw | Direction and bytes |
DeviceState attributes: firmware, model_id, product, presets (99 × Preset | None), current, live,
live_known, page, looper, drum, tuner, settings, user_amps, user_irs; methods modified(),
stored(i), preset_name(i), model_name(module, model), select_preset(i).
Attributes are snapshots.
state.live,state.presets[3],preset.modulesand so on return copies. Changing a copy never changes the model; the model changes only throughapply/feed. To edit, take a copy, change it, and send it (see saving).
Controlling the pedal
Live changes (heard at once, stored only when you save):
from open_stomp import Module
from open_stomp.midi import host
dev.select_preset(4) # P05
dev.set_head(Module.AMP, 8, True) # Mar J800, on (model and on/off share one CC)
dev.set_param(Module.AMP, 0, 100) # GAIN 100
dev.set_param(Module.EQ, 3, 60) # EQ band 4 +10
dev.set_volume(80)
dev.show_page(Module.DELAY) # pedal display jumps to DELAY
dev.looper_button(host.LOOP_REC_PLAY_DUB)
dev.send(host.cc(0x3F, 1)) # any CC from docs/02 (drum on)
To switch a block off without changing its model, pass the current one:
dev.set_head(m, state.live.module(m).model, False).
Sends are refused (an "info" update with error=True) in listen-only mode or without an output port. The engine
reports each message it sends as an Origin.HOST "message" update, so state.feed keeps the model in step with
your own edits.
Saving a preset
p = state.live # a copy of the edit buffer with all live edits
source = state.current # the slot the edits were made on
p.index = 21 # store to P22 (or keep `source` to overwrite)
p.name = "Lead" # <= 16 printable ASCII; others become '?'
dev.save_preset(source, p) # name, record, then select the saved slot
Importing a NAM model
dev.import_nam_file(2, "Jcm800.nam") # optimised conversion (~1 min, GIL released) + upload to USER AMP 03
dev.import_nam_file(2, "Jcm800.nam", measure="app") # the official algorithm (~4 s)
Or step by step:
from open_stomp import nam
model = nam.Model.load("Jcm800.nam")
c = nam.convert(model, measure="optimised") # or nam.convert("Jcm800.nam", ...); the default is "app"
dev.import_user_amp(2, "Jcm800", c.data, c.factor)
out = model.process(samples) # run the network on your own audio (mono, 48 kHz)
For an exact match with the official app, pass its own test signal, read from an installed Stomp Manager:
nam.convert(model, excitation=nam.official_excitation()) (or official_excitation("/path/to/Stomp Manager.app")).
This is opt-in; dev.import_nam_file never reads the official app.
The optimised conversion (the default for import_nam_file) fits the filters with a simulation of the pedal; see
05.
Converted also has every intermediate stage (levels, levels3, bla_db, g1_gains_db, g1_ir, g2_ir).
Importing an IR
dev.import_ir_file(0, "My Cab.wav") # optimised conversion + upload to USER IR 01 ("My Cab")
from open_stomp import ir
c = ir.convert("My Cab.wav") # c.data: the 2048 floats; c.gain, c.ir
rate, channels = ir.load_wav("My Cab.wav") # also ir.save_wav, ir.resample
The conversion is the official app’s, recovered from its code and identical to its uploads (05).
method="optimised" fits the 384 samples the pedal actually plays to the whole IR instead, much closer on the pedal
for long or delayed IRs (05); it is the default for
dev.import_ir_file (method="app" for the official conversion), while ir.convert defaults to "app".
Uploading a user AMP or IR
dev.import_user_amp(2, "Jcm800", floats) # 2048 floats -> USER AMP 03 (factor=1.0 by default)
dev.import_user_ir(0, "My Cab", floats) # USER IR 01
dev.delete_user_amp(2)
dev.rename_user_amp(2, "Marshall JCM800") # only the name; the sound stays as it is
dev.rename_user_ir(0, "SVT 8x10 57")
nam.convert and ir.convert make the 2048 floats from .nam and .wav files; a captured
conversion can be recovered with upload.Chunk.parse + upload.Reassembler and uploaded to any slot. A bad slot or
data raises ValueError.
Changing system settings
s = state.settings # start from the pedal's record: unknown bytes survive
s.input_trim = 13 # 0-20 = -10..+10 dB
s.loopback = False
dev.write_settings(s)
Preset and settings writes match the official app byte for byte but have not yet been run on hardware from this library. Back up your presets first (keep the
Presets fromstate.presets).
Presets and settings
from open_stomp import Module, Preset
p = Preset.from_packed(payload) # a 0x0B/0x0C SysEx body; ValueError if it isn't one
p.name, p.index, p.volume, p.chain # chain: list of 7 Modules (GATE is always first, not listed)
p.module(Module.AMP) # ModuleState(on=True, model=8, params=[...12 values])
p.set_module(Module.AMP, on=False, model=3, params=[50, 50, 50])
p.set_param(Module.AMP, 0, 100)
p.to_packed(), p.to_raw() # bytes; unknown bytes of the original record are kept
p.same_sound(other) # ignores unused parameter slots
Preset() is an empty preset. copy.copy/deepcopy and p.copy() work. Assigning a whole list to p.modules
also works. Settings has input_trim (+ input_trim_db), eq_bass, eq_mid, eq_treble, wireless,
loopback, raw, from_packed, to_packed, to_raw.
The catalogue
from open_stomp import Module
Module.AMP.models[8] # 'Mar J800'
[p.name for p in Module.AMP.params(8)] # ['GAIN', 'BASS', 'MIDDLE', 'TREBLE', 'PRESENCE', 'MASTER']
mic = Module.CAB.param(3, 0)
mic.kind, mic.choices[:2], mic.format(4) # ('enum', ['E906', 'M160'], 'SM57')
Module.from_param_cc(0x12) # (Module.AMP, 0)
Module.AMP.head_cc, Module.AMP.param_ccs, Module.AMP.label # 1, (18, 6), 'AMP'
Module is an int-like enum (int(Module.EQ) == 7, hashable); Module.ALL and Module.EFFECTS list them.
ParamDef.kind is "knob" (0–100), "bipolar" (0–100, 50 = centre) or "enum"; format(v) gives the value as
the official app shows it. catalog.drum_pattern_name(13) is "Rock 04"; catalog.NOTES names tuner notes.
Bring your own transport
The protocol pieces work on plain bytes, so you can drive the pedal through any MIDI library (mido,
python-rtmidi, Web MIDI via Pyodide, BLE-MIDI…):
from open_stomp import Assembler, DeviceState, Msg, Origin
from open_stomp.queue import RequestQueue, follow_up, handshake
q, asm, state = RequestQueue(timeout=1.0), Assembler(), DeviceState()
q.start_sync(handshake())
def pump(): # call regularly
while (r := q.pop()) is not None: # nothing while a request is in flight
midi_out.send_message(list(r.bytes))
state.apply(r.bytes, Origin.HOST)
q.sent(r)
q.expire() # move on from unanswered requests
def on_midi_in(data: bytes): # any chunking
for msg in asm.push(data):
q.on_message(msg) # releases the next request if it answers
for r in follow_up(msg):
q.read(r)
state.apply(msg, Origin.PEDAL)
q.progress is (done, total) of the sync. For tests, sent(r, at=…) and expire(at=…) take a time in seconds
since the queue was created, so timeouts can be driven without sleeping.
Capture logs and replay
from open_stomp import DeviceState, capture
state = DeviceState()
for e in capture.parse(open("captures/logs/01-main-session.log").read()):
if e.kind == "mark":
print("=====", e.text)
else:
print(state.apply(e.bytes, e.origin))
dev.replay(path, speed) plays a log through the engine instead, with its timing, as if a pedal were connected.
Conventions
- Bytes in: every argument that takes MIDI data accepts
bytes,bytearrayor a list of ints. Bytes out: alwaysbytes. - Indices are 0-based: preset 0 is P01 (
records.preset_label(0) == "P01"), model 8 is shown as#09. - Errors: bad records raise
ValueError, out-of-range slotsIndexError, using a closedDeviceRuntimeError. Builders that cannot apply returnNone(host.head(Module.LOOP, …)). - Snapshots: objects returned from attributes are copies (see above).
Threads and Ctrl-C
The engine runs on its own thread. recv, iteration, close and the end of a with block release the GIL, so
other Python threads keep running, and they wake at least every 100 ms to let Ctrl-C raise KeyboardInterrupt. A
Device can be used from several threads (one receiving, others sending commands). The bindings also support
free-threaded Python.
Testing and development
cd bindings/python
pip install maturin pytest mypy
maturin develop # build into the active venv
pytest # tests driven by captures/logs
python -m mypy.stubtest open_stomp --ignore-missing-stub # the .pyi stubs match the module
| Test | Checks |
|---|---|
test_protocol.py | Packing, parsing, reassembly, command builders (captured bytes), catalogue, record encode/decode, copy semantics |
test_captures.py | The whole captured session applies cleanly; every record re-encodes byte for byte; the captured save as is rebuilt; the startup sync through RequestQueue against a fake pedal answering from the capture |
test_device.py | The engine end to end through replay, timeouts, close semantics |
Layout: bindings/python/src/*.rs (one file per Rust module), python/open_stomp/ (__init__.py and the .pyi
stubs), tests/, examples/. The Python package version is the workspace version in the root Cargo.toml.