cyclotone 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,16 +10,40 @@ module Cyclotone
10
10
  DEFAULT_BPM = 120
11
11
  DEFAULT_PPQN = 480
12
12
  DEFAULT_TRACK_NAME = "Cyclotone"
13
+ TEMPO_EVENT_PRIORITY = -30
14
+ TIME_SIGNATURE_EVENT_PRIORITY = -20
15
+ TRACK_NAME_EVENT_PRIORITY = -10
16
+ END_OF_TRACK_PRIORITY = 99
13
17
 
14
- attr_reader :path, :channel, :ppqn, :bpm
18
+ attr_reader :path, :channel, :ppqn, :bpm, :track_mode
15
19
 
16
- def initialize(path:, bpm: DEFAULT_BPM, ppqn: DEFAULT_PPQN, channel: 0, track_name: DEFAULT_TRACK_NAME)
20
+ def self.bpm_from_cps(cps, beats_per_cycle: 4)
21
+ cps.to_f * 60.0 * beats_per_cycle.to_f
22
+ end
23
+
24
+ def initialize(
25
+ path:,
26
+ bpm: DEFAULT_BPM,
27
+ ppqn: DEFAULT_PPQN,
28
+ channel: 0,
29
+ track_name: DEFAULT_TRACK_NAME,
30
+ time_signature: [4, 4],
31
+ track_mode: :single,
32
+ unsupported_controls: :ignore,
33
+ fractional_notes: :floor
34
+ )
17
35
  @path = path
18
- @bpm = bpm.to_f
19
- @ppqn = ppqn.to_i
36
+ @bpm = normalize_bpm(bpm)
37
+ @ppqn = normalize_positive_integer(ppqn, "ppqn")
20
38
  @channel = channel.to_i
21
39
  @track_name = track_name.to_s
40
+ @time_signature = normalize_time_signature(time_signature)
41
+ @track_mode = track_mode.to_sym
42
+ @unsupported_controls = normalize_unsupported_control_policy(unsupported_controls)
43
+ @fractional_notes = normalize_fractional_note_policy(fractional_notes)
22
44
  @messages = []
45
+ @tempo_changes = []
46
+ @time_signature_changes = []
23
47
  @origin_time = nil
24
48
  end
25
49
 
@@ -28,18 +52,35 @@ module Cyclotone
28
52
  self
29
53
  end
30
54
 
55
+ def end_capture
56
+ self
57
+ end
58
+
31
59
  def clear
32
60
  @messages.clear
61
+ @tempo_changes.clear
62
+ @time_signature_changes.clear
33
63
  @origin_time = nil
34
64
  self
35
65
  end
36
66
 
37
- def send_event(event, at: Time.now.to_f, **_options)
67
+ def tempo_change(at:, bpm:)
68
+ @tempo_changes << { at: at.to_f, bpm: normalize_bpm(bpm) }
69
+ self
70
+ end
71
+
72
+ def time_signature_change(at:, numerator:, denominator:)
73
+ signature = normalize_time_signature([numerator, denominator])
74
+ @time_signature_changes << { at: at.to_f, signature: signature }
75
+ self
76
+ end
77
+
78
+ def send_event(event, at: Time.now.to_f, cps: nil, slot_id: nil, **_options)
38
79
  capture_time = at.to_f
39
80
  @origin_time ||= capture_time
40
81
 
41
- messages_for(event).each do |message|
42
- @messages << normalize_message(message, capture_time)
82
+ messages_for(event, cps: cps).each do |message|
83
+ @messages << normalize_message(message, capture_time, slot_id)
43
84
  end
44
85
 
45
86
  self
@@ -55,30 +96,55 @@ module Cyclotone
55
96
  raise ConnectionError, error.message
56
97
  end
57
98
 
99
+ def flush
100
+ clear
101
+ end
102
+
103
+ def close
104
+ self
105
+ end
106
+
107
+ def panic
108
+ self
109
+ end
110
+
58
111
  def midi_file_data
59
- header_chunk + track_chunk(track_data)
112
+ tracks = track_payloads
113
+ header_chunk(tracks.length) + tracks.map { |data| track_chunk(data) }.join
60
114
  end
