Running Scripts

A Rhai script can add nodes, wire ports, set properties, drive the transport, and toggle UI state - a broad subset of what the GUI does, but deterministic and repeatable.

The full API reference is at the scripting guide. This page covers how to run scripts day-to-day.

Opening the Console

Aether's console panel hosts a Rhai REPL. Toggle it from the command palette (press Space Space, type "Console") or through the view menu.

Type a line, press Return - it evaluates immediately. Results print below. Errors point at the offending line.

Minimum Working Script

let osc = Oscillator.add("lead");
let sink = AudioSink.first();
osc.output("Audio Out").port().connect(sink.input("Audio In").port());

Paste this into the console on a blank canvas, press Return. An oscillator at 440 Hz (its default frequency) is wired to the existing default AudioSink and the tone plays the moment the connection lands - Oscillator is free-running, so no play() is required to hear it.

Why AudioSink.first() and not AudioSink.add(...)? The engine spawns a default AudioSink on startup and routes audio from that specific sink to the speakers. Adding a second AudioSink via Type.add(label) creates a node that receives audio but isn't the one the audio thread reads from - you'd hear nothing. Type.first() returns the existing default sink, so the connection lands on the right node.

If you add transport-driven nodes (Sequencer, Arranger) to the chain later, follow up with a play() call so the transport actually advances.

Note the pattern: Type.add("label") creates a new node (or returns the existing one if a node with that label already exists - idempotent by label). Type.first() returns the first existing node of that type without creating anything. .output("...") / .input("...") resolves a port handle by display name. .connect() joins two port handles.

Running a Script File

To run a .rhai file rather than individual console lines, use include with a URI:

include("external://scripts/demos/lofi-chill");
include("script://my_project_script");

Two URI forms:

  • external://<key>/<relative-path> - loads from a registered filesystem path key. scripts is pre-registered when Aether runs from the .app bundle (it points at Contents/Resources/scripts/, which holds the bundled tutorial, features, and demos). Additional path keys can be registered from the command line with --path <key>=<dir> when launching from source. The .rhai extension is optional - it's added automatically if missing.
  • script://<name> - loads a script stored inside the currently open project's database.

A bare filename like include("file.rhai") errors - the URI scheme is required.

Bundled Scripts

Contents/Resources/scripts/ inside the .app ships with three groups:

  • tutorial/ - lesson progression. The 0.1.0 demo bundles the first couple of lessons (01-hello-sound.rhai, 02-properties.rhai); more land over time.
  • features/ - targeted demos of specific node patterns (arranger gate, gated sequencer, help system, region fade helpers, track-named ports).
  • demos/ - full musical patches (lofi chill, gamelan, drum kit, grain cloud, harmonic pad, showcase).

There's no in-editor file browser for bundled scripts in 0.1.0 - run one with include("external://scripts/<group>/<name>") from the console, or read the files at the filesystem path above to copy-paste. The Project Explorer's Script resource type lists scripts stored inside the current project's database, not the bundled filesystem scripts.

Saving a Patch as a Script

export_graph() serialises the current graph to a Rhai script and returns it as a string. Save it yourself:

let source = export_graph();
// Copy from the console output, or write it to a file via the editor.

The returned string contains explicit Type.add, .connect, and .set calls that rebuild the graph verbatim when re-run. For long-term storage, saving a patch as a script is more durable than the .aether binary format - see Working with Projects.

Common Traps

  • Case-sensitive node types. Oscillator.add(...) is right; oscillator.add(...) fails (Rhai is case-sensitive for identifiers).
  • Lenient port name lookup, but still be specific. Port lookup in .output("...") / .input("...") tries exact match, case-insensitive match, underscores-as-spaces, then substring - so "Audio Out", "audio_out", and "audio" all resolve to the Audio Out port. A typo that doesn't partial-match any port name fails with "Output port 'X' not found". Check the node reference for the canonical names.
  • Continuous vs gated sources. Oscillator, LFO, Noise, DroneOscillator, ShepardTone run continuously - connect and they play. The gated synths need their triggers wired correctly: Polysynth has a MIDI In port and takes any MIDI stream; DrumVoice has both a required Gate In and an optional MIDI In (either fires the drum); SamplePlayer has only a required Gate In - to drive it from a MIDI source you need a MIDI → CV node (the MidiToCv type) in between to convert MIDI note-on events into gate pulses.
  • The transport. play() starts, stop() stops. Many nodes respect the transport state; some (free-running oscillators) do not. If a graph is silent, check the transport.

Headless Running

Headless execution is available in the source tree (cargo run -p aether-headless -- --script path.rhai --fast) but the headless binary does not ship in the .app bundle. For end users, scripts run from inside the editor via include or from the console REPL.