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
|
@@ -5,14 +5,36 @@ require "socket"
|
|
|
5
5
|
module Cyclotone
|
|
6
6
|
module Backends
|
|
7
7
|
class OSCBackend
|
|
8
|
-
|
|
8
|
+
Double = Struct.new(:value, keyword_init: true)
|
|
9
|
+
Blob = Struct.new(:bytes, keyword_init: true)
|
|
9
10
|
|
|
10
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
data/lib/cyclotone/controls.rb
CHANGED
|
@@ -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: :
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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)
|
data/lib/cyclotone/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/cyclotone/event.rb
CHANGED
|
@@ -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:
|
|
49
|
-
|
|
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
|
data/lib/cyclotone/harmony.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
103
|
+
value.merge(note: map_value(intervals, root_note, value[:note]))
|
|
98
104
|
else
|
|
99
|
-
|
|
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.
|
|
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
|