Skip to main content

scenarios/

JSON-defined workflows that drive Songbird into a starting state for repeatable manual testing.
./utils/build-rs -s lead-synth                 # bare name, recursive search
./utils/build-rs --scenario workflows/lead-synth   # path-qualified
When -s NAME is passed, the Tauri app boots normally, waits for the React UI to signal app.ui_ready, then runs the scenario by calling sync engine commands one at a time — the same code path the UI uses. There’s no parallel “scenario API”. If a sync engine command exists, you can call it from a scenario. After project.new and project.open steps the runner also waits for the audio engine graph rebuild (spawned on the orchestrator’s session-rebuild worker thread) to complete before dispatching the next step. Without this, a follow-up plugin.add_to_track or transport.play could race the rebuilt graph and silently no-op. For all other steps the inter-step delay defaults to zero — the in-place track-add path now goes through the same apply_track_install helper as the full rebuild, so there’s no quietly-incomplete async work between steps to wait out. If you hit a new async path the architecture hasn’t accounted for, bump RunOptions::delay_between_steps (default Duration::ZERO) to a small non-zero value as a backstop.

Default project

Before the scenario’s first step, the runner auto-injects project.new unless the scenario already contains project.new or project.open. Net effect:
  • A scenario that just says “add a track” gets a clean slate. No need to include primitives/empty-project explicitly.
  • A fixture scenario that calls project.open: { path: "x.bird" } opts out automatically — the load won’t get clobbered.
primitives/empty-project.json is still around for the rare case where you want to be explicit, or want to project.new mid-scenario.

File format

{
  "description": "Lead synth + 1-bar loop rolling.",
  "include": ["primitives/empty-project", "primitives/add-midi-track"],
  "steps": [
    { "plugin.add_to_track": { "trackId": "${lead.trackId}", "pluginId": "songbird4OSC", "slotType": "instrument" } },
    { "transport.set_looping": { "enabled": true } },
    { "transport.play": {} }
  ]
}
Three pieces of syntax — that’s the entire DSL:
  • include: — list of other scenario names (bare or path-qualified). Resolved depth-first, deduplicated by file path. Steps from includes run before the local steps.
  • as: + ${name.field} — save a step’s return value under a name, then reference fields of that value in later steps. Only whole-string refs are supported ("${lead.trackId}", not "track-${lead.trackId}").
  • steps: — ordered list of { "channel.action": { …params }, "as"?: …, "note"?: … }. Exactly one channel.action key per step. note is for human/LLM comments — ignored at runtime.

Folder layout

FolderContents
primitives/Smallest reusable bricks (1–3 steps). Add a track, add a plugin, set a loop.
workflows/Composed scenarios — usually a stack of include:s, sometimes with a few extra steps.
fixtures/”Load this .bird file” scenarios (project state is the fixture, not commands).
tests/Throw-away or test-specific scenarios kept around for repro / e2e.
Names must be globally unique across folders (the recursive lookup errors on ambiguity). Folders are organizational only.

Authoring tips

  • Reuse > inline. A new scenario is usually include: two or three existing ones plus one or two local steps. If you write the same steps twice, extract them to a primitive.
  • Param names are camelCase on the wire even though Rust uses snake_case (startBeat, not start_beat; trackId, not track_id).
  • Save names are scenario-global. Once a step saves as: "lead", every later step in the run can use ${lead.…} — including steps from included scenarios that run afterward. Pick distinct names if you compose two scenarios that both want to save “track”.
  • No conditionals, no loops. Compose with include: instead. If a scenario needs branching, it’s actually two scenarios.

Discovering commands

Every sync engine command is callable from a scenario. The full list with param schemas is introspectable via the MCP list_sync_commands tool, or by reading rust/crates/data/songbird-sync/src/channels/*/defs.rs. A few useful starting points:
  • project.new — fresh project
  • project.open { path } — load a .bird fixture
  • song.add_midi_track { name? } → returns { trackId, trackIndex, name, type }
  • song.add_audio_track { name? }
  • plugin.add_to_track { trackId, pluginId, slotType? } — built-ins: songbird4OSC, songbirdSTK, songbird303, songbirdWavetable, songbirdSampler
  • clip.add_midi_note { trackId, note, velocity, startBeat, durationBeats }
  • transport.set_loop_range { startBar, endBar } (bars, not beats)
  • transport.set_looping { enabled }
  • transport.play, transport.stop, transport.pause

What scenarios can NOT do (yet)

  • Assertions. Today the runner just dispatches steps and logs the result. “Did the workflow actually work?” is human-driven for now.
  • Timing / waits. No wait: 2bars step type. Playback starts when transport.play runs.
  • Recording. No “record my manual session to a scenario file” toggle.
See handoffs/future/2026-05-13-scenario-system.md for the planned follow-on work.