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.
@@ -5,14 +5,36 @@ require "socket"
5
5
  module Cyclotone
6
6
  module Backends
7
7
  class OSCBackend
8
- attr_reader :host, :port
8
+ Double = Struct.new(:value, keyword_init: true)
9
+ Blob = Struct.new(:bytes, keyword_init: true)
9
10
 
10
- def initialize(host: "127.0.0.1", port: 57_120, socket: nil, socket_factory: nil, retries: 1)
11
+ attr_reader :host, :port, :address
12
+
13
+ class << self
14
+ def double(value)
15
+ Double.new(value: value.to_f)
16
+ end
17
+
18
+ def blob(bytes)
19
+ Blob.new(bytes: bytes.to_s.b)
20
+ end
21
+ end
22
+
23
+ def initialize(
24
+ host: "127.0.0.1",
25
+ port: 57_120,
26
+ address: "/dirt/play",
27
+ socket: nil,
28
+ socket_factory: nil,
29
+ retries: 1
30
+ )
11
31
  @host = host
12
32
  @port = port
33
+ @address = address
13
34
  @socket_factory = socket_factory || proc { UDPSocket.new }
14
35
  @retries = retries.to_i
15
36
  @socket = socket || build_socket
37
+ @closed = false
16
38
  rescue StandardError => error
17
39
  raise ConnectionError, error.message
18
40
  end
@@ -28,10 +50,16 @@ module Cyclotone
28
50
  end
29
51
 
30
52
  def build_message(event, at:, cps: nil)
31
- encode_message("/dirt/play", payload_for(event, at: at, cps: cps))
53
+ encode_message(address, payload_for(event, at: at, cps: cps))
54
+ end
55
+
56
+ def build_bundle(events, at:, cps: nil, timetag: at)
57
+ messages = Array(events).map { |event| build_message(event, at: at, cps: cps) }
58
+ encode_bundle(messages, timetag: timetag)
32
59
  end
33
60
 
34
- def send_event(event, at: Time.now.to_f, cps: nil)
61
+ def send_event(event, at: Time.now.to_f, cps: nil, **_options)
62
+ reopen_if_closed!
35
63
  with_retry do
36
64
  @socket.send(build_message(event, at: at, cps: cps), 0, host, port)
37
65
  end
@@ -39,6 +67,20 @@ module Cyclotone
39
67
  raise ConnectionError, error.message
40
68
  end
41
69
 
70
+ def flush
71
+ self
72
+ end
73
+
74
+ def panic
75
+ self
76
+ end
77
+
78
+ def close
79
+ close_socket
80
+ @closed = true
81
+ self
82
+ end
83
+
42
84
  private
43
85
 
44
86
  def with_retry
@@ -56,8 +98,17 @@ module Cyclotone
56
98
  end
57
99
 
58
100
  def reconnect!
59
- @socket.close if @socket.respond_to?(:close)
101
+ close_socket
60
102
  @socket = build_socket
103
+ @closed = false
104
+ end
105
+
106
+ def reopen_if_closed!
107
+ reconnect! if @closed
108
+ end
109
+
110
+ def close_socket
111
+ @socket.close if @socket.respond_to?(:close)
61
112
  end
62
113
 
63
114
  def build_socket
@@ -65,7 +116,7 @@ module Cyclotone
65
116
  end
66
117
 
67
118
  def flatten_hash(hash)
68
- hash.each_with_object([]) do |(key, value), payload|
119
+ hash.sort_by { |key, _| Support::Deterministic.canonical_key(key) }.each_with_object([]) do |(key, value), payload|
69
120
  payload << key.to_s
70
121
  payload << value
71
122
  end
@@ -81,36 +132,73 @@ module Cyclotone
81
132
  return nil unless event.offset
82
133
  return event.offset.to_f if cps.nil? || event.duration.nil?
83
134
 
84
- at.to_f + (event.duration.to_f / cps.to_f)
135
+ at.to_f + (event.duration.to_f / cps)
85
136
  end
86
137
 
87
138
  def encode_message(address, arguments)
88
- type_tags = arguments.map do |argument|
89
- case argument
90
- when Integer then "i"
91
- when Float then "f"
92
- else "s"
93
- end
94
- end.join
139
+ type_tags = arguments.map { |argument| osc_type_tag(argument) }.join
140
+
141
+ padded(address) + padded(",#{type_tags}") + arguments.filter_map { |argument| encode_argument(argument) }.join
142
+ end
95
143
 
