Modular MIDI generation for electronic musicians/live coders who want the feel of a patch-cable session and the reproducibility of a saved file
oxurack borrows the mental model of Eurorack — small modules, patch cables, unified signals — and applies it to MIDI. Nothing here generates audio. Instead, modules produce and transform streams of notes, gates, velocities, and control values, routed through virtual cables to your synthesizers, samplers, and DAW.
Generative and modular music-making tools tend to live on opposite ends of a spectrum. On one end: live-coding environments that are expressive but ephemeral — the performance and the patch are the same artifact, and recalling what you did last Tuesday is a matter of guessing your own past. On the other end: GUI-based modular racks like VCV Rack that are beautifully reproducible but expensive to run next to a DAW, and where capturing a live performance for later editing means bouncing audio rather than keeping the structure.
oxurack aims for the middle:
- Patches are files. A patch is a declarative description of modules, parameters, and cable connections. It loads the same way every time, ticks deterministically from a known seed, and lives in git alongside the rest of your music.
- The output is MIDI, not audio. That means every performance — every parameter tweak, every patch change — can be recorded directly into a DAW as editable notes and controllers. Your generative session becomes a starting point, not a print.
- CPU footprint is small. oxurack isn't synthesizing sound, so it leaves room for your DAW and your soft synths on the same machine — the thing that drove the original motivation away from VCV Rack for live recording.
- The ontology is constrained, on purpose. Eurorack's uniform-voltage, single-signal-per-cable design is its strength: one vocabulary, reused across thousands of modules. oxurack aims for the same feel in the MIDI domain.
These are the intended musical workflows. The current state and quick start below describe what is available today.
- Build a generative rhythm section from a Euclidean module, a noise source, and a Turing Machine; record the result into Logic Pro (or any DAW) as MIDI you can edit, quantize, and remix afterward.
- Put a scale quantizer, sample-and-hold, and LFO in front of a hardware synth and drive its pitch with self-similar melodies shaped by the instruments' physical controls.
- Save a patch you built during a late-night experiment, come back in a week, load it with the same seed, and recreate its starting configuration. Exact mid-performance checkpoints remain future work.
- Map a nanoKONTROL2 (or any MIDI controller) to module parameters and play oxurack like an instrument while it plays your synth.
- Sync to your DAW as a clock slave so your generative performance records cleanly into an existing session — or run oxurack as the master clock when you want it to drive everything else.
- Embed a single module — just the Turing Machine, say — inside a Rust project that has nothing to do with oxurack itself. Every module crate stands alone.
oxurack is a Cargo workspace:
oxurack-core— the shared vocabulary: values, ports, cables, the tick cycle, and the patch file format.oxurack-rt— MIDI I/O and real-time clock infrastructure. Integration with the live interface's external transport controls remains planned.oxurack-mod-*— module engines and rack adapters. Clock, LFO and Turing Machine can run standalone; their optionalrackfeature adds Bevy/core integration.oxurack-graph— optional rack navigation and topology queries.oxurack— the public rack API, patch loading, persistence, Scope capture and executable examples.rack-liveprovides a paused terminal command interface; visual editing and controller mapping remain future work.
Install module types with the facade, for example
rack.install::<TuringMachineModule>(), before loading a patch that uses them.
The rack creates the patch's module instances and runs their registered Bevy
systems, routing values between modules on each 1 ms simulation sample.
Planning lives on the dedicated planning branch in .worktrees/planning.
Start with project01-mvp
for the current roadmap and reconstructed status. The historical design and
implementation documents are preserved there with their Git history.
If the worktree is absent, create it with
git worktree add .worktrees/planning planning after fetching the branch.
oxurack is unreleased, but the Clock → Turing Machine → MIDI-out instrument now runs in the rack, with LFO modulation and a bounded Scope workbench. APIs may change before v1.0.
Available on main:
- Seeded RON patches, deterministic MIDI traces and paced playback to a selected MIDI output.
- Clock, Turing Machine and five-waveform LFO modules, plus the MIDI-out sink.
- Scope capture of selected signal ports and MIDI events, with terminal inspection and CSV export.
- Live parameter, metadata and cable edits through the Rust API; saving the current configuration and replaying it in a fresh rack.
- A paused
rack-liveterminal session with play/pause, parameter, note, status and observation commands. - Explicit reset/restart/load/unload operations, module removal, typed instance handles and bounded command processing.
The examples below exercise these capabilities. The paused rack-live example
provides interactive play/pause, parameter and note commands, status, and
ordered change observation; it is a terminal command interface, not a visual
editor. Controller mapping and external transport controls remain future work.
The finite-run Scope view is printed after the run, rather than continuously
redrawn. Additional modules, including Chord, remain planned.
Saved patches preserve configuration and seed, not the current engine position. A fresh load restarts the sequence; it does not resume the exact performance. For standalone Turing Machine use, see its crate README.
Run these commands from the repository root with Rust and Cargo installed. The examples are built from this workspace; no separate CLI installation is needed. Trace-only runs do not open a MIDI device or produce audio.
Start with a short Clock/Turing Machine trace:
cargo run --locked -p oxurack --example turing-machine-midi -- \
--patch crates/oxurack/examples/patches/turing-machine-midi.ron --samples 2000Then inspect the LFO-modulated instrument and save its Scope capture:
OXURACK_DEMO_DIR=$(mktemp -d)
cargo run --locked -p oxurack --example turing-machine-midi -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--samples 10000 --scope-view \
--scope-port lfo_main.bipolar_out --scope-port tm_main.note \
--scope-csv "$OXURACK_DEMO_DIR/scope.csv" \
> "$OXURACK_DEMO_DIR/midi.trace"The terminal view goes to stderr; midi.trace contains the MIDI bytes and
scope.csv contains the retained signal/MIDI records. Offline runs advance
simulation time as fast as the runner can execute. With a selected MIDI output,
samples are paced at 1 ms, so 10,000 samples represent roughly ten seconds.
To hear it, first list available outputs, then replace the example name below with one exact name from that list. Route that output to a hardware synth or a DAW instrument track configured to receive MIDI:
cargo run --locked -p oxurack --example turing-machine-midi -- --list-outputs
cargo run --locked -p oxurack --example turing-machine-midi -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--output-name "YOUR MIDI OUTPUT NAME" --samples 10000Playback ends at the sample limit; Ctrl-C requests an earlier stop and MIDI cleanup. For the full flag list:
cargo run --locked -p oxurack --example turing-machine-midi -- --helpContinue with editing and saving, the scripted rack session, or Scope capture and inspection.
For a guided listening and inspection session, use the Clock, LFO and Scope user-testing runbook.
The installed midi_out sink can stream a deterministic sample-indexed MIDI
byte trace without opening a MIDI device. The default run advances 2,000
1 ms samples; set --samples 0 for an empty trace or choose another finite
sample count:
cargo run -p oxurack --example turing-machine-midi -- \
--patch crates/oxurack/examples/patches/turing-machine-midi.ron --samples 2000Each output row is sample|STATUS DATA1 DATA2, with uppercase hexadecimal
bytes. The example drains each completed sample before advancing the rack.
rack-live starts paused at sample zero. On Unix, a supervised editor helper
owns terminal input and human-readable rendering; the parent keeps the single
rack, playback Session, and native MIDI sender. The rack owner continues its
1 ms sample loop while the frontend waits for input. MIDI trace rows use
stdout, while command replies and Scope views use stderr:
cargo run --locked -p oxurack --example rack-live -- \
--patch crates/oxurack/examples/patches/turing-machine-midi.ron \
--scope-port tm_main.inversion_probability --scope-capacity 100000The Unix editor uses Reedline 0.52.1 and requires Rust 1.95 or newer. Its streams are strict: stdout contains only complete sample-indexed MIDI rows; prompts, startup notices, command replies, and watch pages belong to the frontend's stderr sink. A helper must complete the version, process-ID, and mode handshake before the parent admits commands. The frontend becomes ready only after it receives the initial 32 credits. While those credits are in use, the prompt reports busy and keeps one unsubmitted edited line locally; a painted result returns its credit through a separate ACK. A protected/write completion means the parent published a complete frame, and a paint ACK means the frontend rendered it to its sink. Neither proves that a person read it. The bounded IPC and presentation limits fail the session explicitly when a reply cannot be represented or delivered; an executed mutation is reported as completed with an error when only its presentation fails. Such a failure exits unsuccessfully and does not turn forced child recovery into graceful quit.
On Unix, the parent also starts a byte-only trace helper so a slow stdout pipe
does not block the rack owner in a synchronous write. --trace-capacity N
defaults to 4096 rows and accepts 1 through 65536. A reservation and every
committed row without an ACK occupy this capacity. Reservations are not trace
rows: with a native output selected, only the successfully sent prefix receives
contiguous trace IDs. Without a native output, generated rows are committed
directly. The helper accepts batches of at most 64 rows and ACKs only after the
whole batch has been written to stdout and flushed; an ACK does not promise
durable storage or that a person read the pipe. The owner services at most
16 KiB of trace IPC per turn and stops explicitly if its oldest unacknowledged
row reaches the 500 ms observed-service limit. Stop allows a 1500 ms joint
editor/trace drain. A trace failure exits unsuccessfully, reports the incomplete
row/counter state, and still attempts MIDI cleanup. A failed write may leave an
unknown byte prefix or partial final line; rack-live never replays it. These
observed deadlines do not promise hard real-time pacing or hardware jitter.
Non-Unix builds keep the synchronous stdout writer and do not claim this
pressure isolation.
Scope capture is always enabled for a loaded patch. With no --scope-port, the
bounded capture records MIDI events only; specify one or more exact scalar ports
to also capture their values. --scope-capacity works independently. The old
--scope-view flag remains accepted for compatibility and does not enable or
disable capture. Enter help for the command list. status reports patch-global
BPM separately from each Clock's audible bpm parameter. For example, play starts, pause sends
voice cleanup and retains the current sample and engine phase, and reset
sends cleanup while preserving whether the rack is running. get and set
operate on registered live parameters; save PATH writes the current
configuration and overwrites an existing destination as the facade's SavePatch
command does; scope-save PATH creates a new CSV without replacing an existing
file. Configuration saves do not include engine phase. The TTY frontend keeps
at most 32 unacknowledged command results; while that window is full, Enter
leaves the edited line in place until a result has been painted and its credit
returned. Ctrl-C ends the whole session, discards the unsubmitted partial line,
and performs MIDI, editor, and terminal cleanup. Piped commands retain
their order through EOF, including a final valid line without a newline.
Keep terminal output readable while using the TTY interface: replies and watch
pages are delivered there, and a stalled display delays their acknowledgement.
The Unix parent observes renderer-owned health every 100 ms after the editor is
initialized and requires a strictly increasing epoch within 1500 ms. These
pulses report that the renderer is still running; they do not prove that a
paint was seen, a command credit was returned, or a key was received. Paint and
watch ACKs remain separate delivery evidence. Stop-admission does not stop
health pulses while the renderer remains responsive. If renderer health
expires, rack-live stops new command execution, attempts native note-off and
controller-reset cleanup, finalizes the MIDI trace before editor recovery, then
allows a bounded shutdown drain before forced helper reap and terminal
restoration. The session exits unsuccessfully; this is not a graceful quit. The
separate foreground host and terminal file descriptors matter to recovery: the
harness does not change the product's inherited blocking mode or drain held
output before actual rack exit. On Unix, detected helper or cleanup failures
return an unsuccessful exit; forced termination is never a graceful quit.
Scope labels generated
events as Generated and records native delivery as Sent only after a successful
device send. Both output modes also write the rack's sample-indexed MIDI trace
to stdout. Scope views and synchronous file operations can delay wall-clock
pacing while preserving the continuous rack state; a sample that became due
during the operation is admitted once, then accumulated lateness is discarded.
Type quit or send Ctrl-C to clean
up and exit. Add --output-name EXACT_NAME or
--output-index N to connect to one selected native MIDI output; without either
flag the example writes the deterministic MIDI trace and does not enumerate or
open MIDI devices.
Run the finite Unix helper and terminal regressions after building the default
example with python3 crates/oxurack/examples/rack_live/pty_regression.py.
Its unread-output recovery case runs with a separate Python controlling-session
host and verifies the rack/helper foreground identities, helper reap, and full
terminal restoration while the rack parent is still alive. The observed PTY
queue threshold is specific to that host. The historical self-session-leader
blocked-output boundary is retained as an explicit opt-in with
--session-leader-boundary; it may fail during process exit on the measured
macOS host and is not part of the supported default regression result.
To inspect current output names before selecting a device, run
cargo run --locked -p oxurack --example rack-live -- --list-outputs. The
command above uses the plain MIDI fixture, with no LFO connected to the Turing
Machine's change_modulation input, so the configured q/mode lock example can
be read directly:
play
set clock_main bpm 90
set tm_main inversion_probability 0.375
set tm_main write_mode normal
get tm_main inversion_probability
scope
save /tmp/my-live-patch.ron
pause
status
quit
When using the LFO fixture, finite held CV changes the effective q; the
configured inversion_probability getter and saved patch value do not include
that CV. Disconnect or unpatch the LFO cable before treating q as a locked
effective value.
The live rack also supports load PATH, unload, restart, and remove MODULE
while keeping its MIDI sender session open. cables lists stable cable IDs and
current parallel-cable occurrences; find any ["tag", ...] and
find all ["tag", ...] search current rack, module, and cable tags exactly.
patch accepts an optional RON Option<CableTransform>, such as
Some(Affine(factor: 0.25, offset: 0.0)); unpatch names an exact occurrence.
Label text uses a RON string, while tags and rack notes use RON string lists:
label set rack description "hello world\nsecond line"
label set rack notes ["alpha", "beta"]
label set rack tags ["demo", "session"]
find all ["demo", "session", "demo"]
label get rack notes
scope-select [("lfo_main", "bipolar_out")]
scope-select []
For identity-bearing history, use the note commands instead of replacing the
whole Notes label:
note list
note add ("motif with spaces", Some("Duncan"))
note edit 1 ("revised motif", None)
note delete 1 Some("Marjorie")
note history 1
IDs are stable, nonzero decimal patch-local numbers. Authors are explicit
(None means anonymous); empty text is valid, and deletion appends a retained
tombstone. Listing includes deleted notes, while the rack Notes label shows
only active text. A whole-list label set rack notes [...] remains supported
and replaces journal notes by tombstoning active IDs and allocating fresh IDs,
even when text is unchanged. On a legacy patch, whole-list label writes retain
the legacy list representation and its duplicate/empty-string behavior. Reads
do not migrate data; their virtual IDs become persistent only after a
successful per-note migration. The first successful
per-note write to a legacy 1.0 patch migrates all legacy strings and saves as
1.1; other legacy schema versions reject per-note writes. Older readers may
ignore the optional journal when loading, and saving through one can discard
history, so keep the original file when round-tripping with older software.
Notes are patch metadata, not a runtime checkpoint: edits do not advance or
reset samples, drain MIDI, or clear Scope.
Library compatibility: RackMeta.notes is now a NoteStore, so code that
constructs legacy values can use vec![...].into() or
NoteStore::legacy(vec![...]). RackConfig struct literals should set
note_journal: None or use ..Default::default(). LabelValue::Notes, the
facade's legacy string-list API, and the legacy wire omission remain supported.
To rehearse the save across two fresh processes without opening a MIDI output:
NOTE_SESSION_DIR="$(mktemp -d)"
cargo build --locked -p oxurack --examples
target/debug/examples/rack-live --patch crates/oxurack/examples/patches/turing-machine-lfo.ron <<EOF
label set rack notes ["alpha", "beta"]
note list
note edit 1 ("alpha revised\nsecond", Some("Duncan"))
note add ("", None)
note delete 2 Some("Marjorie")
note history 1
save $NOTE_SESSION_DIR/notes.ron
quit
EOF
target/debug/examples/rack-live --patch "$NOTE_SESSION_DIR/notes.ron" <<'EOF'
note list
note history 1
note history 2
status
quit
EOF
target/debug/examples/turing-machine-midi --patch "$NOTE_SESSION_DIR/notes.ron" \
--samples 2000 --scope-view --scope-port lfo_main.bipolar_outThe first command seeds legacy IDs 1 and 2. After editing, adding, and deleting, the second process should show schema 1.1, ID 1's two revisions, ID 2's tombstone, and ID 3's empty active text. The final finite run verifies that the saved metadata is readable by a fresh instrument process; it does not claim a hardware or listening witness.
Targets are rack, module NAME, or cable SRC_MODULE SRC_PORT DST_MODULE DST_PORT OCCURRENCE, so dots inside names remain intact. A malformed RON value
or unsupported target is reported as a recoverable command reply; it leaves the
owner running. scope-select uses a full RON list of (module, port) pairs and
commits remembered preferences only after configuration succeeds.
One session view retains Scope selection/capacity through unload and reload.
After a successful replacement or restart, an invalid remembered selector is
reported and capture falls back to MIDI-only at the same capacity. Other capture
failures are reported as unavailable while the committed rack remains loaded.
status shows the current origin and displays <unloaded> when empty. restart
retains the current file origin. Watch uses an independent reader at the current
end of the bounded 1024-record journal; watch off releases it. Each owner turn
prints at most 32 records, including batch/lifetime/sample boundaries and any
exact retention gap. Watch rows and command replies go to stderr; stdout remains
the MIDI trace. See
docs/user-testing-rack-session.md for a
copy/paste interaction check and report-back template.
For a no-device live save and fresh-load check, build the example and use the public commands. The first process prints its MIDI trace to stdout and replies to stderr; the second process reads the saved configuration:
TRACE_SESSION_DIR="$(mktemp -d)"
target/debug/examples/rack-live \
--patch crates/oxurack/examples/patches/turing-machine-midi.ron \
--trace-capacity 4096 <<EOF
set clock_main bpm 1000
set clock_main ppqn 30
set tm_main inversion_probability 0.375
play
status
pause
save $TRACE_SESSION_DIR/live.ron
quit
EOF
target/debug/examples/rack-live --patch "$TRACE_SESSION_DIR/live.ron" <<'EOF'
get clock_main bpm
get tm_main inversion_probability
status
quit
EOFThe fresh process should report clock_main.bpm=1000 and
tm_main.inversion_probability=0.375. status reports the rack sample; it
does not restore engine phase from the saved configuration. Use
python3 crates/oxurack/examples/rack_live/trace_regression.py after the
default example build for finite held, released, and closed-pipe pressure
checks. That harness records its prefill separately, restores blocking mode
before product spawn, and keeps a separate controlling-session host so the
rack process is not the session leader.
For an interactive no-device rehearsal, use one shell for both launches and keep it open while you work. Build the example, create a unique save directory, and print the concrete command to paste into the REPL:
cargo build --locked -p oxurack --example rack-live
REHEARSAL_DIR="$(mktemp -d)"
printf 'At the rack-live prompt, paste this save command:\nsave %s/session.ron\n' "$REHEARSAL_DIR"Launch the built example directly, with no native MIDI endpoint. The shell
redirects only stdout to a new regular text file for the sample-indexed MIDI
trace; stderr stays on the terminal so the interactive editor and its replies
remain visible. Do not combine the streams with 2>&1.
target/debug/examples/rack-live \
--patch crates/oxurack/examples/patches/turing-machine-midi.ron \
--trace-capacity 4096 \
>"$REHEARSAL_DIR/live-midi.txt"At the prompt, enter set clock_main bpm 1000, set clock_main ppqn 30,
set tm_main inversion_probability 0.5, watch on, and play. Run status
twice; the sample numbers should increase. Then type sta without pressing
Enter and wait for the watch redraw. The prompt should still show the incomplete
sta; append tus and press Enter. The resulting status should show
inversion_probability=0.5. Enter pause and
note add ("foundation witness", Some("Duncan")). Paste the exact save ...
command printed by the shell, then finish with quit.
After the first process exits, start a fresh process from the same shell and redirect its stdout to a different new trace file:
target/debug/examples/rack-live \
--patch "$REHEARSAL_DIR/session.ron" \
--trace-capacity 4096 \
>"$REHEARSAL_DIR/fresh-midi.txt"Run get clock_main bpm, get tm_main inversion_probability, note list,
and status, then quit. The saved BPM, q value, and authored note should be
present; the fresh rack starts at sample zero, so its MIDI trace file should be
empty. After both processes exit, wc -l "$REHEARSAL_DIR/live-midi.txt" "$REHEARSAL_DIR/fresh-midi.txt" shows the nonempty live trace and empty fresh
trace. The REPL does not expand shell variables, which is why you paste the
concrete save command printed before launch. Omit --output-name and
--output-index to keep this workflow free of a native MIDI endpoint. A
renderer-health expiry exits with status 1 after cleanup; do not interpret that
recovery as a successful graceful quit.
After a fresh default build, run
python3 crates/oxurack/examples/rack_live/shutdown_regression.py for the
combined shutdown fault and positive workflow receipts. Its PTY cases prefill
before spawn or use a separately opened slave descriptor, keep the foreground
host alive through actual rack reap, record editor and trace worker PIDs, and
verify the full terminal state while the rack parent remains live and again
after exit.
The LFO-modulated patch keeps the same seeded Turing Machine and MIDI route, with a slow sine LFO changing its write probability. Run its sample-indexed trace offline:
cargo run -p oxurack --example turing-machine-midi -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron --samples 10000Oxurack::parameter reads a registered getter from the active module instance;
set_parameter applies its normal validation and emits one DirectApi
ParameterChanged message when successful. snapshot_patch returns an owned
configuration made from the active modules' current getters, labels, cables and
loaded patch identity. save_patch snapshots first and then uses the existing
RON writer. These synchronous calls can run between samples while the rack is
stopped or running. They do not advance time, consume MIDI, clear Scope, or reset
module state. Getter and I/O failures are returned to the caller.
A saved patch is a seeded configuration, not an engine checkpoint: it preserves
the current parameter values and the loaded version, seed and global BPM, but
does not save LFO phase, Turing Machine registers/RNG position, pending MIDI or
Scope records. Loading it in a fresh rack starts the seed from its initial phase.
For the Turing Machine, versioned patches retain canonical
inversion_probability and write_mode; the engine publishes effective q
after finite held CVs. The legacy write_probability alias maps p to
q=(1-p)/2. Raw dac_byte and normalized main_cv readouts are available on
Scope. Saved patches are not engine checkpoints. Patches without the typed RON
entry "engine_version": String("mk2-v1") are rejected. In a parameter map,
use typed entries such as "write_mode": String("normal") and
"inversion_probability": Float(0.5). For manual migration, convert p to q,
choose a write mode, and remove p when q is present. Old position 7 maps to
physical position 6 (length 12); old position 8 maps to position 7 (length 16).
Old position 6 paired with old length 10 requires an explicit physical choice
of length 8/position 5 or length 12/position 6. When old length and position
conflict, preserve the old position and update its length. Parallel cables with
identical endpoints retain their loaded patch-vector order through propagation
and saving.
For a live rack without LFO modulation, select tm_main.register_bits, tm_main.dac_byte, and
tm_main.main_cv with the scope-port options, then use scope to inspect the
raw trace. Set canonical q with set tm_main inversion_probability 1, then
return to set tm_main inversion_probability 0.5 and read it with
get tm_main inversion_probability. write_mode can force a clocked zero or
one (force_zero or force_one) while retaining q. With an LFO cable
connected, this getter reports configured base q while the engine uses
effective q after held CV. Use watch on, save the patch, and load it in a
fresh rack to replay the saved configured q/mode; held modulation and engine
position are not performance checkpoints. Whole-rack/LFO replay additionally
requires the same locked build, dependencies and platform, plus version, seed,
finite ordered controls and clock-event sequence; it is not cross-host or
cross-dependency evidence.
Raw ECS edits to a cable's CableOrder must call
oxurack_core::mark_propagation_order_dirty before the next sample.
This example edits the running LFO fixture after 200 samples and saves at that
point; it then continues to sample 300:
cargo run --locked -p oxurack --example save-live-patch -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--save /tmp/oxurack-edited.ron
cargo run --locked -p oxurack --example turing-machine-midi -- \
--patch /tmp/oxurack-edited.ron --samples 10000 \
--scope-view --scope-port lfo_main.bipolar_outThe second command runs the saved configuration in a fresh process. Its LFO phase begins at zero while its frequency is 1 Hz.
The facade also exposes explicit whole-rack lifetime operations. reset()
appends channel-ordered Note Off, All Notes Off, and All Sound Off messages for
dirty MIDI sinks without advancing or reseeding the rack; call drain_midi()
to retrieve the complete batch. restart() snapshots the current live patch and
builds it into a fresh stopped App at sample zero. unload_patch() creates a
fresh empty stopped App. load_patch() also replaces the whole App. These three
lifetime replacements require the rack to be stopped and all pending MIDI to be
drained first; a running error takes precedence over pending output and file or
snapshot errors. Replacements replay installed module types in their original
order. They clear Scope and prior output faults on success, and retain the
selected simulation driver. Cleanup carried from the old lifetime keeps its old
sample index and must be drained before starting the new lifetime.
Run the save-live-patch example's lifecycle recipe to edit/save, unload, reload,
capture, and emit a replay trace on stdout:
cargo run --locked -p oxurack --example save-live-patch -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--save /tmp/oxurack-lifecycle.ron --lifecycleWithout --lifecycle, the example keeps its original 200-sample edit, save,
100-sample continuation, and summary output.
The adapter exposes the seven clock-masked Pulses stages as
pulse_stage_1 through pulse_stage_7, plus the specified digital
conjunction relationships 1&2, 2&4, 4&7, and 1&2&4&7. The public Rust
PulsesOutputs fields are and_1_2, and_2_4, and_4_7, and
and_1_2_4_7; rack ports and CSV columns retain their pulse_* names. These
relationships do not claim measured electrical equivalence. The older six
pulse_1 through pulse_6 outputs remain software adjacent-bit pairs. Five
saved Float parameters, volts_weight_1 through volts_weight_5, default to
1.0 and accept finite values in 0.0..=1.0. The normalized volts output is
sum(weight[i] * bit[i], i=0..4) / 5; it is a software model rather than an
electrical-voltage measurement. volts_note and volts_note_valid provide a
main-quantizer MIDI note convenience readout.
Run the shipped expander fixture for a bounded trace without opening a MIDI endpoint. Scope selectors name scalar ports exactly:
cargo run --locked -p oxurack --example turing-machine-midi -- \
--patch crates/oxurack/examples/patches/turing-machine-expanders.ron \
--samples 1000 --scope-view \
--scope-port tm_main.pulse_stage_1 \
--scope-port tm_main.pulse_stage_7 \
--scope-port tm_main.pulse_2_4 \
--scope-port tm_main.pulse_1_2_4_7 \
--scope-port tm_main.volts \
--scope-port tm_main.volts_note \
--scope-port tm_main.volts_note_validUse rack-live with the same eight Scope selectors and capacity for the full
edit/save/fresh-replay rehearsal. This launch selects no MIDI endpoint:
cargo run --locked -p oxurack --example rack-live -- \
--patch crates/oxurack/examples/patches/turing-machine-expanders.ron \
--scope-capacity 200000 \
--scope-port tm_main.register_bits \
--scope-port tm_main.inversion_probability \
--scope-port tm_main.pulse_2_4 --scope-port tm_main.pulse_stage_7 \
--scope-port tm_main.volts --scope-port tm_main.volts_note \
--scope-port tm_main.volts_note_valid --scope-port midi_main.gateUse fresh unused .ron and .csv paths for each rehearsal; scope-save
refuses to overwrite an existing file. Before launching the saved configuration,
confirm that both save and scope-save reported success.
Wait for the ready banner, then run status and confirm sample=0 running=false
before continuing. Do not paste every phase at once. After each phase's start
command, run status and record its sample=S value; poll status until it
reports sample at least S+N while running=true, where N is the phase's
sample threshold below. These are sample-clock barriers, not wall-clock delays.
With the LFO cable disconnected, the configured q getter is the effective q.
Turn on observation and establish normal mode at q=0, then start playback:
watch on
status
get tm_main engine_version
get tm_main volts_weight_1
unpatch lfo_main bipolar_out tm_main change_modulation 0
set tm_main write_mode normal
set tm_main inversion_probability 0
get tm_main inversion_probability
play
Wait at least 1200 samples from the status sample S after play. Then
exercise inverted and fair feedback in separate phases:
set tm_main inversion_probability 1
get tm_main inversion_probability
Wait at least 2200 samples from the status sample S after setting q=1. Then:
set tm_main inversion_probability 0.5
get tm_main inversion_probability
Wait at least 600 samples from the status sample S after setting q=0.5 before continuing. q=0 preserves feedback, q=1 inverts feedback, and q=0.5 makes a fair inversion decision.
Rewire the MIDI gate to the seventh sampled pulse, inspect it, then restore the original conjunction. Wait at each barrier before moving to the next route:
unpatch tm_main pulse_2_4 midi_main gate 0
patch tm_main pulse_stage_7 midi_main gate
Wait at least 600 samples from the status sample S after the patch, then run
scope and inspect register_bits, the target gate, pulse_stage_7, and the
sampled low intervals between pulse highs. Restore the original cable:
unpatch tm_main pulse_stage_7 midi_main gate 0
patch tm_main pulse_2_4 midi_main gate
Wait at least 200 samples from the status sample S after restoring it, then
run scope again. Exercise force-zero and force-one as distinct clocked
phases:
set tm_main write_mode force_zero
Wait at least 2200 samples from the status sample S after setting
force_zero, then run scope: the full register and raw Volts should be 0;
the main gate and expander gates should be false. Then:
set tm_main write_mode force_one
Wait at least 2200 samples from the status sample S after setting
force_one, then run scope. With the fixture's weights
[1, 0.5, 0.25, 0.75, 0], expect register_bits=65535, raw volts=0.5, and
volts_note=60. The sampled-clock pulse outputs still go low between high
samples even while their stored bits remain one.
Set weight 1 to zero, check its getter and the new raw Volts/note, then try an invalid value and confirm it leaves the getter at zero:
set tm_main volts_weight_1 0
get tm_main volts_weight_1
Wait at least 600 samples from the status sample S after setting the weight
to zero, then run scope: expect raw volts=0.3 and volts_note=55. Continue:
set tm_main volts_weight_1 1.1
get tm_main volts_weight_1
The invalid update must produce a rejection diagnostic, leave the getter at 0, and add neither a successful watch record nor an acknowledgement tick. Finish the played session by pausing, inspecting its sample/Scope state, and saving the configuration and trace:
pause
status
scope
save /tmp/turing-machine-expanders-correction02.ron
scope-save /tmp/turing-machine-expanders-correction02.csv
quit
Now launch a second rack-live process with that saved file and the same Scope
selection/capacity. It also selects no endpoint:
cargo run --locked -p oxurack --example rack-live -- \
--patch /tmp/turing-machine-expanders-correction02.ron \
--scope-capacity 200000 \
--scope-port tm_main.register_bits \
--scope-port tm_main.inversion_probability \
--scope-port tm_main.pulse_2_4 --scope-port tm_main.pulse_stage_7 \
--scope-port tm_main.volts --scope-port tm_main.volts_note \
--scope-port tm_main.volts_note_valid --scope-port midi_main.gateWait for the fresh ready banner and confirm sample=0 running=false. Verify the
saved marker, mode, weight and restored cable route: expect
engine_version=mk2-v1, write_mode=force_one, and volts_weight_1=0. Then
observe a fresh run:
watch on
status
get tm_main engine_version
get tm_main write_mode
get tm_main volts_weight_1
cables
play
Confirm cables includes tm_main.pulse_2_4 -> midi_main.gate. Immediately
run status to record S (zero at fresh start), then poll until at least
S+800 samples have completed while running=true. Inspect status and scope,
then finish the fresh process:
pause
status
scope
scope-save /tmp/turing-machine-expanders-correction02-fresh.csv
quit
The generated MIDI trace should begin with 376|90 37 4F; generated events
should be nonempty, sent MIDI should remain 0, and Scope should report zero
overwritten records. Arbitrary manual timing can change later row counts. The
fresh process starts at sample zero from the saved configuration and seed; it
does not resume the prior register, RNG position, phase, or pending MIDI. This
is a configured restart, not a checkpoint. rack-live pause sends panic
cleanup and drains active notes, while the facade's stop() alone preserves an
active voice. For a bounded noninteractive trace, use the finite runner:
cargo run --locked -p oxurack --example turing-machine-midi -- \
--patch /tmp/turing-machine-expanders-correction02.ron --samples 1000 \
--scope-view --scope-port tm_main.volts --scope-port tm_main.volts_noteOxurack::label reads the active rack's raw display name, description, tags,
or rack notes. set_label writes those fields on the rack, an active module,
or a cable selected by exact source and target module/port names. Empty module
or cable text and empty tag sets clear the stored component; the graph view then
uses the normal display-name fallback. Rack reads keep the actual empty string,
empty tag set, or empty note list. Build Tag values with Tag::new; invalid
tags return TagError before a write begins. Wrong payloads and notes requested
for a module or cable return CoreError::InvalidLabelValue. A missing active
module returns UnknownModule; a missing exact port returns the nested
PatchError::UnknownPort; and a missing cable occurrence returns
CoreError::UnknownCable with both endpoint tuples and the zero-based index.
Cable occurrence order is present CableOrder first by ordinal, then absent
orders, with Entity as the deterministic tie-break. Legacy notes remain
replaceable as a whole ordered list, preserving duplicates and empty strings;
identity-bearing notes use the notes, note_history, add_note, edit_note,
and delete_note facade APIs. Reads and writes are synchronous between
samples. Successful writes emit owned note receipts followed by one
LabelChanged; they do not tick the rack or change parameters, cables, cable
order, or MIDI output. The raw label APIs work with or without the
default graph feature; graph queries additionally show the updated labels
immediately when that feature is enabled.
Run the metadata example with the same seeded fixture:
cargo run --locked -p oxurack --example annotate-live-patch -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--save /tmp/oxurack-annotated.ronIt annotates the rack, Turing Machine, and clock cable after 200 samples, saves
the live configuration, then advances and drains another 100 samples. It prints
the raw values it wrote and completed_samples=300. The saved file preserves
configuration metadata; it does not checkpoint running module engines.
Live cable edits use Oxurack::patch and Oxurack::unpatch between samples.
They validate exact active module/port names and signal compatibility before
changing topology. Successful edits refresh the optional graph before returning
and take effect in propagation on the next sample. A disconnected input with no
contributors holds its previous value until another cable contributes. Saving
after a rewire stores cable configuration and labels; loading it in a fresh rack
restarts engine state.
This example replaces LFO modulation with a constant +0.25 cable after 200
samples, saves the edited configuration, then advances to sample 300:
cargo run --locked -p oxurack --example rewire-live-patch -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--save /tmp/oxurack-rewired.ron
cargo run --locked -p oxurack --example turing-machine-midi -- \
--patch /tmp/oxurack-rewired.ron --samples 10000 \
--scope-view --scope-port tm_main.change_modulationThe second command replays the saved configuration in a fresh process. It restarts the LFO and Turing Machine engines while retaining the edited cable, order, and description.
Typed Handle<M> values and bound port/knob references identify one exact
module instance in one committed rack lifetime. A handle becomes stale when its
module is removed or the rack successfully loads, restarts, or unloads; a failed
replacement keeps the existing handles valid. Tuple constructors preserve dots
inside names, while dotted reference parsing rejects ambiguous names. The
string-based parameter and cable methods remain available for current-name
lookups.
remove_module requires a stopped rack and drained MIDI. It preflights the
module hierarchy and incident cables, queues cleanup for that MIDI sink only,
then retires the module without resetting surviving engines. Drain any resulting
cleanup before advancing again. Scope is disabled if it selected the removed
module, and the optional graph is refreshed before the method returns.
CoreCommand can be executed synchronously with execute_command or submitted
to the bounded 64-slot FIFO with enqueue_command and process_commands.
Processing returns typed per-command outcomes in submission order; failed
commands do not stop later commands. A successful reset cancels only commands
still pending behind it. Completed and cancelled slots use queue capacity until
process_commands collects them. SetBpm changes patch metadata used by
snapshot/save/restart; it does not change the Clock module's independent bpm
parameter.
rack-session is an automated demonstration of edits, persistence and lifecycle
operations, rather than an interactive command prompt. Run it with the default
graph or without it:
OXURACK_SESSION_DIR=$(mktemp -d)
cargo run --locked -p oxurack --example rack-session -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--save "$OXURACK_SESSION_DIR/session.ron" --samples 2000 \
> "$OXURACK_SESSION_DIR/session.trace"
cargo run --locked -p oxurack --example turing-machine-midi -- \
--patch "$OXURACK_SESSION_DIR/session.ron" --samples 2000 \
> "$OXURACK_SESSION_DIR/fresh.trace"
cmp "$OXURACK_SESSION_DIR/session.trace" "$OXURACK_SESSION_DIR/fresh.trace"
cargo run --locked -p oxurack --no-default-features --example rack-session -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--save "$OXURACK_SESSION_DIR/no-graph.ron" --samples 2000 \
> "$OXURACK_SESSION_DIR/no-graph.trace"
cmp "$OXURACK_SESSION_DIR/session.trace" "$OXURACK_SESSION_DIR/no-graph.trace"The first run writes only its final lifetime's MIDI trace to stdout. Its
phase-labelled cleanup events, including final-cleanup and the independently
loaded reference-cleanup, go to stderr after playback; both racks are reset
and their cleanup vectors are compared. SetBpm changes saved patch metadata,
while the Clock module's own bpm parameter remains independently audible.
Capture selected scalar ports and the runner's generated MIDI events to a
bounded CSV file. Capacity counts mixed records; when full, the oldest rows are
discarded. Signal rows name the selected module and port. MIDI rows distinguish
events generated by the rack from events successfully sent by a live software
sender; sent does not mean a synthesizer rendered or recorded the event.
cargo run -p oxurack --example turing-machine-midi -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--samples 10000 --scope-csv /tmp/turing-machine-lfo.csv \
--scope-port clock_main.pulse \
--scope-port lfo_main.bipolar_out \
--scope-port tm_main.change_modulation \
--scope-port tm_main.note \
--scope-capacity 100000This run retains 40,000 signal records and 78 generated MIDI events (40,078
records total, plus the CSV header) when the seeded fixture produces its full
trace. The example prints capacity, retained rows, and overwritten rows to
stderr. Leaving out --scope-port captures MIDI only. Leaving out
--scope-capacity uses 100,000 records. clear_scope resets retained rows
and the overwrite count without resetting sample time; loading a patch
successfully disables the old selectors, so configure again for the new patch.
For a terminal view without creating a file, add --scope-view. The view is
printed to stderr after the finite run stops (and after live MIDI cleanup). It
shows the patch path, authoritative completed sample count, mixed-record
capacity, retained/overwritten counts, retained sample span, latest value for
each selected signal, and separate recent signal and MIDI tails. Use
--scope-rows to choose 1–1000 rows in each tail; the default is 12. The latest
per-selector section is additional to those tails.
cargo run -p oxurack --example turing-machine-midi -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--samples 10000 --scope-view \
--scope-port clock_main.pulse \
--scope-port lfo_main.bipolar_out \
--scope-port tm_main.change_modulation \
--scope-port tm_main.noteThe view and CSV can be requested together. The sample-indexed MIDI trace stays
on stdout, the human view goes to stderr, and the CSV keeps the same schema and
retained records as a CSV-only run. With the fixture above and capacity 100,000,
the retained view reports 10,000 completed samples, 40,078 records (40,000
signals and 78 generated events), and no overwritten records. The CSV has
40,079 lines including its header. --scope-view alone opens no file.
cargo run -p oxurack --example turing-machine-midi -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--samples 10000 --scope-view --scope-rows 12 \
--scope-csv /tmp/oxurack-workbench.csv --scope-capacity 100000 \
--scope-port clock_main.pulse \
--scope-port lfo_main.bipolar_out \
--scope-port tm_main.change_modulation \
--scope-port tm_main.note \
> /tmp/oxurack-workbench.traceThe two tails describe only the records still retained in the one mixed FIFO.
When capacity is reached, old records are evicted; a retained window can begin
mid-sample and can include a generated event without its sent counterpart.
Counts, span and latest values never reconstruct evicted history. A generated
MIDI row was drained from the rack by the runner. A sent row means the live
software sender accepted that event; neither label means a synthesizer rendered
or recorded audio. Signal values are sampled scalar/Gate/Raw snapshots; a held
MIDI-valued port is not an event history.
For example, selecting only lfo_main.bipolar_out with --scope-capacity 3
and --samples 10000 retains the last three signal records (samples 9997–9999)
and evicts the earlier 10,075 mixed records. The completed count remains 10,000
and the MIDI trace remains the same 78 rows. With --samples 0, selected ports
are shown as unobserved, both tails say (none retained), and CSV contains its
header only. These are retained-window observations, not a live redraw.
clear_scope clears captured records and the overwrite count only. It leaves
the rack's sample clock and module phases in place; stopping and starting also
continues the same rack. A new process loads the seeded patch into a fresh rack
and starts again from its initial phase and sample index. To clear and continue
an already configured rack:
rack.stop();
rack.clear_scope();
rack.start();
for _ in 0..100 {
if rack.advance_samples(1)? == 0 {
break;
}
let events: Vec<_> = rack.drain_midi().collect();
for event in events {
rack.record_scope_midi(event, oxurack::MidiDelivery::Generated);
}
}
rack.stop();
// Inspect/export the retained continuation. A fresh rack/process reseeds.This records generated events in Scope after each per-sample drain; live sending uses the existing runner, which owns its drain and records successful sends.
Scope options are validated before playback: --scope-port and
--scope-capacity require either --scope-csv or --scope-view, while
--scope-rows requires --scope-view. Scope options cannot be combined with
--list-outputs. A requested CSV file is opened before the rack starts or a
live output is connected, so selector/configuration/file failures prevent the
run.
The same capture flags work with live output selection. For example, replace the
sample-only command above with a current endpoint name selected from
--list-outputs:
cargo run -p oxurack --example turing-machine-midi -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--output-name "MIDI Bus 1" --samples 10000 \
--scope-csv /tmp/turing-machine-live.csv \
--scope-port lfo_main.bipolar_outCSV export happens after stop and live cleanup, including when playback fails; the primary playback error is retained if export also fails. Scope rows do not change the existing stdout performance trace.
For live playback, choose an exact endpoint name from the current host's list:
cargo run -p oxurack --example turing-machine-midi -- \
--patch crates/oxurack/examples/patches/turing-machine-lfo.ron \
--output-name "MIDI Bus 1" --samples 10000To list the MIDI outputs available to the current host:
cargo run -p oxurack --example turing-machine-midi -- --list-outputsChoose one listed output by its exact, unique name or its zero-based index to play the patch live. Refresh the listing before using an index because available indices can change between runs:
cargo run -p oxurack --example turing-machine-midi -- \
--patch crates/oxurack/examples/patches/turing-machine-midi.ron \
--output-index 0 --samples 2000Live playback also ends at the sample limit. Press Ctrl-C to stop after the current MIDI send and trace row; the example then attempts active-note offs, All Notes Off, and All Sound Off on channels it used. Cleanup is best effort: a disconnected endpoint or forced process termination can prevent delivery. Live samples are paced at 1 ms and do not catch up in bursts after a slow rack step or MIDI send. The sample-indexed trace records successfully sent patch messages only; it does not prove that a synthesizer rendered or recorded them.
oxurack is the Rust successor to underack, an LFE/Erlang project that explored the same design space. The modular-MIDI stance and the rough shape of the module catalog carry forward; the architecture is new, built on Bevy ECS as the runtime substrate.
MIT
