For Developers / Advanced Users

States And Connections

The basic principles of muting, monitoring, recording, playing back etc. are straightforward. However, in the case of a dry/wet combined looping setup, these principles can get confusing and/or impossible to implement perfectly, given that only a single FX processor exists.

For example, it is not possible to re-record the dry audio of multiple loops into their respective wet audio channels if they are sharing the same track (and thus the same FX processor). Their wet audio would be combined. Likewise, it is not possible to monitor the input while re-recording a loop.

Therefore some compromises have been made to select what is probably the most desirable wiring for each state of loop(s) and monitoring.

The following diagrams show the internal wiring of ShoopDaLoop’s ports and loops, including which signal paths are disabled / silent in different track/loop states.

Connections in different states

Connections in different states.

Lua scripting

Runtime and ownership

ShoopDaLoop embeds pinned omniLua with Lua 5.4 semantics. Native and browser builds run the same embedded host API libraries and externally packaged application scripts. Each script has an isolated state owned by the application runtime. Stopping or restarting a script removes its callbacks, timers, logical MIDI ports, connections, queued output, and script-owned dialogs.

Every script must make shoop_announce_api_version(major, minor) its first Shoop API call. The current version is 1.6. A script runs only when its major equals the host major and its minor is no newer than the host minor. Missing, malformed, repeated, or incompatible announcements cancel initial execution before versioned side effects. The global two-integer signature is independent of modules and reserved to remain stable across future API versions. See docs/lua_dialog_api.md for the compatibility and migration contract. Lua API compatibility is independent of the .shoop session format version. An incompatible source remains inspectable and exportable, but cannot be started.

The sandbox exposes selected standard-library functions and ShoopDaLoop modules. It prevents ordinary module access and restricts file access to normalized paths in each script’s filesystem or in-memory provider, but should still be treated as a compatibility boundary for trusted local scripts, not as a hardened security boundary.

Script management

Open Settings → Scripts to inspect lifecycle, errors, help, activity, logs, and MIDI diagnostics. Native builds can add, reload, and remove user script files. Browser builds fetch the generated external built-ins catalog and manage sources bundled in sessions, without machine path actions. Both targets can load a UTF-8 .lua file from the run-once picker or by OS drag and drop after confirmation. Run-once sources remain restartable in memory, are independent of session replacement and serialization, and disappear when the app closes. Loading a same-named version stops the active version and retains both entries under unique display names. Every listed script can be exported as its exact .lua source. A built-in, example, user, or run-once script can be included in the session; this transfers the current source plus supported Markdown/PNG companions to atomic session-bundle ownership. A session script can instead be converted to run once or removed from the session; converting it back reuses the in-memory bundle.

Built-ins are discovered recursively by normalized relative identity. New identities are disabled until explicitly enabled. The Scripts tab can rescan after additions, changes, removals, or a location change. Persistent changes apply after Save; runtime Stop, Restart, and Reload do not alter the settings draft. Bundled scripts in a .shoop session are resource/hash/syntax-checked before transactional session commit and round-trip without machine paths.

Browser builds target wasm32-unknown-unknown and run the same pure-Rust omniLua scripting manager cooperatively. Version checks, script-owned dialogs, keyboard callbacks, session scripts, and permission-gated Web MIDI control use the shared cross-target contracts.

MIDI rules

Scripts create logical input/output ports with full-name regular expressions. Discovery is hotplug-aware, queues are bounded, and positive output rates are paced without catch-up bursts. Native services use JACK or midir. Browser services use explicitly enabled Web MIDI. Per-rule endpoint and failure state is published to the Scripts tab.

Global APIs

shoop_announce_api_version(major, minor)

Mandatory first Shoop API call. Announces the non-negative integer major and minor version for which the script was designed.

print(msg), print_debug(msg), print_error(msg), print_info(msg)

Add a message at the corresponding level to the script log.

Built-in modules

shoop_dialog

Script-owned simple and paged dialogs. Contents are ordered portable rich text and labeled buttons, and buttons may retain script callbacks. Scripts may request opening at startup or from callbacks; users retain window visibility and current-page control. Dialogs are destroyed with their owning runtime. See docs/lua_dialog_api.md for constructors, style fields, examples, errors, and lifecycle behavior.

shoop_control

Synchronous queries and typed mutations for loops, tracks, global controls, callbacks, timers, and logical MIDI ports. Stable Key_*, KeyModifier_*, loop-mode, event-type, and sentinel constants are exposed for bundled and user scripts.