61
115
 
62
116
  private
63
117
 
64
- def normalize_message(message, capture_time)
118
+ def normalize_message(message, capture_time, slot_id)
65
119
  timestamp = capture_time + message.fetch(:delay, 0).to_f
66
- message.reject { |key, _| key == :delay }.merge(at: timestamp)
120
+ message.except(:delay).merge(at: timestamp, track: track_key(slot_id))
67
121
  end
68
122
 
69
- def header_chunk
70
- "MThd".b << [6, 0, 1, ppqn].pack("Nnnn")
123
+ def header_chunk(track_count)
124
+ format = track_count > 1 ? 1 : 0
125
+ "MThd".b << [6, format, track_count, ppqn].pack("Nnnn")
71
126
  end
72
127
 
73
128
  def track_chunk(data)
74
129
  "MTrk".b << [data.bytesize].pack("N") << data
75
130
  end
76
131
 
77
- def track_data
132
+ def track_payloads
133
+ return [track_data(@messages, @track_name)] unless track_mode == :slot
134
+
135
+ grouped = @messages.group_by { |message| message[:track] }
136
+ return [track_data([], @track_name)] if grouped.empty?
137
+
138
+ grouped.sort_by { |track, _| track.to_s }.map do |track, messages|
139
+ track_data(messages, "#{@track_name}:#{track}")
140
+ end
141
+ end
142
+
143
+ def track_data(messages, track_name)
78
144
  previous_tick = 0
79
145
  body = +"".b
80
146
 
81
- track_events.each do |track_event|
147
+ track_events(messages, track_name).each do |track_event|
82
148
  delta = track_event[:tick] - previous_tick
83
149
  body << encode_variable_length(delta)
84
150
  body << track_event[:data]
@@ -88,19 +154,42 @@ module Cyclotone
88
154
  body
89
155
  end
90
156
 
91
- def track_events
157
+ def track_events(messages, track_name)
92
158
  events = [
93
- { tick: 0, priority: 0, data: tempo_event },
94
- { tick: 0, priority: 1, data: track_name_event }
159
+ { tick: 0, priority: TEMPO_EVENT_PRIORITY, data: tempo_event(bpm) },
160
+ { tick: 0, priority: TIME_SIGNATURE_EVENT_PRIORITY, data: time_signature_event(@time_signature) },
161
+ { tick: 0, priority: TRACK_NAME_EVENT_PRIORITY, data: track_name_event(track_name) }
95
162
  ]
96
163
 
97
- events.concat(@messages.map { |message| channel_track_event(message) })
164
+ events.concat(tempo_change_events)
165
+ events.concat(time_signature_change_events)
166
+ events.concat(messages.map { |message| channel_track_event(message) })
98
167
 
99
168
  end_tick = events.map { |event| event[:tick] }.max || 0
100
- events << { tick: end_tick, priority: 99, data: end_of_track_event }
169
+ events << { tick: end_tick, priority: END_OF_TRACK_PRIORITY, data: end_of_track_event }
101
170
  events.sort_by { |event| [event[:tick], event[:priority]] }
102
171
  end
103
172
 
173
+ def tempo_change_events
174
+ @tempo_changes.map do |change|
175
+ {
176
+ tick: seconds_to_ticks(change[:at] - origin_time),
177
+ priority: TEMPO_EVENT_PRIORITY,
178
+ data: tempo_event(change[:bpm])
179
+ }
180
+ end
181
+ end
182
+
183
+ def time_signature_change_events
184
+ @time_signature_changes.map do |change|
185
+ {
186
+ tick: seconds_to_ticks(change[:at] - origin_time),
187
+ priority: TIME_SIGNATURE_EVENT_PRIORITY,
188
+ data: time_signature_event(change[:signature])
189
+ }
190
+ end
191
+ end
192
+
104
193
  def channel_track_event(message)
105
194
  tick = seconds_to_ticks(message[:at].to_f - origin_time)
106
195
 
@@ -124,10 +213,34 @@ module Cyclotone
124
213
  end
125
214
 
126
215
  def seconds_to_ticks(seconds)
