Clip transport#

Engine-driven, beat-timed MIDI playback. A ClipTransport loops a flat list of ClipEvent against a DomainClock, dispatched from the audio thread — so the Dart side never has to time a noteOn / noteOff itself. The event list is replaceable while playing (the engine swaps it at the next audio-block boundary), and playback routes to one or more MidiOut sinks.

A clip and its clock may be disposed in either order: a clip whose clock is gone simply stops firing. Stopping or disposing a clip releases every note it still has sounding.

Upstream: YSE::clip.

ClipTransport#

class ClipTransport implements Finalizable

Engine-driven MIDI clip playback (issue #21).

A clip loops a flat, beat-timed [ClipEvent] list against a bound [DomainClock], dispatched from the audio thread. Because the engine owns when every event fires, playback does not jitter under UI-isolate load the way a timer-driven noteOn/noteOff over FFI does.

Wire a clip to one or more sinks — external MIDI-out ports via [connectMidiOut] (and the [ClipTransport.new] midiOut shortcut). Route the playback with [setEvents] (replaceable while playing; the engine swaps the list at the next audio-block boundary and still delivers note-offs for notes sounding across the swap), then [play] / [stop].

Threading (CLAUDE.md): construct and drive this from the [System] isolate. The audio thread performs the actual dispatch; MIDI sends run on a dedicated sender thread, so the audio callback never touches the device.

Route a clip to an internal [Synth] instead of (or alongside) external MIDI-out ports via [connectSynth] — the clip drives the synth’s noteOn / noteOff from the audio thread.

Constructors#

factory ClipTransport(DomainClock clock, {MidiOut? midiOut})

Create a clip bound to [clock], optionally connecting [midiOut] as a sink in one step.

Throws [YseException] if the native clip cannot be created or if no live clock owns clock.name.

Properties#

bool get isPlaying

Whether the clip is currently playing.

Methods#

void bind(DomainClock clock)

Bind (or re-bind) the clip to a live domain [clock] by name.

Binding shares the clock’s lifetime, so the clip and its clock may be disposed in either order: disposing the bound clock first stops it advancing, and the clip simply stops firing.

Throws [YseException] if no live clock owns clock.name — which also unbinds the clip from any clock it was bound to.

void connectMidiOut(MidiOut out)

Route this clip’s playback to an external MIDI output port. [out] must already have opened a port. May be called for several ports.

The clip holds a Dart reference to [out] until [disconnectMidiOut] or [dispose], so garbage collection never frees the port under it.

void disconnectMidiOut(MidiOut out)

Stop routing this clip to [out].

void connectSynth(Synth synth)

Route this clip’s playback to an internal [synth]. The engine drives the synth’s note events from the audio thread. [synth] must outlive the clip (or be disconnected first). May be called for several synths.

The clip holds a Dart reference to [synth] until [disconnectSynth] or [dispose], so garbage collection never frees the synth under it; don’t .dispose() the synth while it is connected.

void disconnectSynth(Synth synth)

Stop routing this clip to [synth].

void setEvents(List<ClipEvent> events, {required double loopBeats})

Replace the note-event list and set the loop length in one call.

The swap is safe while [isPlaying]: the engine applies the new list at the next audio-block boundary and still delivers note-offs for notes sounding across the swap. [loopBeats] is the loop length in beats; a value <= 0 disables looping (the events fire once). Passing an empty [events] list clears the clip.

void play()

Start (or resume) playback.

void stop()

Stop playback.

On stop the engine flushes note-offs for exactly the notes this transport still has sounding — its own (channel, pitch) pairs — to every connected sink (synths and MIDI-out ports). This is the per-transport equivalent of the note-off delivery [setEvents] guarantees across a swap: no note this transport started is left hanging.

The release is scoped to this transport — it is not a global all-notes-off. On a [MidiOut] shared with other concurrent clips, stopping one clip never silences notes the others started, even when they share a MIDI channel. A consumer routing several clips through one port therefore does not need its own allNotesOff safety net on stop.

void dispose()

Destroy the underlying native clip and detach the finalizer.

Releases every note the clip still has sounding, exactly as [stop] does, so disposing a playing clip leaves no hanging notes on its sinks. Safe in any order relative to the bound [DomainClock], and a clip handle stays valid across System.close() — it may be disposed after the engine closes.

ClipEvent#

class ClipEvent

One timed note event on a [ClipTransport].

Positioned in beats on the clip’s bound [DomainClock]. The engine fires the note-on at [startBeat] and the matching note-off [durationBeats] later, all from the audio thread — the Dart side never dispatches a note.

Constructors#

ClipEvent({required this.startBeat, required this.durationBeats, required this.channel, required this.pitch, this.velocity = 1.0, this.pitchBend = 0.0})

Construct a note event. [velocity] defaults to full, [pitchBend] to none.

Properties#

final double startBeat

Beat within the loop at which the note starts (>= 0).

final double durationBeats

Note length in beats.

final int channel

MIDI channel in the musical layer’s 1-based numbering, 1..16 — the same base as [Synth]. A [MidiOut] sink receives it on the wire as channel - 1, so this is not the 0-based channel MidiOut.noteOn takes.

final int pitch

MIDI note number, 0..127.

final double velocity

Velocity, normalized to [0, 1].

final double pitchBend

Optional per-note pitch bend in [-1, 1] for microtonal voicing; 0 (the default) applies no bend.