Class: HeadMusic::Time::Conductor

Inherits:
Object
  • Object
show all
Defined in:
lib/head_music/time/conductor.rb

Overview

Representation of a conductor track for musical material

The Conductor class synchronizes three different time representations:

  • Clock time: elapsed nanoseconds (source of truth)
  • Musical position: bars:beats:ticks:subticks notation
  • SMPTE timecode: hours:minutes:seconds:frames

Each moment in a track corresponds to all three positions simultaneously. The conductor handles conversions between these representations based on the current tempo, meter, and framerate.

Examples:

Basic usage

conductor = HeadMusic::Time::Conductor.new
clock_pos = HeadMusic::Time::ClockPosition.new(1_000_000_000) # 1 second
musical_pos = conductor.clock_to_musical(clock_pos)
smpte = conductor.clock_to_smpte(clock_pos)

With custom tempo and meter

conductor = HeadMusic::Time::Conductor.new(
  starting_tempo: HeadMusic::Rudiment::Tempo.new("quarter", 96),
  starting_meter: HeadMusic::Rudiment::Meter.get("3/4")
)

Converting between representations

conductor = HeadMusic::Time::Conductor.new
musical = HeadMusic::Time::MusicalPosition.new(2, 1, 0, 0)
clock = conductor.musical_to_clock(musical)
smpte = conductor.clock_to_smpte(clock)

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(starting_musical_position: nil, starting_smpte_timecode: nil, framerate: SmpteTimecode::DEFAULT_FRAMERATE, starting_tempo: nil, starting_meter: nil, tempo_map: nil, meter_map: nil) ⇒ Conductor

Create a new conductor

Parameters:

  • starting_musical_position (MusicalPosition) (defaults to: nil)

    initial musical position (default: 1:1:0:0)

  • starting_smpte_timecode (SmpteTimecode) (defaults to: nil)

    initial SMPTE timecode (default: 00:00:00:00)

  • framerate (Integer) (defaults to: SmpteTimecode::DEFAULT_FRAMERATE)

    frames per second (default: 30)

  • starting_tempo (HeadMusic::Rudiment::Tempo) (defaults to: nil)

    initial tempo (default: quarter = 120)

  • starting_meter (HeadMusic::Rudiment::Meter, String) (defaults to: nil)

    initial meter (default: 4/4)

  • tempo_map (TempoMap) (defaults to: nil)

    custom tempo map (optional, creates one from starting_tempo if not provided)

  • meter_map (MeterMap) (defaults to: nil)

    custom meter map (optional, creates one from starting_meter if not provided)



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/head_music/time/conductor.rb', line 68

def initialize(
  starting_musical_position: nil,
  starting_smpte_timecode: nil,
  framerate: SmpteTimecode::DEFAULT_FRAMERATE,
  starting_tempo: nil,
  starting_meter: nil,
  tempo_map: nil,
  meter_map: nil
)
  @starting_musical_position = starting_musical_position || MusicalPosition.new
  @starting_smpte_timecode = starting_smpte_timecode || SmpteTimecode.new(0, 0, 0, 0, framerate: framerate)
  @framerate = framerate

  # Create or use provided maps
  @tempo_map = tempo_map || TempoMap.new(
    starting_tempo: starting_tempo || HeadMusic::Rudiment::Tempo.new("quarter", 120),
    starting_position: @starting_musical_position
  )
  @meter_map = meter_map || MeterMap.new(
    starting_meter: starting_meter || "4/4",
    starting_position: @starting_musical_position
  )

  # Link maps together for position normalization
  @tempo_map.meter = @meter_map.meter_at(@starting_musical_position)
end

Instance Attribute Details

#framerateInteger

Returns frames per second for SMPTE conversions.

Returns:

  • (Integer)

    frames per second for SMPTE conversions



41
42
43
# File 'lib/head_music/time/conductor.rb', line 41

def framerate
  @framerate
end

#meter_mapMeterMap (readonly)

Returns the meter map for this conductor.

Returns:

  • (MeterMap)

    the meter map for this conductor