127
- beats = [seconds.to_f, 0.0].max * bpm / 60.0
216
+ elapsed = [seconds.to_f, 0.0].max
217
+ current_bpm = bpm
218
+ previous_elapsed = 0.0
219
+ beats = 0.0
220
+
221
+ sorted_tempo_changes.each do |change|
222
+ change_elapsed = change[:at] - origin_time
223
+
224
+ if change_elapsed <= previous_elapsed
225
+ current_bpm = change[:bpm]
226
+ next
227
+ end
228
+
229
+ break if change_elapsed >= elapsed
230
+
231
+ beats += (change_elapsed - previous_elapsed) * current_bpm / 60.0
232
+ previous_elapsed = change_elapsed
233
+ current_bpm = change[:bpm]
234
+ end
235
+
236
+ beats += (elapsed - previous_elapsed) * current_bpm / 60.0
128
237
  (beats * ppqn).round
129
238
  end
130
239
 
240
+ def sorted_tempo_changes
241
+ @tempo_changes.sort_by { |change| change[:at] }
242
+ end
243
+
131
244
  def channel_event_data(message)
132
245
  channel = message[:channel].to_i.clamp(0, 15)
133
246
 
@@ -143,8 +256,8 @@ module Cyclotone
143
256
  end
144
257
  end
145
258
 
146
- def tempo_event
147
- microseconds = (60_000_000 / bpm).round.clamp(1, 0xFF_FF_FF)
259
+ def tempo_event(bpm_value)
260
+ microseconds = (60_000_000 / bpm_value).round.clamp(1, 0xFF_FF_FF)
148
261
  "\xFF\x51\x03".b << [
149
262
  (microseconds >> 16) & 0xFF,
150
263
  (microseconds >> 8) & 0xFF,
@@ -152,15 +265,60 @@ module Cyclotone
152
265
  ].pack("C3")
153
266
  end
154
267
 
155
- def track_name_event
156
- name = @track_name.dup.force_encoding(Encoding::ASCII_8BIT)
268
+ def track_name_event(name_value)
269
+ name = name_value.to_s.dup.force_encoding(Encoding::ASCII_8BIT)
157
270
  "\xFF\x03".b << encode_variable_length(name.bytesize) << name
158
271
  end
159
272
 
273
+ def track_key(slot_id)
274
+ return :default unless track_mode == :slot
275
+
276
+ slot_id || :default
277
+ end
278
+
279
+ def time_signature_event(signature)
280
+ numerator, denominator = signature
281
+ exponent = denominator.bit_length - 1
282
+ "\xFF\x58\x04".b << [numerator, exponent, 24, 8].pack("C4")
283
+ end
284
+
160
285
  def end_of_track_event
161
286
  "\xFF\x2F\x00".b
162
287
  end
163
288
 
289
+ def normalize_bpm(value)
290
+ normalized = Float(value)
291
+ return normalized if normalized.positive? && normalized.finite?
292
+
293
+ raise ArgumentError, "bpm must be positive"
294
+ rescue TypeError
295
+ raise ArgumentError, "bpm must be numeric"
296
+ end
297
+
298
+ def normalize_positive_integer(value, name)
299
+ normalized = Integer(value)
300
+ return normalized if normalized.positive?
301
+
302
+ raise ArgumentError, "#{name} must be positive"
303
+ rescue TypeError
304
+ raise ArgumentError, "#{name} must be an integer"
305
+ end
306
+
307
+ def normalize_time_signature(signature)
308
+ values = Array(signature)
309
+ raise ArgumentError, "time_signature must contain numerator and denominator" unless values.length == 2
310
+
311
+ numerator = normalize_positive_integer(values[0], "time_signature numerator")
312
+ denominator = normalize_positive_integer(values[1], "time_signature denominator")
313
+ return [numerator, denominator] if power_of_two?(denominator)
314
+
315
+ raise ArgumentError, "time_signature denominator must be a power of two"
316
+ end
317
+
318
+ def power_of_two?(value)
319
+ value.nobits?(value - 1)
320
+ end
321
+
164
322
  def encode_variable_length(value)
165
323
  number = value.to_i