96
- padded(address) + padded(",#{type_tags}") + arguments.map { |argument| encode_argument(argument) }.join
144
+ def osc_type_tag(argument)
145
+ case argument
146
+ when Double then "d"
147
+ when Blob then "b"
148
+ when Integer then "i"
149
+ when Float then "f"
150
+ when TrueClass then "T"
151
+ when FalseClass then "F"
152
+ when NilClass then "N"
153
+ when Symbol then "S"
154
+ else "s"
155
+ end
97
156
  end
98
157
 
99
158
  def encode_argument(argument)
100
159
  case argument
160
+ when Double
161
+ [argument.value].pack("G")
162
+ when Blob
163
+ encode_blob(argument.bytes)
101
164
  when Integer
102
165
  [argument].pack("N")
103
166
  when Float
104
167
  [argument].pack("g")
168
+ when TrueClass, FalseClass, NilClass
169
+ nil
105
170
  else
106
171
  padded(argument.to_s)
107
172
  end
108
173
  end
109
174
 
175
+ def encode_blob(bytes)
176
+ data = bytes.to_s.b
177
+ [data.bytesize].pack("N") + pad_bytes(data)
178
+ end
179
+
180
+ def encode_bundle(messages, timetag:)
181
+ body = messages.map { |message| [message.bytesize].pack("N") + message }.join
182
+ padded("#bundle") + encode_timetag(timetag) + body
183
+ end
184
+
185
+ def encode_timetag(value)
186
+ return [0, 1].pack("NN") if value.nil? || value == :immediate
187
+
188
+ seconds = value.to_f + 2_208_988_800
189
+ whole = seconds.floor
190
+ fraction = ((seconds - whole) * (2**32)).round
191
+ [whole, fraction].pack("NN")
192
+ end
193
+
110
194
  def padded(string)
111
- bytes = "#{string}\0"
195
+ bytes = "#{string}\0".b
196
+ pad_bytes(bytes)
197
+ end
198
+
199
+ def pad_bytes(bytes)
112
200
  padding = (4 - (bytes.bytesize % 4)) % 4
113
- bytes + ("\0" * padding)
201
+ bytes + ("\0".b * padding)
114
202
  end
115
203
  end
116
204
  end
@@ -6,11 +6,11 @@ module Cyclotone
6
6
  s: { type: :string, aliases: [:sound] },
7
7
  n: { type: :integer },
8
8
  speed: { type: :float, default: 1.0 },
9
- begin: { type: :float, aliases: [:sample_begin] },
10
- end: { type: :float, aliases: [:sample_end] },
11
- pan: { type: :float, default: 0.5 },
12
- gain: { type: :float, default: 1.0 },
13
- amp: { type: :float, default: 1.0 },
9
+ begin: { type: :float, aliases: [:sample_begin], range: 0..1 },
10
+ end: { type: :float, aliases: [:sample_end], range: 0..1 },
11
+ pan: { type: :float, default: 0.5, range: 0..1 },
12
+ gain: { type: :float, default: 1.0, minimum: 0 },
13
+ amp: { type: :float, default: 1.0, minimum: 0 },
14
14
  cut: { type: :integer },
15
15
  unit: { type: :string },
16
16
  accelerate: { type: :float },
@@ -55,10 +55,10 @@ module Cyclotone
55
55
  ring: { type: :float },
56
56
  ringf: { type: :float },
57
57
  ringdf: { type: :float },
58
- note: { type: :integer },
59
- velocity: { type: :integer, default: 100 },
58
+ note: { type: :numeric_or_array },
59
+ velocity: { type: :integer, default: 100, range: 0..127 },
60
60
  sustain: { type: :float, default: 1.0 },
61
- channel: { type: :integer, default: 0 },
61
+ channel: { type: :integer, default: 0, range: 0..15 },
62
62
  cc: { type: :hash }
63
63
  }.freeze
64
64
 
@@ -95,29 +95,77 @@ module Cyclotone
95
95
  end
96
96
 
97
97
  def coerce_pattern(pattern_or_value)
98
- return pattern_or_value if pattern_or_value.is_a?(Pattern)
99
- return Pattern.mn(pattern_or_value) if pattern_or_value.is_a?(String)
100
-
101
- Pattern.pure(pattern_or_value)
98
+ Pattern.ensure_pattern(pattern_or_value, strings: :mini_notation)
102
99
  end
103
100
 
104
101
  def wrap_value(control_name, value)