47
48
49
# File 'lib/head_music/time/conductor.rb', line 47

def meter_map
  @meter_map
end

#starting_musical_positionMusicalPosition

Returns the musical position at clock time 0.

Returns:



35
36
37
# File 'lib/head_music/time/conductor.rb', line 35

def starting_musical_position
  @starting_musical_position
end

#starting_smpte_timecodeSmpteTimecode

Returns the SMPTE timecode at clock time 0.

Returns:



38
39
40
# File 'lib/head_music/time/conductor.rb', line 38

def starting_smpte_timecode
  @starting_smpte_timecode
end

#tempo_mapTempoMap (readonly)

Returns the tempo map for this conductor.

Returns:

  • (TempoMap)

    the tempo map for this conductor



44
45
46
# File 'lib/head_music/time/conductor.rb', line 44

def tempo_map
  @tempo_map
end

Instance Method Details

#clock_to_musical(clock_position) ⇒ MusicalPosition

Convert clock position to musical position

Uses the tempo map to determine how many beats have elapsed, accounting for tempo changes along the timeline.

Parameters:

Returns:



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/head_music/time/conductor.rb', line 102

def clock_to_musical(clock_position)
  target_nanoseconds = clock_position.nanoseconds
  accumulated_nanoseconds = 0
  current_position = starting_musical_position

  # We need an end position far enough to contain our target clock time
  # Start with a reasonable guess and extend if needed
  estimated_end_bar = starting_musical_position.bar + 1000
  estimated_end = MusicalPosition.new(estimated_end_bar, 1, 0, 0)

  tempo_map.each_segment(starting_musical_position, estimated_end) do |start_pos, end_pos, tempo|
    meter = meter_map.meter_at(start_pos)

    # Calculate clock duration of this segment
    start_subticks = musical_position_to_subticks(start_pos, meter)
    end_subticks = musical_position_to_subticks(end_pos, meter)
    segment_subticks = end_subticks - start_subticks
    segment_ticks = segment_subticks / HeadMusic::Time::SUBTICKS_PER_TICK.to_f
    segment_nanoseconds = (segment_ticks * tempo.tick_duration_in_nanoseconds).round

    # Check if our target falls within this segment
    if accumulated_nanoseconds + segment_nanoseconds >= target_nanoseconds
      # Target is in this segment - calculate exact position
      remaining_nanoseconds = target_nanoseconds - accumulated_nanoseconds
      remaining_ticks = remaining_nanoseconds / tempo.tick_duration_in_nanoseconds.to_f
      remaining_subticks = (remaining_ticks * HeadMusic::Time::SUBTICKS_PER_TICK).round

      # Add to start position of this segment
      total_subticks = start_subticks + remaining_subticks

      # Convert to bar:beat:tick:subtick
      ticks_per_count = meter.ticks_per_count
      counts_per_bar = meter.counts_per_bar
      subticks_per_count = ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
      subticks_per_bar = counts_per_bar * subticks_per_count

      bars = (total_subticks / subticks_per_bar).floor
      remaining = total_subticks % subticks_per_bar

      beats = (remaining / subticks_per_count).floor
      remaining %= subticks_per_count

      ticks = (remaining / HeadMusic::Time::SUBTICKS_PER_TICK).floor
      subticks = remaining % HeadMusic::Time::SUBTICKS_PER_TICK

      position = MusicalPosition.new(bars + 1, beats + 1, ticks, subticks)
      return position.normalize!(meter)
    end

    accumulated_nanoseconds += segment_nanoseconds
    current_position = end_pos
  end

  # If we get here, return the last position (shouldn't normally happen)
  current_position
end

#clock_to_smpte(clock_position) ⇒ SmpteTimecode

Convert clock position to SMPTE timecode

Uses the framerate to determine the timecode.

Parameters:

Returns:



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/head_music/time/conductor.rb', line 198

def clock_to_smpte(clock_position)
  # Calculate total frames from nanoseconds
  nanoseconds_per_second = 1_000_000_000.0
  elapsed_seconds = clock_position.nanoseconds / nanoseconds_per_second
  total_frames = (elapsed_seconds * framerate).round

  # Add starting timecode frames
  starting_frames = starting_smpte_timecode.to_total_frames
  total_frames += starting_frames

  # Convert frames to HH:MM:SS:FF
  hours = total_frames / (framerate * 60 * 60)
  remaining = total_frames % (framerate * 60 * 60)

  minutes = remaining / (framerate * 60)
  remaining %= (framerate * 60)

  seconds = remaining / framerate
  frames = remaining % framerate

  timecode = SmpteTimecode.new(hours, minutes, seconds, frames, framerate: framerate)
  timecode.normalize!
end

#musical_position_to_subticks(position, meter = nil) ⇒ Integer (private)

Convert a musical position to total subticks for calculation

Parameters:

Returns:

  • (Integer)

    total subticks from the beginning



249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/head_music/time/conductor.rb', line 249

def musical_position_to_subticks(position, meter = nil)
  meter ||= meter_map.meter_at(position)
  ticks_per_count = meter.ticks_per_count
  counts_per_bar = meter.counts_per_bar

  total = 0
  total += (position.bar - 1) * counts_per_bar * ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
  total += (position.beat - 1) * ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
  total += position.tick * HeadMusic::Time::SUBTICKS_PER_TICK
  total += position.subtick

  total
end

#musical_to_clock(musical_position) ⇒ ClockPosition

Convert musical position to clock position

Uses the tempo map to determine how much clock time has elapsed based on the musical position, accounting for tempo changes.

Parameters:

Returns:



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
# File 'lib/head_music/time/conductor.rb', line 166

def musical_to_clock(musical_position)
  total_nanoseconds = 0

  # Iterate through each tempo segment from start to target position
  tempo_map.each_segment(starting_musical_position, musical_position) do |start_pos, end_pos, tempo|
    # Get the meter for this segment to calculate subticks correctly
    meter = meter_map.meter_at(start_pos)

    # Calculate subticks in this segment
    start_subticks = musical_position_to_subticks(start_pos, meter)
    end_subticks = musical_position_to_subticks(end_pos, meter)
    segment_subticks = end_subticks - start_subticks

    # Convert subticks to ticks
    segment_ticks = segment_subticks / HeadMusic::Time::SUBTICKS_PER_TICK.to_f

    # Convert ticks to nanoseconds using this segment's tempo
    nanoseconds_per_tick = tempo.tick_duration_in_nanoseconds
    segment_nanoseconds = (segment_ticks * nanoseconds_per_tick).round

    total_nanoseconds += segment_nanoseconds
  end

  ClockPosition.new(total_nanoseconds)
end

#smpte_to_clock(smpte_timecode) ⇒ ClockPosition

Convert SMPTE timecode to clock position

Uses the framerate to determine the clock time.

Parameters:

  • smpte_timecode (SmpteTimecode)

    the SMPTE timecode to convert

Returns:



228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/head_music/time/conductor.rb', line 228

def smpte_to_clock(smpte_timecode)
  # Calculate total frames
  total_frames = smpte_timecode.to_total_frames
  starting_frames = starting_smpte_timecode.to_total_frames
  elapsed_frames = total_frames - starting_frames

  # Convert frames to seconds, then to nanoseconds
  nanoseconds_per_second = 1_000_000_000.0
  elapsed_seconds = elapsed_frames / framerate.to_f
  elapsed_nanoseconds = (elapsed_seconds * nanoseconds_per_second).round

  ClockPosition.new(elapsed_nanoseconds)
end

#starting_meterHeadMusic::Rudiment::Meter

Returns the initial meter (delegates to meter_map).

Returns:



55
56
57
# File 'lib/head_music/time/conductor.rb', line 55

def starting_meter
  meter_map.events.first.meter
end

#starting_tempoHeadMusic::Rudiment::Tempo

Returns the initial tempo (delegates to tempo_map).

Returns:



50
51
52
# File 'lib/head_music/time/conductor.rb', line 50

def starting_tempo
  tempo_map.events.first.tempo
end