166
324
  bytes = [number & 0x7F]
@@ -3,68 +3,139 @@
3
3
  module Cyclotone
4
4
  module Backends
5
5
  module MIDIMessageSupport
6
- def messages_for(event)
6
+ SUPPORTED_NOTE_CONTROLS = %i[
7
+ note channel velocity gain velocity_scale release_velocity release_velocity_scale sustain sustain_cycles fractional_notes
8
+ ].freeze
9
+ SUPPORTED_CC_CONTROLS = %i[
10
+ cc channel cc_scale controller_scale
11
+ ].freeze
12
+ UNSUPPORTED_CONTROL_POLICIES = %i[ignore error].freeze
13
+ FRACTIONAL_NOTE_POLICIES = %i[floor round error].freeze
14
+
15
+ def messages_for(event, cps: nil)
7
16
  values = event.value.is_a?(Hash) ? event.value : { note: event.value }
8
- return control_change_messages(values) if values.key?(:cc)
17
+ if values.key?(:cc)
18
+ validate_unsupported_controls!(values, SUPPORTED_CC_CONTROLS)
19
+ return control_change_messages(values)
20
+ end
21
+
22
+ notes = Array(values[:note])
23
+ return [] if notes.empty? || notes.all?(&:nil?)
9
24
 
10
- note = values[:note]
11
- return [] if note.nil?
25
+ validate_unsupported_controls!(values, SUPPORTED_NOTE_CONTROLS)
12
26
 
13
27
  active_channel = normalize_channel(values[:channel] || channel)
14
- sustain = [extract_sustain(values, event), 0.0].max
28
+ sustain = [extract_sustain(values, event, cps), 0.0].max
29
+ velocity_scale = values[:velocity_scale]
30
+ attack_velocity = normalize_velocity(values[:velocity] || values[:gain] || 1.0, scale: velocity_scale)
31
+ release_velocity = normalize_velocity(
32
+ values.fetch(:release_velocity, 0),
33
+ scale: values.fetch(:release_velocity_scale, velocity_scale)
34
+ )
15
35
 
16
- [
17
- {
18
- type: :note_on,
19
- channel: active_channel,
20
- note: normalize_data_byte(note),
21
- velocity: normalize_velocity(values[:velocity] || values[:gain] || 1.0)
22
- },
23
- {
24
- type: :note_off,
25
- channel: active_channel,
26
- note: normalize_data_byte(note),
27
- velocity: 0,
28
- delay: sustain
29
- }
30
- ]
36
+ notes.compact.flat_map do |note|
37
+ normalized_note = normalize_note(note, policy: values.fetch(:fractional_notes, fractional_note_policy))
38
+
39
+ [
40
+ {
41
+ type: :note_on,
42
+ channel: active_channel,
43
+ note: normalized_note,
44
+ velocity: attack_velocity
45
+ },
46
+ {
47
+ type: :note_off,
48
+ channel: active_channel,
49
+ note: normalized_note,
50
+ velocity: release_velocity,
51
+ delay: sustain
52
+ }
53
+ ]
54
+ end
31
55
  end
32
56
 
33
57
  private
34
58
 
59
+ def normalize_unsupported_control_policy(value)
60
+ policy = value.to_sym
61
+ return policy if UNSUPPORTED_CONTROL_POLICIES.include?(policy)
62
+
63
+ raise ArgumentError, "unsupported_controls must be one of #{UNSUPPORTED_CONTROL_POLICIES.join(", ")}"
64
+ end
65
+
66
+ def normalize_fractional_note_policy(value)
67
+ policy = value.to_sym
68
+ return policy if FRACTIONAL_NOTE_POLICIES.include?(policy)
69
+
70
+ raise ArgumentError, "fractional_notes must be one of #{FRACTIONAL_NOTE_POLICIES.join(", ")}"
71
+ end
72
+
73
+ def unsupported_control_policy
74
+ @unsupported_controls || :ignore
75
+ end
76
+
77
+ def fractional_note_policy
78
+ @fractional_notes || :floor
79
+ end
80
+
81
+ def validate_unsupported_controls!(values, supported_controls)
82
+ return unless unsupported_control_policy == :error
83
+
84
+ unsupported = values.keys - supported_controls
85
+ return if unsupported.empty?
86
+
87
+ names = unsupported.map(&:inspect).join(", ")
88
+ raise InvalidControlError, "unsupported MIDI controls: #{names}"
89
+ end
90
+
35
91
  def control_change_messages(values)