105
102
  if value.is_a?(Hash)
106
103
  if control_name == :s && (value.key?(:s) || value.key?(:n))
107
- value.merge(s: value[:s] || value[:value])
104
+ sample = value.key?(:s) ? value[:s] : value[:value]
105
+ value.merge(s: validate_value(control_name, sample))
108
106
  elsif control_name == :note && value.key?(:note)
109
- value
107
+ value.merge(note: validate_value(control_name, value[:note]))
110
108
  else
111
- value.merge(control_name => value[control_name] || value[:value] || value)
109
+ raw_value = if value.key?(control_name)
110
+ value[control_name]
111
+ elsif value.key?(:value)
112
+ value[:value]
113
+ else
114
+ value
115
+ end
116
+ value.merge(control_name => validate_value(control_name, raw_value))
112
117
  end
113
118
  else
114
- { control_name => value }
119
+ { control_name => validate_value(control_name, value) }
115
120
  end
116
121
  end
117
122
 
118
123
  def canonical(name)
119
124
  ALIASES.fetch(name.to_sym) { raise InvalidControlError, "unknown control #{name}" }
120
125
  end
126
+
127
+ def validate_value(control_name, value)
128
+ definition = CONTROL_DEFS.fetch(control_name)
129
+ validate_type(control_name, value, definition.fetch(:type))
130
+ validate_range(control_name, value, definition)
131
+ value
132
+ end
133
+
134
+ def validate_type(control_name, value, type)
135
+ case type
136
+ when :integer, :float
137
+ raise InvalidControlError, "#{control_name} must be numeric" unless value.is_a?(Numeric)
138
+ when :numeric_or_array
139
+ values = value.is_a?(Array) ? value : [value]
140
+ raise InvalidControlError, "#{control_name} must be numeric" unless values.all?(Numeric)
141
+ when :hash
142
+ validate_cc(value) if control_name == :cc
143
+ end
144
+ end
145
+
146
+ def validate_range(control_name, value, definition)
147
+ values = value.is_a?(Array) ? value : [value]
148
+
149
+ if definition[:range]
150
+ range = definition[:range]
151
+ raise InvalidControlError, "#{control_name} must be within #{range}" unless values.all? { |entry| range.cover?(entry) }
152
+ end
153
+
154
+ return unless definition.key?(:minimum)
155
+
156
+ minimum = definition.fetch(:minimum)
157
+ raise InvalidControlError, "#{control_name} must be >= #{minimum}" unless values.all? { |entry| entry >= minimum }
158
+ end
159
+
160
+ def validate_cc(value)
161
+ raise InvalidControlError, "cc must be a Hash" unless value.is_a?(Hash)
162
+
163
+ value.each do |controller, amount|
164
+ raise InvalidControlError, "cc controllers must be within 0..127" unless controller.is_a?(Numeric) && controller.between?(0, 127)
165
+
166
+ raise InvalidControlError, "cc values must be within 0..127" unless amount.is_a?(Numeric) && amount.between?(0, 127)
167
+ end
168
+ end
121
169
  end
122
170
  end
123
171
 
data/lib/cyclotone/dsl.rb CHANGED
@@ -8,9 +8,9 @@ module Cyclotone
8
8
  end
9
9
  end
10
10
 
11
- %i[sine cosine tri saw isaw square rand irand perlin range smooth].each do |name|
12
- define_method(name) do |*args|
13
- Oscillators.public_send(name, *args)
11
+ %i[sine cosine tri saw isaw square rand irand perlin range bipolar noise sample_and_hold brownian smooth].each do |name|
12
+ define_method(name) do |*args, **kwargs|
13
+ Oscillators.public_send(name, *args, **kwargs)
14
14
  end
15
15
  end
16
16
 
@@ -126,8 +126,8 @@ module Cyclotone
126
126
  stream.stop
127
127
  end
128
128
 
129
- def chord(name, root: 0)
130
- Harmony.chord(name, root: root)
129
+ def chord(name, root: 0, **options)
130
+ Harmony.chord(name, root: root, **options)
131
131
  end
132
132
 
133
133
  def scale(name, pattern, root: 0)
@@ -4,11 +4,12 @@ module Cyclotone
4
4
  class Error < StandardError; end
5
5
 
6
6
  class ParseError < Error
7
- attr_reader :line, :column
7
+ attr_reader :line, :column, :source
8
8
 
9
- def initialize(message, line: nil, column: nil)
9
+ def initialize(message, line: nil, column: nil, source: nil)
10
10
  @line = line
