Skip to content
oxurPublic

About

Rust crates built with midir, inspired by Eurorack modules for use with hw/sw MIDI devices

Resources

Stars

0 stars

Watchers

1 watching

Forks

Latest commit

 

History

141 Commits

Folders and files

Repository files navigation

oxurack

Crates.io Docs.rs MIT / Apache-2.0

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.

Why

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.

Where we are heading

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.

Shape of the project

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 optional rack feature 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-live provides 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.

Current state

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

Quick start

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 2000

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

Playback 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 -- --help

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

Usage guide

Run a Turing Machine MIDI patch

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 2000

Each output row is sample|STATUS DATA1 DATA2, with uppercase hexadecimal bytes. The example drains each completed sample before advancing the rack.

Run and edit a live 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 100000

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

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

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

Edit and save a running patch

Oxurack::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_out

The second command runs the saved configuration in a fresh process. Its LFO phase begins at zero while its frequency is 1 Hz.

Reset, restart and replace a rack

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

Without --lifecycle, the example keeps its original 200-sample edit, save, 100-sample continuation, and summary output.

Turing Machine Pulses and Volts readouts

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_valid

Corrected-instrument rehearsal

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

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

Wait 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_note

Edit patch metadata

Oxurack::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.ron

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

Rewire a running patch

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_modulation

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

Use instance handles and commands

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.

Run the scripted rack session

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 and inspect Scope

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 100000

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

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

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

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

Play through a MIDI output

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 10000

To list the MIDI outputs available to the current host:

cargo run -p oxurack --example turing-machine-midi -- --list-outputs

Choose 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 2000

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

Lineage

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.

License

MIT

About

Rust crates built with midir, inspired by Eurorack modules for use with hw/sw MIDI devices

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages