Class: Musa::Transport::Transport

Inherits:
Object
  • Object
show all
Defined in:
lib/musa-dsl/transport/transport.rb

Overview

Main transport class connecting clocks to sequencers with lifecycle management.

Transport acts as the bridge between a clock (timing source) and a sequencer (event scheduler). It manages the playback lifecycle, including initialization, start/stop, and position changes, with support for callbacks at each stage.

Architecture

Clock --ticks--> Transport --tick()--> Sequencer --events--> Music

Lifecycle Phases

  1. before_begin: Run once before first start (initialization)
  2. on_start: Run each time transport starts
  3. Running: Clock generates ticks → sequencer processes events
  4. on_position_change: Run when position jumps/seeks
  5. after_stop: Run when transport stops

Position Management

Transport handles three position formats:

  • bars: Musical position in bars (Rational)
  • beats: Position in beats
  • midi_beats: Position in MIDI beats (for MIDI Clock sync)

Use Cases

  • Standalone compositions with internal timing
  • DAW-synchronized playback via MIDI Clock
  • Testing with dummy/external clocks
  • Live coding with dynamic tempo changes

Examples:

Basic setup

# A DummyClock so this runs; a piece that is meant to be heard puts a
# TimerClock or an InputMidiClock here and changes nothing else.
clock = Musa::Clock::DummyClock.new(5 * 96)
# beats_per_bar and ticks_per_beat are positional, not keywords.
transport = Musa::Transport::Transport.new(clock, 4, 24)

# Schedule events. Bars are numbered from 1, and `at 0` is before the
# sequencer's starting position, so it would silently never fire. The
# parentheses are required: `at 1 { ... }` is a Ruby syntax error.
log = []
transport.sequencer.at(1) { log << "Start!" }
transport.sequencer.at(4) { log << "Bar 4" }

transport.start

log  # => ["Start!", "Bar 4"]

With lifecycle callbacks

events = []

transport = Transport.new(Musa::Clock::DummyClock.new(200), 4, 24)
transport.before_begin { events << :before_begin }
transport.on_start     { |s| events << [:on_start, s.position] }
transport.after_stop   { events << :after_stop }

transport.sequencer.at(1) { events << :bar_1 }
transport.start

events
# => [:before_begin, [:on_start, (95/96)], :bar_1, :after_stop, :before_begin]

# Two things to read there. `on_start` sees 95/96 -- one tick before bar
# 1, the sequencer's own starting position. And `before_begin` appears
# AGAIN at the end: stopping prepares the transport for the next start,
# so a before_begin that allocates has to expect to run more than once.

The same, as constructor keywords

# NOT as a block: `Transport.new(clock) { |t| t.before_begin { ... } }`
# is accepted by Ruby, ignored by the constructor, and registers nothing.
events = []

transport = Transport.new(Musa::Clock::DummyClock.new(200), 4, 24,
                          before_begin: -> { events << :before_begin },
                          on_start: ->(s) { events << [:on_start, s.position] },
                          after_stop: -> { events << :after_stop })
transport.sequencer.at(1) { events << :bar_1 }
transport.start

events
# => [:before_begin, [:on_start, (95/96)], :bar_1, :after_stop, :before_begin]

MIDI Clock synchronization

# midi_input = MIDICommunications::Input.all.first
# clock = Musa::Clock::InputMidiClock.new(midi_input)
# transport = Transport.new(clock)
# transport.start  # waits for MIDI Clock Start

See Also:

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(clock, beats_per_bar = nil, ticks_per_beat = nil, offset: nil, sequencer: nil, before_begin: nil, on_start: nil, after_stop: nil, on_position_change: nil, logger: nil, do_log: nil) ⇒ Transport

Creates a new transport connecting a clock to a sequencer.

Examples:

With callback parameters

Transport.new(clock, 4, 24,
  on_start: -> (seq) { puts "Started at #{seq.position}" }
)

With callback methods

transport = Transport.new(clock, 4, 24)
transport.before_begin { setup_instruments }
transport.on_start { start_recording }
transport.after_stop { save_recording }

Parameters:

  • clock (Clock)

    timing source (TimerClock, InputMidiClock, etc.)

  • beats_per_bar (Integer, nil) (defaults to: nil)

    time signature numerator (default: 4)

  • ticks_per_beat (Integer, nil) (defaults to: nil)

    timing resolution (default: 24)

  • offset (Rational, nil) (defaults to: nil)

    time offset in bars (default: 0)

  • sequencer (Sequencer, nil) (defaults to: nil)

    existing sequencer (creates new if nil)

  • before_begin (Proc, nil) (defaults to: nil)

    callback run once before first start

  • on_start (Proc, nil) (defaults to: nil)

    callback run each time transport starts

  • after_stop (Proc, nil) (defaults to: nil)

    callback run when transport stops

  • on_position_change (Proc, nil) (defaults to: nil)

    callback for position changes

  • logger (Logger, nil) (defaults to: nil)

    logger instance

  • do_log (Boolean, nil) (defaults to: nil)

    enable logging



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/musa-dsl/transport/transport.rb', line 141

def initialize(clock,
               beats_per_bar = nil,
               ticks_per_beat = nil,
               offset: nil,
               sequencer: nil,
               before_begin: nil,
               on_start: nil,
               after_stop: nil,
               on_position_change: nil,
               logger: nil,
               do_log: nil)

  beats_per_bar ||= 4
  ticks_per_beat ||= 24
  offset ||= 0r

  do_log ||= false

  @clock = clock

  @before_begin = []
  @before_begin << Musa::Extension::SmartProcBinder::SmartProcBinder.new(before_begin) if before_begin

  @on_start = []
  @on_start << Musa::Extension::SmartProcBinder::SmartProcBinder.new(on_start) if on_start

  @on_change_position = []
  @on_change_position << Musa::Extension::SmartProcBinder::SmartProcBinder.new(on_position_change) if on_position_change

  @after_stop = []
  @after_stop << Musa::Extension::SmartProcBinder::SmartProcBinder.new(after_stop) if after_stop

  @do_log = do_log

  @sequencer = sequencer
  @sequencer ||= Musa::Sequencer::Sequencer.new beats_per_bar, ticks_per_beat, offset: offset, logger: logger, do_log: @do_log

  @clock.on_start do
    do_on_start
  end

  @clock.on_stop do
    do_stop
  end

  @clock.on_change_position do |bars: nil, beats: nil, midi_beats: nil|
    change_position_to bars: bars, beats: beats, midi_beats: midi_beats
  end

  # TODO: Consider adding block/DSL support for cleaner initialization syntax.
  #
  # Future enhancement could yield a DSL context that provides direct access to
  # transport methods without requiring the transport variable. This would enable:
  #
  #   Transport.new(clock, 4, 24) do
  #     before_begin { setup_instruments }
  #     on_start { start_recording }
  #     after_stop { save_recording }
  #   end
  #
  # Instead of current approach:
  #
  #   transport = Transport.new(clock, 4, 24)
  #   transport.before_begin { setup_instruments }
  #   transport.on_start { start_recording }
  #   transport.after_stop { save_recording }
  #
  # Implementation would require a DSL context object that delegates methods to
  # the transport instance, similar to the sequencer DSL pattern.
end

Instance Attribute Details

#sequencerSequencer::Sequencer (readonly)

The sequencer instance managing event scheduling.

Returns:



115
116
117
# File 'lib/musa-dsl/transport/transport.rb', line 115

def sequencer
  @sequencer
end

Instance Method Details

#after_stop {|sequencer| ... } ⇒ void

This method returns an undefined value.

Registers a callback to run when the transport stops.

after_stop callbacks run when the clock stops, before the sequencer is reset.

Examples:

after_stop callback

transport.after_stop do |seq|
  puts "Stopped at position #{seq.position}"
  cleanup_resources
end

Yields:

Yield Parameters:

  • sequencer (Sequencer)

    the sequencer instance



261
262
263
# File 'lib/musa-dsl/transport/transport.rb', line 261

def after_stop(&block)
  @after_stop << Musa::Extension::SmartProcBinder::SmartProcBinder.new(block)
end

#before_begin {|sequencer| ... } ⇒ void

This method returns an undefined value.

Registers a callback to run once before the first start.

before_begin callbacks are run only once, before the very first start. They're ideal for one-time setup like loading samples or initializing state.

After a stop, before_begin runs again before the next start.

Examples:

before_begin callback

transport.before_begin do |seq|
  puts "Initializing at position #{seq.position}"
  load_samples
end

Yields:

Yield Parameters:

  • sequencer (Sequencer)

    the sequencer instance



228
229
230
# File 'lib/musa-dsl/transport/transport.rb', line 228

def before_begin(&block)
  @before_begin << Musa::Extension::SmartProcBinder::SmartProcBinder.new(block)
end

#change_position_to(bars: nil, beats: nil, midi_beats: nil) ⇒ void

Note:

Backward seeks trigger stop/restart cycle

Note:

Calls on_change_position callbacks at new position

Note:

Calls sequencer.on_fast_forward callbacks during forward seek

This method returns an undefined value.

Changes the playback position (seek/jump).

Handles position changes from various sources, converting between formats. IMPORTANT: Position changes trigger fast-forward, which executes all intermediate events between current and target positions.

Fast-Forward Behavior

When changing position forward:

  1. Calls sequencer.on_fast_forward callbacks with true (entering fast-forward)
  2. Ticks through all intermediate positions, executing all scheduled events
  3. Calls sequencer.on_fast_forward callbacks with false (exiting fast-forward)
  4. Calls on_change_position callbacks at the new position

When changing position backward (requires stop):

  1. Stops the transport (calls after_stop callbacks)
  2. Resets the sequencer to initial state
  3. Sets position to target
  4. Restarts (calls on_start callbacks)

Handling Intermediate Events

During fast-forward, all scheduled events execute. To prevent unwanted sound output (e.g., MIDI notes), use on_fast_forward callbacks:

  • MIDIVoices integration: Set midi_voices.fast_forward = true during fast-forward to register note state internally without emitting MIDI messages
  • Custom handlers: Check fast-forward state in event handlers to skip audio/visual output during position jumps

Examples:

Jump to bar 8 (fast-forwards through bars 1-7)

transport.change_position_to(bars: 8)
transport.sequencer.position  # => 767/96r
# One tick before bar 8, the position from which the next tick is bar 8.

MIDI Song Position Pointer

# MIDI beats are sixteenths: 96 of them are 24 quarters, six bars of
# 4/4, so counting from bar 1 this lands on bar 7.
transport.change_position_to(midi_beats: 96)
transport.sequencer.position  # => 671/96r

Preventing sound during fast-forward with MIDIVoices

output = [].tap { |sent| def sent.puts(message) = self << message }
midi_voices = Musa::MIDIVoices::MIDIVoices.new(
  sequencer: transport.sequencer, output: output, channels: [0])

transport.sequencer.at(4) { midi_voices.voices.first.note pitch: 60, duration: 1r }

transport.sequencer.on_fast_forward do |is_starting|
  midi_voices.fast_forward = is_starting
end

# Seeking past bar 4 updates the voice's state without sounding it:
# without this guard the note would emit its note-on and note-off.
transport.change_position_to(bars: 10)
output.size  # => 0

Parameters:

  • bars (Numeric, nil) (defaults to: nil)

    target position in bars

  • beats (Numeric, nil) (defaults to: nil)

    offset in beats to add

  • midi_beats (Integer, nil) (defaults to: nil)

    offset in MIDI beats (for MIDI Clock)

Raises:

  • (ArgumentError)

    if no valid position specified



421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
# File 'lib/musa-dsl/transport/transport.rb', line 421

def change_position_to(bars: nil, beats: nil, midi_beats: nil)
  logger.debug('Transport') do
    "asked to change position to #{"#{bars} bars " if bars}#{"#{beats} beats " if beats}" \
    "#{"#{midi_beats} midi beats " if midi_beats}"
  end

  # Calculate position from provided parameters
  position = bars&.rationalize || 1r
  position += Rational(midi_beats, 4 * @sequencer.beats_per_bar) if midi_beats
  position += Rational(beats, @sequencer.beats_per_bar) if beats

  # Adjust for sequencer offset and tick duration
  position += @sequencer.offset
  position -= @sequencer.tick_duration

  raise ArgumentError, "undefined new position" unless position

  logger.debug('Transport') { "received message position change to #{position.inspect}" }

  start_again_later = false

  # Backward seek requires stop/restart to reinitialize state
  if @sequencer.position > position
    do_stop
    start_again_later = true
  end

  logger.debug('Transport') { "setting sequencer position #{position.inspect}" }

  # Schedule position change callback at new position
  @sequencer.raw_at position, force_first: true do
    @on_change_position.each { |block| block.call @sequencer }
  end

  @sequencer.position = position

  do_on_start if start_again_later