shoop_coords

  • shoop_coords.move(coords, direction_key) -> coords

    Take a single coordinates list and return coordinates if they were moved to the direction indicated by the given keyboard key.

  • shoop_coords.extreme(all_coords, direction_key, highest) -> coord

    Look for the highest (if highest == true) or lowest index (row/col) in the given direction

shoop_helpers

  • shoop_helpers.expand_selection(direction_key)

    Given a direction key, expand the current selection of loops by adding the loop(s) in the given direction.

  • shoop_helpers.shrink_selection(direction_key)

    Given a direction key, shrink the current selection of loops by removing loops “coming from” that direction.

  • shoop_helpers.move_selection(direction_key)

    Given a direction key, move the selection of loops to that direction if none of the loops would be out of bounds.

  • shoop_helpers.default_loop_action(loop_selector, dry)

    Perform the “default loop action” on a set of loop coordinates. The default loop action is designed to cycle intuitively from empty to recording/grabbing, playing and stopping. If “dry” is set to true, going to playback will go to playing dry through wet instead.

  • shoop_helpers.record_into_first_empty(overdub)

    In the track(s) of all selected loop(s) (or recording loop(s) of none selected), find the first empty loop and start recording into it. If overdub is true, already recording loops will transition to Playing. Otherwise, they will transition to Stopped.

  • shoop_helpers.create_click_hold_detector(timeout_ms, on_click, on_hold_start, on_hold_stop)

    Create an independent button click/hold detector. Call press() and release() on the returned value for button transitions. A release before timeout_ms calls on_click; otherwise on_hold_start is called at the timeout and on_hold_stop at release.

  • shoop_helpers.toggle_solo()

    Toggle the global “solo” control

  • shoop_helpers.toggle_sync_active()

    Toggle the global “sync active” control

  • shoop_helpers.toggle_play_after_record()

    Toggle the global “sync active” control

  • shoop_helpers.track_toggle_muted(index)

    Toggle the muted state of the given track. -1 is the sync track.

  • shoop_helpers.track_toggle_input_muted(selector, respect_auto_mute)

    Toggle input mutedness for the selected tracks as a group. -1 is the sync track. When respect_auto_mute is true, unmuting respects the global auto-mute control.

  • shoop_helpers.start_sampler(loops)

    Start “sampler mode” on the given loops. This just means to transition them to recording (if empty) or playing (if non-empty) immediately, without regard for sync with other loops. They will immediately exit the mode when stop_sampler() is called.

  • shoop_helpers.stop_sampler()

    Stop “sampler mode”. This means that any loop which was started with “start_sampler(…)” will immediately stop.

shoop_format

  • shoop_format.format_table(table, recursive) -> string

    Format a table such that all elements can be inspected.

Software design

Architecture

ShoopDaLoop is a Rust workspace with one application composition root: shoopdaloop.

shoop_egui

Presentation widgets consume immutable API snapshots and emit typed intents. It does not own audio drivers or session persistence.

shoop_app_api and shoop_app

Stable application values plus the actor/cooperative runtime that validates intents, owns model state, and publishes revisions.

shoop_backend and shoop_engine

Backend adaptation, native JACK/CPAL+midir/dummy drivers, graph scheduling, realtime audio/MIDI processing, processors, and bounded state publication. Native driver composition uses shoop_engine/app_backend; the browser uses a dedicated AudioWorklet protocol.

shoop_session and shoop_settings

Versioned session/media codecs, deterministic resampling, typed settings, native atomic storage, and browser storage values.

shoop_scripting

The omniLua runtime, control API, provider-backed resources, callbacks/timers, and logical MIDI service.

shoop_audio_protocol and shoop_audio_worklet

Bounded browser control/audio/MIDI messages and the realtime Web Audio engine.

Realtime ownership

Timing-authoritative state machines run in the engine. UI refreshes only observe published state. Topology and content replacements are prepared off the audio thread and committed through bounded callback-boundary operations. Realtime allocation and lock guards cover engine and AudioWorklet paths.

Carla hosting

Carla processors implement one frontend-independent processor contract. A pinned libcarla_native-plugin is loaded by absolute path and Rack/Patchbay descriptors are instantiated through CarlaNative.h; ShoopDaLoop does not host Carla through LV2 or another plugin wrapper. In-process hosting owns Carla on a non-realtime bridge thread. Subprocess mode gives each chain an authenticated worker generation and bounded shared-memory block transport. The same executable dispatches hidden worker mode before creating the GUI.

Build and packaging

Cargo builds the native workspace. Trunk builds the browser UI and dedicated AudioWorklet with matching profiles. The application artifact script emits unsigned native archives, a complete hosted web archive, and a core-only HTML file. Native archives include a manifest- and checksum-verified Carla runtime component with UI/discovery/bridge helpers, licenses, and corresponding-source metadata. Native and hosted-web archives include the external built-ins tree; the core-only HTML explicitly does not. Browser artifacts contain no Carla component.

