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.
- checksums.yaml +4 -4
- data/README.md +8 -74
- data/Rakefile +37 -1
- data/cyclotone.gemspec +16 -3
- data/exe/cyclotone +12 -0
- data/lib/cyclotone/backends/midi_backend.rb +106 -21
- data/lib/cyclotone/backends/midi_file_backend.rb +182 -24
- data/lib/cyclotone/backends/midi_message_support.rb +111 -28
- data/lib/cyclotone/backends/null_backend.rb +33 -0
- data/lib/cyclotone/backends/osc_backend.rb +105 -17
- data/lib/cyclotone/controls.rb +64 -16
- data/lib/cyclotone/dsl.rb +5 -5
- data/lib/cyclotone/errors.rb +8 -3
- data/lib/cyclotone/event.rb +38 -3
- data/lib/cyclotone/harmony.rb +62 -8
- data/lib/cyclotone/mini_notation/ast.rb +85 -5
- data/lib/cyclotone/mini_notation/compiler.rb +18 -10
- data/lib/cyclotone/mini_notation/parser.rb +168 -34
- data/lib/cyclotone/oscillators.rb +130 -28
- data/lib/cyclotone/pattern.rb +211 -36
- data/lib/cyclotone/scheduler.rb +179 -40
- data/lib/cyclotone/state.rb +0 -1
- data/lib/cyclotone/stream.rb +91 -45
- data/lib/cyclotone/support/deterministic.rb +37 -1
- data/lib/cyclotone/time_span.rb +29 -7
- data/lib/cyclotone/transforms/accumulation.rb +28 -5
- data/lib/cyclotone/transforms/alteration.rb +82 -18
- data/lib/cyclotone/transforms/condition.rb +15 -3
- data/lib/cyclotone/transforms/sample.rb +33 -9
- data/lib/cyclotone/transforms/time.rb +24 -5
- data/lib/cyclotone/transition.rb +54 -42
- data/lib/cyclotone/version.rb +1 -1
- data/lib/cyclotone.rb +1 -0
- data/sig/cyclotone.rbs +99 -0
- metadata +4 -1
|
@@ -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
|
|
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
|
|
19
|
-
@ppqn = ppqn
|
|
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
|
|
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
|
-
|
|
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.
|
|
120
|
+
message.except(:delay).merge(at: timestamp, track: track_key(slot_id))
|
|
67
121
|
end
|
|
68
122
|
|
|
69
|
-
def header_chunk
|
|
70
|
-
|
|
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
|
|
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:
|
|
94
|
-
{ tick: 0, priority:
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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 /
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|