11
11
  @column = column
12
+ @source = source
12
13
 
13
14
  super(format_message(message))
14
15
  end
@@ -17,11 +18,15 @@ module Cyclotone
17
18
 
18
19
  def format_message(message)
19
20
  return message unless line && column
21
+ return "#{message} at line #{line}, column #{column}" unless source
20
22
 
21
- "#{message} at line #{line}, column #{column}"
23
+ source_line = source.lines.fetch(line - 1, "").chomp
24
+ caret = "#{" " * [column - 1, 0].max}^"
25
+ "#{message} at line #{line}, column #{column}\n#{source_line}\n#{caret}"
22
26
  end
23
27
  end
24
28
 
25
29
  class ConnectionError < Error; end
26
30
  class InvalidControlError < Error; end
31
+ class InvalidRationalError < Error; end
27
32
  end
@@ -2,12 +2,17 @@
2
2
 
3
3
  module Cyclotone
4
4
  class Event
5
+ UNSET = Object.new.freeze
6
+
5
7
  attr_reader :whole, :part, :value
6
8
 
7
9
  def initialize(whole:, part:, value:)
10
+ raise ArgumentError, "part must be a TimeSpan" unless part.is_a?(TimeSpan)
11
+ raise ArgumentError, "whole must be nil or a TimeSpan" unless whole.nil? || whole.is_a?(TimeSpan)
12
+
8
13
  @whole = whole
9
14
  @part = part
10
- @value = value
15
+ @value = deep_freeze(value)
11
16
  freeze
12
17
  end
13
18
 
@@ -45,8 +50,23 @@ module Cyclotone
45
50
  self.class.new(whole: whole, part: part, value: new_value)
46
51
  end
47
52
 
48
- def with_span(new_whole: whole, new_part: part)
49
- self.class.new(whole: new_whole, part: new_part, value: value)
53
+ def with_span(new_whole: UNSET, new_part: UNSET, whole: UNSET, part: UNSET)
54
+ next_whole = if whole.equal?(UNSET)
55
+ new_whole.equal?(UNSET) ? self.whole : new_whole
56
+ else
57
+ whole
58
+ end
59
+ next_part = if part.equal?(UNSET)
60
+ new_part.equal?(UNSET) ? self.part : new_part
61
+ else
62
+ part
63
+ end
64
+
65
+ self.class.new(whole: next_whole, part: next_part, value: value)
66
+ end
67
+
68
+ def to_h
69
+ { whole: whole, part: part, value: value }
50
70
  end
51
71
 
52
72
  def ==(other)
@@ -61,5 +81,20 @@ module Cyclotone
61
81
  def hash
62
82
  [self.class, whole, part, value].hash
63
83
  end
84
+
85
+ private
86
+
87
+ def deep_freeze(object)
88
+ case object
89
+ when Hash
90
+ object.each_with_object({}) do |(key, entry), frozen_hash|
91
+ frozen_hash[deep_freeze(key)] = deep_freeze(entry)
92
+ end.freeze
93
+ when Array
94
+ object.map { |entry| deep_freeze(entry) }.freeze
95
+ else
96
+ object.freeze
97
+ end
98
+ end
64
99
  end
65
100
  end
@@ -46,7 +46,7 @@ module Cyclotone
46
46
  module_function
47
47
 
48
48
  def scale(name, pattern, root: 0)
49
- intervals = SCALES.fetch(name.to_sym)
49
+ intervals = SCALES.fetch(name.to_sym) { raise ArgumentError, "unknown scale #{name}" }
50
50
  root_note = note_number(root)
51
51
 
52
52
  Pattern.ensure_pattern(pattern).fmap do |value|
@@ -54,11 +54,15 @@ module Cyclotone
54
54
  end
55
55
  end
56
56
 
57
- def chord(name, root: 0)
57
+ def chord(name, root: 0, inversion: 0, voicing: nil, drop2: false, octave_spread: 0)
58
58
  root_note = note_number(root)
59
- notes = CHORDS.fetch(name.to_sym) { raise ArgumentError, "unknown chord #{name}" }.map do |interval|
59
+ intervals = voicing || CHORDS.fetch(name.to_sym) { raise ArgumentError, "unknown chord #{name}" }
60
+ notes = Array(intervals).map do |interval|
60
61
  root_note + interval
61
62
  end
