Transport - Timing & Clocks
Comprehensive timing infrastructure connecting clock sources to the sequencer. The transport system manages musical playback lifecycle, timing synchronization, and position control.
Architecture:
Clock --ticks--> Transport --tick()--> Sequencer --events--> Music
The system provides precise timing control with support for internal timers, MIDI clock synchronization, and manual control for testing and integration.
When is this the answer
The transport is what turns a schedule into something that happens. You need one
whenever the music has to run in time -- and you do not need one to compute,
render or export, where sequencer.run walks the whole schedule as fast as it
can.
The decision is which clock, and it comes down to who owns the tempo:
| The tempo comes from | This |
|---|---|
| musa-dsl itself | TimerClock -- master; needs an explicit clock.start |
| a DAW, over MIDI | InputMidiClock -- slave; follows start, stop and position |
| your own code, tick by tick | ExternalTickClock -- you call tick |
| nowhere: run as fast as possible | DummyClock, for tests and offline rendering |
Master or slave is not a preference, it is a fact about the setup. If a DAW is recording, it owns the tempo and musa-dsl follows; if musa-dsl is driving the DAW, the reverse. Choosing the wrong one shows up as drift that no amount of correction fixes.
All of them are grids. A clock emits a pulse every so often, which is a MIDI inheritance -- 24 ppqn -- and it is why irregular structures have to be written against a regular pulse. A clock that waits the exact time between one event and the next, with no grid at all, is issue #91.
Three of the callbacks are the lifecycle, and they are not interchangeable
(a fourth, on_change_position, reports seeks and is not part of it).
before_begin
runs before the first tick, with the sequencer already built -- it is where to
schedule what has to exist before time starts. on_start runs when the clock
actually starts. after_stop runs when it stops, however it stopped.
Clock - Timing Sources
Clock is the abstract base class for timing sources. All clocks generate regular ticks that drive the sequencer forward. Multiple clock implementations are available for different use cases.
Clock Activation Models
Clocks use two different activation models:
Automatic Activation (DummyClock):
- Begins generating ticks immediately when
transport.startis called - No external activation required
- Appropriate for testing, batch processing, simulations
External Activation (TimerClock, InputMidiClock):
- Requires external signal/control to begin generating ticks
transport.startblocks waiting for activation- Appropriate for live coding, DAW sync, external control
Available Clock Types
DummyClock - Simplified clock for testing (automatic activation):
- Fast playback without real-time constraints
- Immediately begins generating ticks
- Useful for test suites or batch generation
- No external dependencies
TimerClock - Internal high-precision timer-based clock (external activation):
- Standalone compositions with internal timing
- Requires calling
clock.start()from another thread - Configurable BPM (tempo) and ticks per beat
- Can dynamically change tempo during playback
- Appropriate for live coding clients
InputMidiClock - Synchronized to external MIDI Clock messages (external activation):
- DAW-synchronized playback
- Waits for MIDI "Start" (0xFA) message to begin ticks
- Automatically follows external MIDI Clock Start/Stop/Continue
- Locked to external timing source
ExternalTickClock - Manually triggered ticks (external activation):
- Testing and debugging with precise control
- Integration with external systems (game engines, etc.)
- Call
clock.tick()manually to generate each tick - Frame-by-frame control
require 'musa-dsl'
# TimerClock - Internal timer-based timing
timer_clock = Musa::Clock::TimerClock.new(
bpm: 120, # Beats per minute
ticks_per_beat: 24 # Resolution
)
# InputMidiClock - Synchronized to external MIDI Clock
require 'midi-communications'
midi_input = MIDICommunications::Input.gets # Select MIDI input
midi_clock = Musa::Clock::InputMidiClock.new(midi_input)
# ExternalTickClock - Manual tick control
external_clock = Musa::Clock::ExternalTickClock.new
# DummyClock - For testing (100 ticks)
dummy_clock = Musa::Clock::DummyClock.new(100)
Transport - Playback Lifecycle Manager
Transport connects a clock to a sequencer and manages the playback lifecycle. It provides methods for starting/stopping playback, seeking to different positions, and registering callbacks for lifecycle events.
Lifecycle phases:
- before_begin - Runs before each start, including the one prepared by a stop (initialization)
- on_start - Run each time transport starts
- Running - Clock generates ticks → sequencer processes events
- on_change_position - Run when position jumps/seeks
- after_stop - Run when transport stops
Clean Shutdown
transport.stop triggers the complete lifecycle shutdown sequence, consistently across all clock types:
transport.stopcallsclock.terminateclock.terminatecallsclock.stop(fireson_stopcallbacks)- Transport's
on_stophandler executesafter_stopcallbacks - Sequencer is reset
before_begincallbacks run (preparing for potential restart)- Clock's run loop exits
transport.startreturns
Clock stop vs terminate contract
stop: Fireson_stopcallbacks. Idempotent (second call is a no-op). All clocks implement it.terminate: Callsstopfirst (guarantees callbacks), then exits the run loop. All clocks implement it.
Note: For InputMidiClock, MIDI Stop messages from the DAW also trigger clock.stop (and thus on_stop callbacks). To fully exit the run loop, call clock.terminate or transport.stop.
Key methods:
start- Start playback (blocks while running)stop- Stop playbackchange_position_to(bars: n)- Seek to position (in bars)
require 'musa-dsl'
# Create clock
clock = Musa::Clock::TimerClock.new(bpm: 120, ticks_per_beat: 24)
# Create transport
transport = Musa::Transport::Transport.new(
clock,
4, # beats_per_bar (time signature numerator)
24 # ticks_per_beat (resolution)
)
# Access sequencer through transport
sequencer = transport.sequencer
# Schedule events
sequencer.at 1 do
puts "Starting at bar 1!"
end
sequencer.at 4 do
puts "Reached bar 4"
transport.stop
end
# Register lifecycle callbacks
transport.before_begin do
puts "Initializing (runs once)..."
end
transport.on_start do
puts "Transport started!"
end
transport.after_stop do
puts "Transport stopped, cleaning up..."
end
# IMPORTANT: TimerClock requires external activation
# Start transport in background thread (it will block waiting)
thread = Thread.new { transport.start }
sleep 0.1 # Let transport initialize
# Activate clock from external control (e.g., live coding client)
clock.start # NOW ticks begin generating
# Wait for completion
thread.join
# Seeking example (in separate context)
# transport.change_position_to(bars: 2) # Jump to bar 2
Complete example with MIDI Clock synchronization:
require 'musa-dsl'
require 'midi-communications'
# Setup MIDI-synchronized clock
midi_input = MIDICommunications::Input.gets
clock = Musa::Clock::InputMidiClock.new(midi_input)
# Create transport
transport = Musa::Transport::Transport.new(clock, 4, 24)
# Schedule events
transport.sequencer.at 1 do
puts "Synchronized start at bar 1!"
end
# Start and wait for MIDI Clock Start message
transport.start
The lifecycle, run
require 'musa-dsl'
include Musa::All
events = []
transport = Transport.new(Musa::Clock::DummyClock.new(200), 4, 24)
transport.before_begin { events << :before_begin }
transport.on_start { events << :on_start }
transport.after_stop { events << :after_stop }
transport.sequencer.at(1) { events << :bar_1 }
transport.sequencer.at(2) { events << :bar_2 }
transport.start
events
# => [:before_begin, :on_start, :bar_1, :bar_2, :after_stop, :before_begin]
The numbered sequence above, run. Read the last entry: before_begin appears a
second time. Stopping prepares the transport for the next start, so a
before_begin that allocates -- opens a MIDI port, builds voices -- has to
expect to run more than once, and after_stop is where the matching release
belongs.
Note also how the callbacks were registered: one method call each, after
construction. Transport.new(clock) { |t| t.before_begin { ... } } is accepted
by Ruby, ignored by the constructor, and registers nothing. The constructor's own
form is keywords: Transport.new(clock, 4, 24, before_begin: -> { ... }).
And on_change_position fires only on a seek. A piece that runs from start to
finish never triggers it:
positions = []
transport = Transport.new(Musa::Clock::DummyClock.new(200), 4, 24)
transport.on_change_position { |p| positions << p }
transport.sequencer.at(1) { }
transport.start
positions # => []
API Reference
Complete API documentation:
- Musa::Transport - Playback lifecycle management
- Musa::Clock - Timing sources and clock implementations
Source code: lib/transport/