Testing

The main gates are:

cargo fmt --all -- --check
RUSTFLAGS="-D warnings" cargo build --workspace
SHOOP_ALLOW_MISSING_BACKENDS=1 \
  cargo nextest run --workspace --features shoop_engine/app_backend --profile ci
python3 scripts/check_tracing_coverage.py --require-closed

Web verification additionally builds both Wasm packages, checks browser dependency isolation, verifies hosted/self-contained artifacts, and runs Chrome and Firefox workflows where available. The GitHub workflow is authoritative for Linux, Windows, macOS, and browser release surfaces.

Perfetto profiling and capture

ShoopDaLoop emits standard Perfetto .pftrace data through the private shoop_tracing facade on native, Window, Engine Worker, and AudioWorklet realms.

Modes

Disabled

Omit tracing options. Gated realtime helpers do not call a backend.

Coarse

Use --tracing or Settings > Developer. This includes GUI/application spans, engine control/graph work, and bounded callback/session categories.

Engine detail

Add --tracing-engine-detail for per-stage realtime records. This increases callback overhead and capture size.

Capture natively:

cargo run -p shoopdaloop -- \
  --tracing \
  --tracing-engine-detail

A normal Save or application shutdown atomically publishes a numbered .pftrace below traces/. Discard writes no file, and sequential captures are supported. Use the pinned scripts/trace_processor wrapper for queries.

Hosted Chromium exposes the same controls and downloads application-owned trace bytes. One capture combines Window with the active Engine Worker or AudioWorklet. Multirealm audio tracing transfers recyclable ArrayBuffer chunks and does not require cross-origin isolation. Unsupported browser APIs remain functional and report why tracing is unavailable.

Trace structure

frontend.egui.*

GUI initialization, updates, rendering, settings actions, and intent creation.

frontend.app.*

Intent dispatch/handling/application, backend advancement, snapshot application/publication, and runtime lifecycle. intent_id correlates submission with actor-side handling.

engine.control.* and engine.graph.*

Bounded commands, waits, topology construction, scheduling, and graph apply.

engine.rt.*

Driver/callback/cycle/session hierarchy. Detail mode adds fixed port, channel, composite, MIDI, routing, and processor stages.

worker.* and engine.plugin.*

Background application/graph/plugin work and native processor operations.

Counter tracks retain integer counts, identifiers, occupancy, and reason codes; fractional loads/ratios use floating counters. Structured logs preserve level, target, message, and typed fields as Perfetto arguments.

AudioWorklet timestamps are exact logical sample frames, not callback CPU entry/exit measurements. Browser realms rotate preallocated transferable ArrayBuffer chunks and the Window recycles each buffer after consumption, so capture duration is not limited by the producer pool size. The collector retains consumed trace data until save, with a 512 MiB safety quota per realm; quota, allocation, or storage failure aborts the capture rather than silently producing a complete-looking trace. Always inspect clock calibration, producer drops, discontinuities, and health data when interpreting a browser trace.

Tracing is diagnostic instrumentation, not a transparent realtime measurement. Start with coarse mode, compare equivalent workloads/modes, and use native CPU tracks for callback-duration analysis.

The repository Perfetto skill at .agents/skills/perfetto/SKILL.md documents capture, CI artifact, query, clock, and interpretation workflows.

Rust tracing coverage inventory

docs/tracing_coverage.csv accounts for every production Rust module in the retained workspace. Cargo integration tests and example/benchmark binaries are validation tools and are intentionally outside the production inventory.

Classifications

instrumented_direct

The module contains gated tracing at a meaningful runtime boundary.

instrumented_indirect

The module executes inside a named application, UI, engine, worklet, or persistence boundary; another span would duplicate the owning operation.

excluded

Build-time or logging-declaration code for which runtime instrumentation does not apply or would recurse.

planned_direct and planned_indirect are temporary classifications. The final check rejects them.

Validation

Run during source changes:

python3 scripts/check_tracing_coverage.py

The merge gate requires a closed inventory:

python3 scripts/check_tracing_coverage.py --require-closed

The verifier compares exact tracked Rust module paths, rejects duplicate or stale rows, requires a context and rationale, validates classifications, and in closed mode rejects planned rows. Instrumentation behavior is additionally covered by tracing gates, realtime allocation tests, native capture lifecycle tests, and manual capture/parser checks; inventory completeness alone is not a performance or realtime-safety proof.