end

#loggerLogger

Returns the transport's logger.

Delegates to sequencer's logger.

Returns:

  • (Logger)

    the logger instance



475
476
477
# File 'lib/musa-dsl/transport/transport.rb', line 475

def logger
  @sequencer.logger
end

#on_change_position {|sequencer| ... } ⇒ void

This method returns an undefined value.

Registers a callback for position changes.

Called when playback position changes non-linearly (seek/jump), typically from MIDI Song Position Pointer or manual position changes.

Examples:

on_change_position callback

transport.on_change_position do |seq|
  puts "Position jumped to #{seq.position}"
  resync_external_devices
end

Yields:

Yield Parameters:

  • sequencer (Sequencer)

    the sequencer instance



279
280
281
# File 'lib/musa-dsl/transport/transport.rb', line 279

def on_change_position(&block)
  @on_change_position << Musa::Extension::SmartProcBinder::SmartProcBinder.new(block)
end

#on_start {|sequencer| ... } ⇒ void

This method returns an undefined value.

Registers a callback to run each time the transport starts.

on_start callbacks run every time #start is called, after before_begin.

Examples:

on_start callback

transport.on_start do |seq|
  puts "Starting playback at #{seq.position}"
end

Yields:

Yield Parameters:

  • sequencer (Sequencer)

    the sequencer instance



244
245
246
# File 'lib/musa-dsl/transport/transport.rb', line 244

def on_start(&block)
  @on_start << Musa::Extension::SmartProcBinder::SmartProcBinder.new(block)
end

#startvoid

Note:

Blocking behavior varies by clock type (see above)

Note:

For TimerClock, must call clock.start from separate thread

This method returns an undefined value.

Starts the transport and begins playback.

Runs before_begin (if first start or after stop), then starts the clock's run loop. Behavior depends on the clock type:

Clock Activation by Type

DummyClock (automatic activation):

  • Starts generating ticks immediately
  • Blocks until tick count/condition completes
  • No external activation needed

TimerClock (external activation required):

  • Blocks but remains paused until clock.start is called
  • Must call clock.start() from another thread to begin ticks
  • Typical pattern: Thread.new { transport.start } then clock.start

InputMidiClock (MIDI activation required):

  • Blocks waiting for MIDI "Start" (0xFA) message
  • External DAW/device controls when ticks begin
  • Automatically starts when MIDI Start received

ExternalTickClock (manual tick control):

  • Returns immediately (doesn't block)
  • Call clock.tick() manually to generate each tick
  • Complete control over timing from external system

Examples:

With DummyClock (automatic)

clock = DummyClock.new(100)
transport = Transport.new(clock, 4, 24)
transport.start  # Runs 100 ticks automatically, then returns

With TimerClock (external activation)

# clock = TimerClock.new(bpm: 120)
# transport = Transport.new(clock, 4, 24)
#
# thread = Thread.new { transport.start }  # blocks waiting
# sleep 0.1
# clock.start                              # activate from outside
# thread.join

With InputMidiClock (MIDI activation)

# input = MIDICommunications::Input.all.first
# clock = InputMidiClock.new(input)
# transport = Transport.new(clock, 4, 24)
# transport.start  # blocks until MIDI Start arrives from the DAW

With ExternalTickClock (manual control)

clock = ExternalTickClock.new
transport = Transport.new(clock, 4, 24)

thread = Thread.new { transport.start }  # Returns immediately
sleep 0.1
100.times { clock.tick }  # Generate ticks manually
transport.stop
thread.join


344
345
346
347
348
349
350
351
# File 'lib/musa-dsl/transport/transport.rb', line 344

def start
  do_before_begin unless @before_begin_already_done

  @clock.run do
    @before_begin_already_done = false
    @sequencer.tick
  end
end

#stopvoid

This method returns an undefined value.

Stops the transport.

Terminates the clock, which triggers the stop sequence (after_stop callbacks, sequencer reset, etc.)



466
467
468
# File 'lib/musa-dsl/transport/transport.rb', line 466

def stop
  @clock.terminate
end