63
+ notes = invert_notes(notes, inversion.to_i)
64
+ notes = spread_octaves(notes, octave_spread.to_i)
65
+ notes = drop_second_voice(notes) if drop2
62
66
 
63
67
  Pattern.pure(notes)
64
68
  end
@@ -67,6 +71,7 @@ module Cyclotone
67
71
  Pattern.ensure_pattern(pattern).flat_map_events do |event|
68
72
  notes = extract_notes(event.value)
69
73
  next [event] if notes.empty?
74
+ next [] if event.part.duration.zero?
70
75
 
71
76
  ordered = order_notes(notes, mode)
72
77
  segment_length = event.part.duration / ordered.length
@@ -77,7 +82,7 @@ module Cyclotone
77
82
  event.part.start + (segment_length * (index + 1))
78
83
  )
79
84
 
80
- Event.new(whole: part, part: part, value: note)
85
+ Event.new(whole: part, part: part, value: arpeggiated_value(event.value, note))
81
86
  end
82
87
  end
83
88
  end
@@ -87,20 +92,28 @@ module Cyclotone
87
92
 
88
93
  normalized = value.to_s.strip.downcase
89
94
  match = normalized.match(/\A([a-g](?:s|b)?)(-?\d+)\z/)
90
- return normalized.to_i if match.nil?
95
+ return normalized.to_i if match.nil? && normalized.match?(/\A-?\d+\z/)
96
+ raise ArgumentError, "invalid note #{value.inspect}" if match.nil?
91
97
 
92
98
  NOTE_OFFSETS.fetch(match[1]) + ((match[2].to_i + 1) * 12)
93
99
  end
94
100
 
95
101
  def apply_scale(intervals, root_note, value)
96
102
  if value.is_a?(Hash) && value.key?(:note)
97
- value.merge(note: map_degree(intervals, root_note, value[:note]))
103
+ value.merge(note: map_value(intervals, root_note, value[:note]))
98
104
  else
99
- map_degree(intervals, root_note, value)
105
+ map_value(intervals, root_note, value)
100
106
  end
101
107
  end
102
108
  private_class_method :apply_scale
103
109
 
110
+ def map_value(intervals, root_note, value)
111
+ return value.map { |entry| map_degree(intervals, root_note, entry) } if value.is_a?(Array)
112
+
113
+ map_degree(intervals, root_note, value)
114
+ end
115
+ private_class_method :map_value
116
+
104
117
  def map_degree(intervals, root_note, value)
105
118
  degree = note_number(value)
106
119
  octave, index = degree.divmod(intervals.length)
@@ -131,6 +144,44 @@ module Cyclotone
131
144
  end
132
145
  end
133
146
  private_class_method :order_notes
147
+
148
+ def arpeggiated_value(value, note)
149
+ return value.merge(note: note) if value.is_a?(Hash) && value.key?(:note)
150
+
151
+ note
152
+ end
153
+ private_class_method :arpeggiated_value
154
+
155
+ def invert_notes(notes, inversion)
156
+ normalized = notes.dup
157
+
158
+ inversion.times do
159
+ normalized << (normalized.shift + 12)
160
+ end
161
+
162
+ (-inversion).times do
163
+ normalized.unshift(normalized.pop - 12)
164
+ end
165
+
166
+ normalized
167
+ end
168
+ private_class_method :invert_notes
169
+
170
+ def spread_octaves(notes, spread)
171
+ return notes if spread <= 0
172
+
173
+ notes.each_with_index.map { |note, index| note + (12 * spread * index) }
174
+ end
175
+ private_class_method :spread_octaves
176
+
177
+ def drop_second_voice(notes)
178
+ return notes if notes.length < 2
179
+
180
+ dropped = notes.dup
181
+ dropped[-2] -= 12
182
+ dropped.sort
183
+ end
184
+ private_class_method :drop_second_voice
134
185
  end
135
186
  end
136
187
 
@@ -139,7 +190,10 @@ module Cyclotone
139
190
  def up(semitones)
140
191
  fmap do |value|
141
192
  if value.is_a?(Hash) && value.key?(:note)
142
- value.merge(note: value[:note] + semitones.to_i)
193
+ notes = value[:note].is_a?(Array) ? value[:note].map { |note| note + semitones.to_i } : value[:note] + semitones.to_i
194
+ value.merge(note: notes)
195
+ elsif value.is_a?(Array)
196
+ value.map { |note| note + semitones.to_i }
143
197
  else
144
198
  value + semitones.to_i
145
199
  end