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
data/lib/cyclotone/scheduler.rb
CHANGED
|
@@ -1,70 +1,135 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "thread"
|
|
4
|
-
|
|
5
3
|
module Cyclotone
|
|
6
4
|
class Scheduler
|
|
7
5
|
LOOKAHEAD = 0.3
|
|
8
6
|
INTERVAL = 0.05
|
|
9
7
|
DEFAULT_CPS = Rational(9, 16)
|
|
8
|
+
DEFAULT_STOP_TIMEOUT = 1.0
|
|
9
|
+
SENT_RETAIN_CYCLES = 4
|
|
10
|
+
SystemClock = Struct.new(:unused, keyword_init: true) do
|
|
11
|
+
def monotonic_time
|
|
12
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
13
|
+
end
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
def wall_time
|
|
16
|
+
Time.now.to_f
|
|
17
|
+
end
|
|
18
|
+
end
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
attr_reader :backend, :cps, :lookahead, :interval, :lookahead_cycles, :interval_cycles, :metrics
|
|
21
|
+
|
|
22
|
+
def initialize(
|
|
23
|
+
backend:,
|
|
24
|
+
cps: DEFAULT_CPS,
|
|
25
|
+
lookahead: LOOKAHEAD,
|
|
26
|
+
interval: INTERVAL,
|
|
27
|
+
lookahead_cycles: nil,
|
|
28
|
+
interval_cycles: nil,
|
|
29
|
+
logger: nil,
|
|
30
|
+
retry_failed: false,
|
|
31
|
+
clock: nil
|
|
32
|
+
)
|
|
14
33
|
@backend = backend
|
|
15
|
-
@cps = cps
|
|
34
|
+
@cps = normalize_cps(cps)
|
|
16
35
|
@lookahead = lookahead
|
|
17
36
|
@interval = interval
|
|
37
|
+
@lookahead_cycles = lookahead_cycles&.to_f
|
|
38
|
+
@interval_cycles = interval_cycles&.to_f
|
|
18
39
|
@logger = logger
|
|
40
|
+
@retry_failed = retry_failed
|
|
41
|
+
@clock = clock || SystemClock.new
|
|
19
42
|
@mutex = Mutex.new
|
|
20
43
|
@patterns = {}
|
|
21
44
|
@sent = {}
|
|
22
45
|
@running = false
|
|
46
|
+
@thread = nil
|
|
47
|
+
@last_error = nil
|
|
23
48
|
@start_monotonic = monotonic_time
|
|
24
|
-
@start_wall_time =
|
|
49
|
+
@start_wall_time = wall_time
|
|
25
50
|
@start_cycle = 0.0
|
|
26
51
|
@last_cycle = 0.0
|
|
52
|
+
@metrics = { ticks: 0, last_tick_duration: 0.0, max_tick_duration: 0.0 }
|
|
27
53
|
end
|
|
28
54
|
|
|
29
55
|
def start
|
|
30
|
-
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
return self if @running
|
|
31
58
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
tick
|
|
37
|
-
rescue StandardError => error
|
|
38
|
-
log_runtime_error(error)
|
|
39
|
-
end
|
|
59
|
+
@running = true
|
|
60
|
+
@last_error = nil
|
|
61
|
+
@thread = Thread.new do
|
|
62
|
+
Thread.current.abort_on_exception = false
|
|
40
63
|
|
|
41
|
-
|
|
64
|
+
while running?
|
|
65
|
+
tick_started = monotonic_time
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
tick(tick_started)
|
|
69
|
+
rescue StandardError => error
|
|
70
|
+
log_runtime_error(error)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
record_tick_duration(monotonic_time - tick_started)
|
|
74
|
+
sleep(current_interval)
|
|
75
|
+
end
|
|
42
76
|
end
|
|
43
77
|
end
|
|
78
|
+
|
|
79
|
+
self
|
|
44
80
|
end
|
|
45
81
|
|
|
46
|
-
def stop
|
|
47
|
-
|
|
48
|
-
|
|
82
|
+
def stop(timeout: DEFAULT_STOP_TIMEOUT)
|
|
83
|
+
thread = @mutex.synchronize do
|
|
84
|
+
@running = false
|
|
85
|
+
@thread
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
thread&.join(timeout)
|
|
89
|
+
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
@thread = nil if @thread == thread && !thread&.alive?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def last_error
|
|
98
|
+
@mutex.synchronize { @last_error }
|
|
49
99
|
end
|
|
50
100
|
|
|
51
101
|
def tick(now = monotonic_time)
|
|
52
102
|
state = snapshot_state
|
|
53
|
-
|
|
103
|
+
current_cycle = time_to_cycle(now, state[:cps], state[:start_cycle], state[:start_monotonic])
|
|
104
|
+
logical_end = if lookahead_cycles
|
|
105
|
+
current_cycle + lookahead_cycles
|
|
106
|
+
else
|
|
107
|
+
time_to_cycle(now + lookahead, state[:cps], state[:start_cycle], state[:start_monotonic])
|
|
108
|
+
end
|
|
54
109
|
dispatch_until(logical_end, state)
|
|
55
110
|
end
|
|
56
111
|
|
|
57
|
-
def update_pattern(slot_id, pattern)
|
|
58
|
-
@mutex.synchronize
|
|
112
|
+
def update_pattern(slot_id, pattern, cps: nil, phase: 0)
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
@patterns[slot_id] = {
|
|
115
|
+
pattern: Pattern.ensure_pattern(pattern),
|
|
116
|
+
cps: cps&.to_f,
|
|
117
|
+
phase: Pattern.to_rational(phase)
|
|
118
|
+
}
|
|
119
|
+
end
|
|
59
120
|
end
|
|
60
121
|
|
|
61
122
|
def remove_pattern(slot_id)
|
|
62
|
-
@mutex.synchronize
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
@patterns.delete(slot_id)
|
|
125
|
+
@sent.delete_if { |key, _| key.first == slot_id }
|
|
126
|
+
end
|
|
63
127
|
end
|
|
64
128
|
|
|
65
129
|
def setcps(value)
|
|
130
|
+
normalized_cps = normalize_cps(value)
|
|
66
131
|
now = monotonic_time
|
|
67
|
-
wall_now =
|
|
132
|
+
wall_now = wall_time
|
|
68
133
|
|
|
69
134
|
@mutex.synchronize do
|
|
70
135
|
current_cycle = time_to_cycle(now, @cps, @start_cycle, @start_monotonic)
|
|
@@ -72,7 +137,7 @@ module Cyclotone
|
|
|
72
137
|
@start_cycle = current_cycle
|
|
73
138
|
@start_monotonic = now
|
|
74
139
|
@start_wall_time = wall_now
|
|
75
|
-
@cps =
|
|
140
|
+
@cps = normalized_cps
|
|
76
141
|
end
|
|
77
142
|
end
|
|
78
143
|
|
|
@@ -84,7 +149,7 @@ module Cyclotone
|
|
|
84
149
|
@mutex.synchronize do
|
|
85
150
|
@start_cycle = value.to_f
|
|
86
151
|
@start_monotonic = monotonic_time
|
|
87
|
-
@start_wall_time =
|
|
152
|
+
@start_wall_time = wall_time
|
|
88
153
|
@last_cycle = value.to_f
|
|
89
154
|
@sent.clear
|
|
90
155
|
end
|
|
@@ -99,25 +164,40 @@ module Cyclotone
|
|
|
99
164
|
end
|
|
100
165
|
|
|
101
166
|
def running?
|
|
102
|
-
@running
|
|
167
|
+
@mutex.synchronize { @running }
|
|
103
168
|
end
|
|
104
169
|
|
|
105
|
-
def render(duration:)
|
|
170
|
+
def render(duration:, at: 0.0)
|
|
106
171
|
duration_value = duration.to_f
|
|
107
172
|
raise ArgumentError, "render duration must be non-negative" if duration_value.negative?
|
|
108
173
|
|
|
109
|
-
state = snapshot_state
|
|
174
|
+
state = snapshot_state.merge(start_wall_time: at.to_f)
|
|
110
175
|
state[:backend].begin_capture(at: state[:start_wall_time]) if state[:backend].respond_to?(:begin_capture)
|
|
111
176
|
|
|
112
177
|
logical_end = state[:start_cycle] + (duration_value * state[:cps])
|
|
113
178
|
dispatch_until(logical_end, state)
|
|
179
|
+
|
|
180
|
+
state[:backend].end_capture if state[:backend].respond_to?(:end_capture)
|
|
181
|
+
state[:backend].write! if state[:backend].respond_to?(:write!)
|
|
114
182
|
self
|
|
115
183
|
end
|
|
116
184
|
|
|
117
185
|
private
|
|
118
186
|
|
|
119
187
|
def monotonic_time
|
|
120
|
-
|
|
188
|
+
@clock.respond_to?(:monotonic_time) ? @clock.monotonic_time : @clock.call(:monotonic)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def wall_time
|
|
192
|
+
@clock.respond_to?(:wall_time) ? @clock.wall_time : @clock.call(:wall)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def current_interval
|
|
196
|
+
@mutex.synchronize do
|
|
197
|
+
return @interval unless @interval_cycles
|
|
198
|
+
|
|
199
|
+
@interval_cycles / @cps
|
|
200
|
+
end
|
|
121
201
|
end
|
|
122
202
|
|
|
123
203
|
def snapshot_state
|
|
@@ -138,31 +218,90 @@ module Cyclotone
|
|
|
138
218
|
return if logical_end <= state[:last_cycle]
|
|
139
219
|
|
|
140
220
|
query_span = TimeSpan.new(Rational(state[:last_cycle].to_r), Rational(logical_end.to_r))
|
|
221
|
+
failed = false
|
|
222
|
+
|
|
223
|
+
state[:patterns].each do |slot_id, slot|
|
|
224
|
+
pattern = slot[:pattern]
|
|
225
|
+
scale = slot_scale(slot, state[:cps])
|
|
226
|
+
phase = slot[:phase]
|
|
227
|
+
slot_span = TimeSpan.new((query_span.start * scale) + phase, (query_span.stop * scale) + phase)
|
|
141
228
|
|
|
142
|
-
|
|
143
|
-
|
|
229
|
+
pattern.query_span(slot_span).each do |event|
|
|
230
|
+
event = map_slot_event(event, scale, phase)
|
|
144
231
|
next unless event.onset
|
|
145
232
|
|
|
146
233
|
key = [slot_id, event.onset, event.value]
|
|
147
|
-
next if
|
|
234
|
+
next if sent?(key)
|
|
148
235
|
|
|
149
236
|
absolute_time = state[:start_wall_time] + ((event.onset.to_f - state[:start_cycle]) / state[:cps])
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
237
|
+
|
|
238
|
+
begin
|
|
239
|
+
state[:backend].send_event(event, at: absolute_time, cps: state[:cps], slot_id: slot_id)
|
|
240
|
+
mark_sent(key, logical_end)
|
|
241
|
+
rescue StandardError => error
|
|
242
|
+
failed = true
|
|
243
|
+
log_runtime_error(error, slot_id: slot_id)
|
|
244
|
+
end
|
|
154
245
|
end
|
|
155
246
|
end
|
|
156
247
|
|
|
157
|
-
@mutex.synchronize { @last_cycle = logical_end }
|
|
248
|
+
@mutex.synchronize { @last_cycle = logical_end unless failed && @retry_failed }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def sent?(key)
|
|
252
|
+
@mutex.synchronize { @sent.key?(key) }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def mark_sent(key, logical_end)
|
|
256
|
+
@mutex.synchronize do
|
|
257
|
+
@sent[key] = logical_end
|
|
258
|
+
prune_sent(logical_end)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def prune_sent(logical_end)
|
|
263
|
+
retain_after = logical_end - SENT_RETAIN_CYCLES
|
|
264
|
+
@sent.delete_if { |_key, cycle| cycle < retain_after }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def record_tick_duration(duration)
|
|
268
|
+
@mutex.synchronize do
|
|
269
|
+
@metrics = {
|
|
270
|
+
ticks: @metrics[:ticks] + 1,
|
|
271
|
+
last_tick_duration: duration,
|
|
272
|
+
max_tick_duration: [@metrics[:max_tick_duration], duration].max
|
|
273
|
+
}
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def slot_scale(slot, global_cps)
|
|
278
|
+
slot_cps = slot[:cps]
|
|
279
|
+
return Rational(1) unless slot_cps&.positive?
|
|
280
|
+
|
|
281
|
+
Rational(slot_cps.to_r) / Rational(global_cps.to_r)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def map_slot_event(event, scale, phase)
|
|
285
|
+
return event if scale == 1 && phase.zero?
|
|
286
|
+
|
|
287
|
+
Pattern.map_event(event) { |time| (time - phase) / scale }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def normalize_cps(value)
|
|
291
|
+
normalized = value.to_f
|
|
292
|
+
raise ArgumentError, "cps must be positive" unless normalized.positive?
|
|
293
|
+
|
|
294
|
+
normalized
|
|
158
295
|
end
|
|
159
296
|
|
|
160
297
|
def time_to_cycle(time, cps_value, start_cycle, start_monotonic)
|
|
161
298
|
start_cycle + ((time - start_monotonic) * cps_value)
|
|
162
299
|
end
|
|
163
300
|
|
|
164
|
-
def log_runtime_error(error)
|
|
165
|
-
@
|
|
301
|
+
def log_runtime_error(error, slot_id: nil)
|
|
302
|
+
@mutex.synchronize { @last_error = error }
|
|
303
|
+
slot = slot_id ? " slot=#{slot_id}" : ""
|
|
304
|
+
@logger&.call("[Cyclotone::Scheduler#{slot}] #{error.class}: #{error.message}")
|
|
166
305
|
end
|
|
167
306
|
end
|
|
168
307
|
end
|
data/lib/cyclotone/state.rb
CHANGED
data/lib/cyclotone/stream.rb
CHANGED
|
@@ -1,34 +1,60 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "singleton"
|
|
4
3
|
require "set"
|
|
5
4
|
|
|
6
5
|
module Cyclotone
|
|
7
6
|
class Stream
|
|
8
|
-
include Singleton
|
|
9
7
|
include Transition
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
class << self
|
|
10
|
+
def instance
|
|
11
|
+
@instance ||= new
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :scheduler, :fallback_error
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
NullBackend = Backends::NullBackend
|
|
18
|
+
|
|
19
|
+
def initialize(backend: nil, scheduler: nil)
|
|
14
20
|
@slots = {}
|
|
21
|
+
@slot_options = {}
|
|
22
|
+
@transitions = {}
|
|
15
23
|
@muted = Set.new
|
|
16
24
|
@soloed = Set.new
|
|
17
|
-
@
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
@fallback_error = nil
|
|
26
|
+
@scheduler = scheduler || Scheduler.new(backend: backend || default_backend)
|
|
27
|
+
rescue StandardError => error
|
|
28
|
+
@fallback_error = error
|
|
29
|
+
@scheduler = Scheduler.new(backend: Backends::NullBackend.new)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def d(slot_id, pattern, cps: nil, phase: 0)
|
|
33
|
+
assign(normalize_d_slot_id(slot_id), pattern, cps: cps, phase: phase)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def p(name, pattern, cps: nil, phase: 0)
|
|
37
|
+
assign(normalize_slot_reference(name), pattern, cps: cps, phase: phase)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def hush(mode: :silence)
|
|
41
|
+
case mode
|
|
42
|
+
when :silence
|
|
43
|
+
@slots.each_key { |slot_id| assign(slot_id, Pattern.silence) }
|
|
44
|
+
when :mute
|
|
45
|
+
@muted.merge(@slots.keys)
|
|
46
|
+
sync_scheduler
|
|
47
|
+
when :clear
|
|
48
|
+
@slots.each_key { |slot_id| @scheduler.remove_pattern(slot_id) }
|
|
49
|
+
@slots.clear
|
|
50
|
+
@slot_options.clear
|
|
51
|
+
@transitions.clear
|
|
52
|
+
@muted.clear
|
|
53
|
+
@soloed.clear
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError, "unknown hush mode #{mode}"
|
|
56
|
+
end
|
|
29
57
|
|
|
30
|
-
def hush
|
|
31
|
-
@slots.keys.each { |slot_id| assign(slot_id, Pattern.silence) }
|
|
32
58
|
self
|
|
33
59
|
end
|
|
34
60
|
|
|
@@ -95,7 +121,9 @@ module Cyclotone
|
|
|
95
121
|
end
|
|
96
122
|
|
|
97
123
|
def mtrigger(period)
|
|
98
|
-
normalized_period =
|
|
124
|
+
normalized_period = period.to_i
|
|
125
|
+
raise ArgumentError, "mtrigger period must be positive" unless normalized_period.positive?
|
|
126
|
+
|
|
99
127
|
current_cycle = @scheduler.current_cycle
|
|
100
128
|
next_cycle = current_cycle.ceil
|
|
101
129
|
remainder = next_cycle % normalized_period
|
|
@@ -105,43 +133,71 @@ module Cyclotone
|
|
|
105
133
|
end
|
|
106
134
|
|
|
107
135
|
def slot(slot_id)
|
|
108
|
-
|
|
136
|
+
normalized = normalize_slot_reference(slot_id)
|
|
137
|
+
simplify_completed_transition(normalized)
|
|
138
|
+
@slots[normalized]
|
|
109
139
|
end
|
|
110
140
|
|
|
111
141
|
private
|
|
112
142
|
|
|
113
|
-
def assign(slot_id, pattern)
|
|
143
|
+
def assign(slot_id, pattern, cps: nil, phase: 0)
|
|
114
144
|
@slots[slot_id] = normalize_pattern(pattern)
|
|
145
|
+
@slot_options[slot_id] = { cps: cps, phase: phase }
|
|
146
|
+
@transitions.delete(slot_id)
|
|
115
147
|
sync_scheduler
|
|
116
148
|
@slots[slot_id]
|
|
117
149
|
end
|
|
118
150
|
|
|
119
|
-
def
|
|
120
|
-
|
|
121
|
-
|
|
151
|
+
def assign_transition(slot_id, pattern, replacement:, finish_cycle:)
|
|
152
|
+
@slots[slot_id] = normalize_pattern(pattern)
|
|
153
|
+
@transitions[slot_id] = { replacement: replacement, finish_cycle: finish_cycle }
|
|
154
|
+
sync_scheduler
|
|
155
|
+
@slots[slot_id]
|
|
156
|
+
end
|
|
122
157
|
|
|
123
|
-
|
|
158
|
+
def pattern_for_transition(slot_id)
|
|
159
|
+
simplify_completed_transition(slot_id)
|
|
160
|
+
@slots[slot_id] || Pattern.silence
|
|
124
161
|
end
|
|
125
162
|
|
|
126
|
-
def
|
|
127
|
-
|
|
128
|
-
return
|
|
129
|
-
return
|
|
163
|
+
def simplify_completed_transition(slot_id)
|
|
164
|
+
transition = @transitions[slot_id]
|
|
165
|
+
return unless transition
|
|
166
|
+
return if @scheduler.current_cycle < transition[:finish_cycle].to_f
|
|
167
|
+
|
|
168
|
+
@slots[slot_id] = transition[:replacement]
|
|
169
|
+
@transitions.delete(slot_id)
|
|
170
|
+
sync_scheduler
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def normalize_pattern(pattern)
|
|
174
|
+
Pattern.ensure_pattern(pattern, strings: :mini_notation)
|
|
175
|
+
end
|
|
130
176
|
|
|
131
|
-
|
|
177
|
+
def normalize_d_slot_id(slot_id)
|
|
178
|
+
normalize_slot_id(slot_id, force_d: true)
|
|
132
179
|
end
|
|
133
180
|
|
|
134
181
|
def normalize_slot_reference(slot_id)
|
|
182
|
+
normalize_slot_id(slot_id, force_d: false)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def normalize_slot_id(slot_id, force_d:)
|
|
135
186
|
raw = slot_id.to_s
|
|
136
187
|
return raw.to_sym if raw.match?(/\Ad\d+\z/)
|
|
137
188
|
return :"d#{raw}" if raw.match?(/\A\d+\z/)
|
|
138
189
|
|
|
139
|
-
raw.to_sym
|
|
190
|
+
force_d ? :"d#{raw}" : raw.to_sym
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def default_backend
|
|
194
|
+
Backends::OSCBackend.new(socket: UDPSocket.new)
|
|
140
195
|
end
|
|
141
196
|
|
|
142
197
|
def sync_scheduler
|
|
143
198
|
active_slots.each do |slot_id, pattern|
|
|
144
|
-
@
|
|
199
|
+
options = @slot_options.fetch(slot_id, {})
|
|
200
|
+
@scheduler.update_pattern(slot_id, pattern, cps: options[:cps], phase: options[:phase] || 0)
|
|
145
201
|
end
|
|
146
202
|
|
|
147
203
|
inactive_slots.each do |slot_id|
|
|
@@ -153,7 +209,7 @@ module Cyclotone
|
|
|
153
209
|
|
|
154
210
|
def active_slots
|
|
155
211
|
if @soloed.empty?
|
|
156
|
-
@slots.
|
|
212
|
+
@slots.except(*@muted)
|
|
157
213
|
else
|
|
158
214
|
@slots.select { |slot_id, _| @soloed.include?(slot_id) && !@muted.include?(slot_id) }
|
|
159
215
|
end
|
|
@@ -164,22 +220,12 @@ module Cyclotone
|
|
|
164
220
|
end
|
|
165
221
|
|
|
166
222
|
def wait_until_cycle(target_cycle)
|
|
223
|
+
return self unless @scheduler.running?
|
|
224
|
+
|
|
167
225
|
cycles_remaining = target_cycle.to_f - @scheduler.current_cycle
|
|
168
226
|
seconds = cycles_remaining / @scheduler.cps
|
|
169
227
|
sleep(seconds) if seconds.positive?
|
|
170
228
|
self
|
|
171
229
|
end
|
|
172
|
-
|
|
173
|
-
class NullBackend
|
|
174
|
-
attr_reader :events
|
|
175
|
-
|
|
176
|
-
def initialize
|
|
177
|
-
@events = []
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def send_event(event, at:, **options)
|
|
181
|
-
@events << { event: event, at: at, options: options }
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
230
|
end
|
|
185
231
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
3
5
|
module Cyclotone
|
|
4
6
|
module Support
|
|
5
7
|
module Deterministic
|
|
@@ -7,10 +9,14 @@ module Cyclotone
|
|
|
7
9
|
|
|
8
10
|
def seed_for(*parts)
|
|
9
11
|
parts.flatten.compact.reduce(17) do |memo, part|
|
|
10
|
-
((memo * 31) ^ normalize(part)
|
|
12
|
+
((memo * 31) ^ stable_hash(normalize(part))) & 0xFFFFFFFF
|
|
11
13
|
end
|
|
12
14
|
end
|
|
13
15
|
|
|
16
|
+
def canonical_key(value)
|
|
17
|
+
canonical_dump(normalize(value))
|
|
18
|
+
end
|
|
19
|
+
|
|
14
20
|
def random(*parts)
|
|
15
21
|
Random.new(seed_for(*parts))
|
|
16
22
|
end
|
|
@@ -37,6 +43,36 @@ module Cyclotone
|
|
|
37
43
|
value
|
|
38
44
|
end
|
|
39
45
|
end
|
|
46
|
+
|
|
47
|
+
private_class_method def self.stable_hash(value)
|
|
48
|
+
Digest::SHA256.digest(canonical_dump(value)).unpack1("N")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private_class_method def self.canonical_dump(value)
|
|
52
|
+
case value
|
|
53
|
+
when NilClass
|
|
54
|
+
"nil"
|
|
55
|
+
when TrueClass, FalseClass
|
|
56
|
+
"bool:#{value}"
|
|
57
|
+
when Integer
|
|
58
|
+
"int:#{value}"
|
|
59
|
+
when Float
|
|
60
|
+
"float:#{value}"
|
|
61
|
+
when Symbol
|
|
62
|
+
"sym:#{value}"
|
|
63
|
+
when String
|
|
64
|
+
"str:#{value.bytesize}:#{value}"
|
|
65
|
+
when Array
|
|
66
|
+
"arr:[#{value.map { |entry| canonical_dump(entry) }.join(",")}]"
|
|
67
|
+
when Hash
|
|
68
|
+
entries = value.sort_by { |key, _| canonical_dump(key) }.map do |key, entry|
|
|
69
|
+
"#{canonical_dump(key)}=>#{canonical_dump(entry)}"
|
|
70
|
+
end
|
|
71
|
+
"hash:{#{entries.join(",")}}"
|
|
72
|
+
else
|
|
73
|
+
"#{value.class.name}:#{value.inspect}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
40
76
|
end
|
|
41
77
|
end
|
|
42
78
|
end
|
data/lib/cyclotone/time_span.rb
CHANGED
|
@@ -26,8 +26,9 @@ module Cyclotone
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def intersection(other)
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
other_span = coerce_span(other)
|
|
30
|
+
intersection_start = [start, other_span.start].max
|
|
31
|
+
intersection_stop = [stop, other_span.stop].min
|
|
31
32
|
|
|
32
33
|
return nil if intersection_start >= intersection_stop
|
|
33
34
|
|
|
@@ -39,19 +40,27 @@ module Cyclotone
|
|
|
39
40
|
start <= normalized_time && normalized_time < stop
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
def
|
|
43
|
-
return
|
|
43
|
+
def each_cycle_span(max_cycles: nil)
|
|
44
|
+
return enum_for(:each_cycle_span, max_cycles: max_cycles) unless block_given?
|
|
45
|
+
return self if duration.zero?
|
|
44
46
|
|
|
45
|
-
spans = []
|
|
46
47
|
current_start = start
|
|
48
|
+
emitted = 0
|
|
47
49
|
|
|
48
50
|
while current_start < stop
|
|
51
|
+
raise ArgumentError, "span crosses more than #{max_cycles} cycles" if max_cycles && emitted >= max_cycles
|
|
52
|
+
|
|
49
53
|
cycle_boundary = [Rational(current_start.floor + 1), stop].min
|
|
50
|
-
|
|
54
|
+
yield self.class.new(current_start, cycle_boundary)
|
|
51
55
|
current_start = cycle_boundary
|
|
56
|
+
emitted += 1
|
|
52
57
|
end
|
|
53
58
|
|
|
54
|
-
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def cycle_spans(max_cycles: nil)
|
|
63
|
+
each_cycle_span(max_cycles: max_cycles).to_a
|
|
55
64
|
end
|
|
56
65
|
|
|
57
66
|
def shift(amount)
|
|
@@ -62,6 +71,7 @@ module Cyclotone
|
|
|
62
71
|
|
|
63
72
|
def scale(factor)
|
|
64
73
|
normalized_factor = coerce_time(factor)
|
|
74
|
+
raise ArgumentError, "scale factor must be non-negative" if normalized_factor.negative?
|
|
65
75
|
|
|
66
76
|
self.class.new(start * normalized_factor, stop * normalized_factor)
|
|
67
77
|
end
|
|
@@ -69,6 +79,8 @@ module Cyclotone
|
|
|
69
79
|
def reverse_within(cycle_start, cycle_length = 1)
|
|
70
80
|
normalized_start = coerce_time(cycle_start)
|
|
71
81
|
normalized_length = coerce_time(cycle_length)
|
|
82
|
+
raise ArgumentError, "cycle length must be positive" unless normalized_length.positive?
|
|
83
|
+
|
|
72
84
|
mirror = (normalized_start * 2) + normalized_length
|
|
73
85
|
|
|
74
86
|
self.class.new(mirror - stop, mirror - start)
|
|
@@ -92,8 +104,18 @@ module Cyclotone
|
|
|
92
104
|
|
|
93
105
|
def coerce_time(value)
|
|
94
106
|
return value if value.is_a?(Rational)
|
|
107
|
+
return Rational(value.to_s) if value.is_a?(Float)
|
|
95
108
|
|
|
96
109
|
Rational(value)
|
|
110
|
+
rescue ArgumentError, TypeError => error
|
|
111
|
+
raise ArgumentError, "invalid time value #{value.inspect}: #{error.message}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def coerce_span(value)
|
|
115
|
+
return value if value.is_a?(self.class)
|
|
116
|
+
return self.class.new(value.fetch(0), value.fetch(1)) if value.respond_to?(:fetch)
|
|
117
|
+
|
|
118
|
+
raise ArgumentError, "expected #{self.class}, got #{value.class}"
|
|
97
119
|
end
|
|
98
120
|
end
|
|
99
121
|
end
|