36
92
  cc_values = values[:cc].is_a?(Hash) ? values[:cc] : {}
37
93
  active_channel = normalize_channel(values[:channel] || channel)
94
+ scale = values[:cc_scale] || values[:controller_scale]
38
95
 
39
- cc_values.map do |controller, amount|
96
+ cc_values.sort_by { |controller, _| controller.to_i }.map do |controller, amount|
40
97
  {
41
98
  type: :cc,
42
99
  channel: active_channel,
43
100
  controller: normalize_data_byte(controller),
44
- value: normalize_controller_value(amount)
101
+ value: normalize_controller_value(amount, scale: scale)
45
102
  }
46
103
  end
47
104
  end
48
105
 
49
- def extract_sustain(values, event)
106
+ def extract_sustain(values, event, cps)
107
+ if values.key?(:sustain_cycles)
108
+ return values[:sustain_cycles].to_f / cps if cps.to_f.positive?
109
+
110
+ return values[:sustain_cycles].to_f
111
+ end
112
+
50
113
  sustain = values[:sustain]
51
114
  sustain = event.duration if sustain.nil?
52
115
  sustain ||= 1
53
116
  sustain.to_f
54
117
  end
55
118
 
56
- def normalize_velocity(value)
57
- normalize_7bit_value(value)
119
+ def normalize_velocity(value, scale: nil)
120
+ normalize_7bit_value(value, scale: scale)
58
121
  end
59
122
 
60
- def normalize_controller_value(value)
61
- normalize_7bit_value(value)
123
+ def normalize_controller_value(value, scale: nil)
124
+ normalize_7bit_value(value, scale: scale)
62
125
  end
63
126
 
64
- def normalize_7bit_value(value)
127
+ def normalize_7bit_value(value, scale: nil)
128
+ return normalize_data_byte(value) if scale == :midi
129
+ return normalize_unit_7bit_value(value) if scale == :unit
130
+
65
131
  numeric = value.to_f
66
132
  return numeric.round.clamp(0, 127) if numeric > 1.0
67
133
 
134
+ normalize_unit_7bit_value(numeric)
135
+ end
136
+
137
+ def normalize_unit_7bit_value(value)
138
+ numeric = value.to_f
68
139
  (numeric * 127).round.clamp(0, 127)
69
140
  end
70
141
 
@@ -72,6 +143,18 @@ module Cyclotone
72
143
  value.to_i.clamp(0, 15)
73
144
  end
74
145
 
146
+ def normalize_note(value, policy:)
147
+ numeric = Float(value)
148
+ raise InvalidControlError, "MIDI notes must be finite" unless numeric.finite?
149
+
150
+ raise InvalidControlError, "fractional MIDI note #{value.inspect} is not allowed" if numeric != numeric.floor && policy == :error
151
+
152
+ normalized = policy == :round ? numeric.round : numeric.floor
153
+ normalize_data_byte(normalized)
154
+ rescue ArgumentError, TypeError => error
155
+ raise InvalidControlError, "invalid MIDI note #{value.inspect}: #{error.message}"
156
+ end
157
+
75
158
  def normalize_data_byte(value)
76
159
  value.to_i.clamp(0, 127)
77
160
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyclotone
4
+ module Backends
5
+ class NullBackend
6
+ attr_reader :events
7
+
8
+ def initialize
9
+ @events = []
10
+ end
11
+
12
+ def send_event(event, at:, **options)
13
+ @events << { event: event, at: at, options: options }
14
+ self
15
+ end
16
+
17
+ def flush
18
+ @events.clear
19
+ self
20
+ end
21
+
22
+ def close
23
+ self
24
+ end
25
+
26
+ def panic
27
+ self
28
+ end
29
+ end
30
+
31
+ DryRunBackend = NullBackend
32
+ end
33
+ end