build_audulus_wavetable_node 0.2.0 → 0.3.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/bin/build_audulus_wavetable_node +2 -2
- data/lib/antialias.rb +41 -0
- data/lib/audulus.rb +177 -163
- data/lib/build_audulus_wavetable_node.rb +1 -348
- data/lib/command.rb +99 -0
- data/lib/midi_patch.rb +103 -0
- data/lib/sample_generator.rb +21 -0
- data/lib/sox.rb +14 -0
- data/lib/spline_helper.rb +24 -0
- data/lib/spline_patch.rb +13 -0
- data/lib/wavetable_patch.rb +159 -0
- metadata +38 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e3643e058d04f602f3abc06cc1546396fa81aa13
|
4
|
+
data.tar.gz: 633181be59ed58b990ab26f10b2d5cf8f4e2d3db
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c3a585230dbd27317319aca2fff9088745e66d3171f1bbf45f906ada2180b1e16367c71ba3f893c59fed00be68542419ef8f56ba20614d4788ecc90b59958a46
|
7
|
+
data.tar.gz: cdba3131032642ec1c337b7dc82300a370aa3cf38476b1a16c36d560462a5f9680d85a35bdcfa5e51f7cdeb9cb8acd82707e3aaabce2efa9f9451e7c1218b8ce
|
data/lib/antialias.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'fftw3'
|
2
|
+
|
3
|
+
class Antialias
|
4
|
+
# sample_rate, Hz, e.g. 44100
|
5
|
+
# fundamental, Hz, e.g. 440
|
6
|
+
# samples: -1..1
|
7
|
+
# The sample_rate gives us the Nyquist limit, and the fundamental
|
8
|
+
# tells us how to calculate the partials to find out which ones fall
|
9
|
+
# above the Nyquist limit. With this knowledge we dampen those higher
|
10
|
+
# partials for the purpose of preventing aliases at that fundamental.
|
11
|
+
def self.antialias_for_fundamental(sample_rate, fundamental, samples)
|
12
|
+
fft = FFTW3.fft(NArray[samples]).to_a.flatten
|
13
|
+
dampened = dampen_higher_partials(sample_rate, fundamental, fft)
|
14
|
+
(FFTW3.ifft(NArray[dampened]) / samples.count).real.to_a.flatten
|
15
|
+
end
|
16
|
+
|
17
|
+
# kill everything higher than a scaled nyquist limit
|
18
|
+
# ease in/out everything else to minimize partials near nyquist
|
19
|
+
def self.dampen_higher_partials(sample_rate, fundamental, fft)
|
20
|
+
nyquist = sample_rate.to_f / 2
|
21
|
+
sample_fundamental = sample_rate.to_f / fft.count
|
22
|
+
scaled_nyquist = nyquist / fundamental * sample_fundamental
|
23
|
+
sample_duration = fft.count.to_f / sample_rate
|
24
|
+
sub_nyquist_sample_count = scaled_nyquist * sample_duration
|
25
|
+
fft.each_with_index.map {|power, i|
|
26
|
+
hz = i.to_f / fft.count * sample_rate.to_f
|
27
|
+
if hz < scaled_nyquist
|
28
|
+
scale_partial(i, sub_nyquist_sample_count, power)
|
29
|
+
else
|
30
|
+
0+0i
|
31
|
+
end
|
32
|
+
}
|
33
|
+
end
|
34
|
+
|
35
|
+
# dampen partials higher than a certain frequency using a smooth
|
36
|
+
# "ease-in-out" shape
|
37
|
+
def self.scale_partial(partial_index, partial_count, partial_value)
|
38
|
+
partial_value * (Math.cos(partial_index.to_f*Math::PI/2/partial_count)**2)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
data/lib/audulus.rb
CHANGED
@@ -2,189 +2,202 @@ require 'json'
|
|
2
2
|
require 'yaml'
|
3
3
|
require 'securerandom'
|
4
4
|
|
5
|
-
|
6
|
-
|
7
|
-
end
|
5
|
+
module Audulus
|
6
|
+
module_function
|
8
7
|
|
9
|
-
|
10
|
-
|
11
|
-
case node
|
12
|
-
when Hash
|
13
|
-
node.map {|key, elem|
|
14
|
-
if uuid?(elem)
|
15
|
-
elem
|
16
|
-
else
|
17
|
-
scan_uuids(elem)
|
18
|
-
end
|
19
|
-
}.flatten
|
20
|
-
when Array
|
21
|
-
node.map {|elem| scan_uuids(elem)}.flatten
|
22
|
-
else
|
23
|
-
[]
|
8
|
+
def uuid?(string)
|
9
|
+
string.kind_of?(String) && /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ =~ string
|
24
10
|
end
|
25
|
-
end
|
26
11
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
12
|
+
# Node -> [ UUID ]
|
13
|
+
def scan_uuids(node)
|
14
|
+
case node
|
15
|
+
when Hash
|
16
|
+
node.map {|key, elem|
|
17
|
+
if uuid?(elem)
|
18
|
+
elem
|
19
|
+
else
|
20
|
+
scan_uuids(elem)
|
21
|
+
end
|
22
|
+
}.flatten
|
23
|
+
when Array
|
24
|
+
node.map {|elem| scan_uuids(elem)}.flatten
|
25
|
+
else
|
26
|
+
[]
|
27
|
+
end
|
28
|
+
end
|
34
29
|
|
35
|
-
def
|
36
|
-
|
37
|
-
|
38
|
-
|
30
|
+
def build_uuid_map(node)
|
31
|
+
existing_uuids = scan_uuids(node)
|
32
|
+
existing_uuids.reduce({}) {|h, uuid|
|
33
|
+
h[uuid] = SecureRandom.uuid()
|
34
|
+
h
|
35
|
+
}
|
36
|
+
end
|
39
37
|
|
40
|
-
def
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
38
|
+
def clone_node(node)
|
39
|
+
uuid_map = build_uuid_map(node)
|
40
|
+
clone_node_helper(node, uuid_map)
|
41
|
+
end
|
42
|
+
|
43
|
+
def clone_node_helper(node, uuid_map)
|
44
|
+
case node
|
45
|
+
when Hash
|
46
|
+
Hash[node.map {|key, elem|
|
47
|
+
if uuid?(elem)
|
48
|
+
[key, uuid_map[elem]]
|
49
|
+
else
|
50
|
+
[key, clone_node_helper(elem, uuid_map)]
|
51
|
+
end
|
52
|
+
}]
|
53
|
+
when Array
|
54
|
+
node.map {|elem|
|
55
|
+
clone_node_helper(elem, uuid_map)
|
56
|
+
}
|
57
|
+
else
|
58
|
+
node
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def add_node(patch, node)
|
63
|
+
patch['nodes'] << node
|
64
|
+
patch
|
65
|
+
end
|
66
|
+
|
67
|
+
def add_nodes(patch, nodes)
|
68
|
+
nodes.each do |node|
|
69
|
+
add_node(patch, node)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def move_node(node, x, y)
|
74
|
+
node['position'] = {
|
75
|
+
'x' => x,
|
76
|
+
'y' => y,
|
53
77
|
}
|
54
|
-
else
|
55
78
|
node
|
56
79
|
end
|
57
|
-
end
|
58
80
|
|
59
|
-
def
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
nodes.each do |node|
|
66
|
-
add_node(patch, node)
|
81
|
+
def expose_node(node, x, y)
|
82
|
+
node['exposedPosition'] = {
|
83
|
+
'x' => x,
|
84
|
+
'y' => y,
|
85
|
+
}
|
86
|
+
node
|
67
87
|
end
|
68
|
-
end
|
69
88
|
|
70
|
-
def
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
89
|
+
def wire_output_to_input(patch, source_node, source_output, destination_node, destination_input)
|
90
|
+
patch['wires'] << {
|
91
|
+
"from" => source_node['id'],
|
92
|
+
"output" => source_output,
|
93
|
+
"to" => destination_node['id'],
|
94
|
+
"input": destination_input
|
95
|
+
}
|
96
|
+
end
|
77
97
|
|
78
|
-
def
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
}
|
83
|
-
node
|
84
|
-
end
|
98
|
+
def build_init_doc
|
99
|
+
result = clone_node(INIT_PATCH)
|
100
|
+
result
|
101
|
+
end
|
85
102
|
|
86
|
-
def
|
87
|
-
|
88
|
-
|
89
|
-
"output" => source_output,
|
90
|
-
"to" => destination_node['id'],
|
91
|
-
"input": destination_input
|
92
|
-
}
|
93
|
-
end
|
103
|
+
def make_subpatch(subpatch)
|
104
|
+
doc = build_init_doc
|
105
|
+
patch = doc['patch']
|
94
106
|
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
107
|
+
subpatch_node = build_subpatch_node
|
108
|
+
subpatch_node['subPatch'] = subpatch
|
109
|
+
add_node(patch, subpatch_node)
|
110
|
+
doc
|
111
|
+
end
|
99
112
|
|
100
|
-
def
|
101
|
-
|
102
|
-
|
113
|
+
def build_light_node
|
114
|
+
clone_node(LIGHT_NODE)
|
115
|
+
end
|
103
116
|
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
doc
|
108
|
-
end
|
117
|
+
def build_trigger_node
|
118
|
+
clone_node(TRIGGER_NODE)
|
119
|
+
end
|
109
120
|
|
110
|
-
def
|
111
|
-
|
112
|
-
end
|
121
|
+
def build_subpatch_node
|
122
|
+
clone_node(SUBPATCH_NODE)
|
123
|
+
end
|
113
124
|
|
114
|
-
|
115
|
-
|
116
|
-
|
125
|
+
# gate output is output 0
|
126
|
+
def build_clock_node
|
127
|
+
clone_node(CLOCK_NODE)
|
128
|
+
end
|
117
129
|
|
118
|
-
def
|
119
|
-
|
120
|
-
end
|
130
|
+
def build_via_node
|
131
|
+
clone_node(VIA_NODE)
|
132
|
+
end
|
121
133
|
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
end
|
134
|
+
def build_input_node
|
135
|
+
clone_node(INPUT_NODE)
|
136
|
+
end
|
126
137
|
|
127
|
-
def
|
128
|
-
|
129
|
-
|
138
|
+
def build_output_node
|
139
|
+
result = build_simple_node("Output")
|
140
|
+
result['name'] = "Output"
|
141
|
+
result
|
142
|
+
end
|
130
143
|
|
131
|
-
def
|
132
|
-
|
133
|
-
|
144
|
+
def build_knob_node
|
145
|
+
result = build_simple_node("Knob")
|
146
|
+
result['knob'] = {
|
147
|
+
'value' => 0.5,
|
148
|
+
'min' => 0.0,
|
149
|
+
'max' => 1.0,
|
150
|
+
}
|
151
|
+
expose_node(result, 0, 0)
|
152
|
+
result
|
153
|
+
end
|
134
154
|
|
135
|
-
def
|
136
|
-
|
137
|
-
|
138
|
-
result
|
139
|
-
end
|
155
|
+
def build_sample_and_hold_node
|
156
|
+
build_simple_node("Sample & Hold")
|
157
|
+
end
|
140
158
|
|
141
|
-
def
|
142
|
-
|
143
|
-
|
144
|
-
'value' => 0.5,
|
145
|
-
'min' => 0.0,
|
146
|
-
'max' => 1.0,
|
147
|
-
}
|
148
|
-
expose_node(result, 0, 0)
|
149
|
-
result
|
150
|
-
end
|
159
|
+
def build_mux_node
|
160
|
+
build_simple_node("Mux8")
|
161
|
+
end
|
151
162
|
|
152
|
-
def
|
153
|
-
|
154
|
-
end
|
163
|
+
def build_demux_node
|
164
|
+
build_simple_node("Demux8")
|
165
|
+
end
|
155
166
|
|
156
|
-
def
|
157
|
-
|
158
|
-
|
167
|
+
def build_expr_node(expr)
|
168
|
+
result = build_simple_node('Expr')
|
169
|
+
result['expr'] = expr
|
170
|
+
result
|
171
|
+
end
|
159
172
|
|
160
|
-
def
|
161
|
-
|
162
|
-
|
173
|
+
def build_text_node(text)
|
174
|
+
result = build_simple_node("Text")
|
175
|
+
result['text'] = text
|
176
|
+
result
|
177
|
+
end
|
163
178
|
|
164
|
-
def
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
179
|
+
def build_simple_node(type)
|
180
|
+
clone_node({
|
181
|
+
"type" => type,
|
182
|
+
"id" => "7e5486fc-994c-44f0-ae83-5ebba54d7e3b",
|
183
|
+
"position" => {
|
184
|
+
"x" => 0,
|
185
|
+
"y" => 0
|
186
|
+
}
|
187
|
+
})
|
188
|
+
end
|
169
189
|
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
end
|
190
|
+
XMUX_NODE = JSON.parse(File.read(File.join(File.dirname(__FILE__), 'xmux.audulus')))['patch']['nodes'][0]
|
191
|
+
def build_xmux_node
|
192
|
+
clone_node(XMUX_NODE)
|
193
|
+
end
|
175
194
|
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
"position" => {
|
181
|
-
"x" => 0,
|
182
|
-
"y" => 0
|
183
|
-
}
|
184
|
-
})
|
185
|
-
end
|
195
|
+
O2HZ_NODE = JSON.parse(File.read(File.join(File.dirname(__FILE__), 'o2Hz.audulus')))['patch']['nodes'][0]
|
196
|
+
def build_o2hz_node
|
197
|
+
clone_node(O2HZ_NODE)
|
198
|
+
end
|
186
199
|
|
187
|
-
INIT_PATCH = YAML.load
|
200
|
+
INIT_PATCH = YAML.load <<-YAML
|
188
201
|
---
|
189
202
|
version: 1
|
190
203
|
patch:
|
@@ -195,18 +208,18 @@ patch:
|
|
195
208
|
zoom: 1.0
|
196
209
|
nodes: []
|
197
210
|
wires: []
|
198
|
-
YAML
|
211
|
+
YAML
|
199
212
|
|
200
|
-
LIGHT_NODE = YAML.load
|
213
|
+
LIGHT_NODE = YAML.load <<-YAML
|
201
214
|
---
|
202
215
|
type: Light
|
203
216
|
id: b264602f-365b-48a2-9d25-9b95055a2c34
|
204
217
|
position:
|
205
218
|
x: 0.0
|
206
219
|
y: 0.0
|
207
|
-
YAML
|
220
|
+
YAML
|
208
221
|
|
209
|
-
TRIGGER_NODE = JSON.parse
|
222
|
+
TRIGGER_NODE = JSON.parse <<-JSON
|
210
223
|
{
|
211
224
|
"type": "Trigger",
|
212
225
|
"id": "3e8e612d-3b7a-4c6d-a2ea-2e5f1a00161d",
|
@@ -217,9 +230,9 @@ TRIGGER_NODE = JSON.parse <<JSON
|
|
217
230
|
"toggle": false,
|
218
231
|
"state": false
|
219
232
|
}
|
220
|
-
JSON
|
233
|
+
JSON
|
221
234
|
|
222
|
-
INPUT_NODE = JSON.parse
|
235
|
+
INPUT_NODE = JSON.parse <<-JSON
|
223
236
|
{
|
224
237
|
"type": "Input",
|
225
238
|
"id": "3e8e612d-3b7a-4c6d-a2ea-2e5f1a00161d",
|
@@ -233,9 +246,9 @@ INPUT_NODE = JSON.parse <<JSON
|
|
233
246
|
},
|
234
247
|
"name": "input"
|
235
248
|
}
|
236
|
-
JSON
|
249
|
+
JSON
|
237
250
|
|
238
|
-
SUBPATCH_NODE = JSON.parse
|
251
|
+
SUBPATCH_NODE = JSON.parse <<-JSON
|
239
252
|
{
|
240
253
|
"type": "Patch",
|
241
254
|
"id": "0fe72e0e-2616-4366-8036-f852398d1c73",
|
@@ -254,7 +267,8 @@ SUBPATCH_NODE = JSON.parse <<JSON
|
|
254
267
|
"wires": []
|
255
268
|
}
|
256
269
|
}
|
257
|
-
JSON
|
270
|
+
JSON
|
258
271
|
|
259
|
-
CLOCK_NODE = JSON.parse(File.read(File.join(File.dirname(__FILE__), 'clock.json')))
|
260
|
-
VIA_NODE = JSON.parse(File.read(File.join(File.dirname(__FILE__), 'via.audulus')))['patch']['nodes'][0]
|
272
|
+
CLOCK_NODE = JSON.parse(File.read(File.join(File.dirname(__FILE__), 'clock.json')))
|
273
|
+
VIA_NODE = JSON.parse(File.read(File.join(File.dirname(__FILE__), 'via.audulus')))['patch']['nodes'][0]
|
274
|
+
end
|
@@ -1,348 +1 @@
|
|
1
|
-
""
|
2
|
-
The following code builds an Audulus wavetable node given a single cycle waveform.
|
3
|
-
|
4
|
-
The way it works is by building a spline node corresponding to the waveform, then
|
5
|
-
building the support patch to drive a Phasor node at the desired frequency into the
|
6
|
-
spline node to generate the output.
|
7
|
-
|
8
|
-
The complexity in the patch comes from the fact that for any wavetable you will
|
9
|
-
quickly reach a point where you are generating harmonics that are beyond the Nyquist
|
10
|
-
limit. Without diving into details, the problem with this is that it will cause
|
11
|
-
aliasing, or frequencies that are not actually part of the underlying waveform.
|
12
|
-
These usually sound bad and one usually doesn't want them.
|
13
|
-
|
14
|
-
The solution is as follows (glossing over important details):
|
15
|
-
1. determine a set of frequency bands we care about. In my case, 0-55Hz, and up by
|
16
|
-
octaves for 8 octaves
|
17
|
-
2. for each frequency band, run the waveform through a Fast Fourier Transform
|
18
|
-
3. attenuate frequencies higher than the Nyquist limit for that frequency band
|
19
|
-
4. run an inverse FFT to get a new waveform
|
20
|
-
5. generate a wavetable for each frequency band
|
21
|
-
6. generate support patch to make sure the right wavetable is chosen for a given
|
22
|
-
frequency
|
23
|
-
|
24
|
-
Steps 2–4 behave like a very precise single-pole non-resonant low-pass-filter, and
|
25
|
-
I probably could have used that, but this approach was more direct.
|
26
|
-
"""
|
27
|
-
|
28
|
-
|
29
|
-
require 'json'
|
30
|
-
|
31
|
-
# Load the library for building Audulus patches programmatically.
|
32
|
-
require_relative 'audulus'
|
33
|
-
|
34
|
-
class Sox
|
35
|
-
# load the WAV file at `path` and turn it into a list of samples,
|
36
|
-
# -1 to 1 in value
|
37
|
-
def self.load_samples(path)
|
38
|
-
`sox "#{path}" -t dat -`.
|
39
|
-
lines.
|
40
|
-
reject {|l| l.start_with?(';')}.
|
41
|
-
map(&:strip).
|
42
|
-
map(&:split).
|
43
|
-
map(&:last).
|
44
|
-
map(&:to_f)
|
45
|
-
end
|
46
|
-
end
|
47
|
-
|
48
|
-
class WavetablePatch
|
49
|
-
# Take a list of samples corresponding to a single cycle wave form
|
50
|
-
# and generate an Audulus patch with a single wavetable node that
|
51
|
-
# has title1 and title2 as title and subtitle
|
52
|
-
def self.build_patch(samples, title1, title2)
|
53
|
-
# The below code lays out the Audulus nodes as needed to build
|
54
|
-
# the patch. It should mostly be familiar to anyone who's built
|
55
|
-
# an Audulus patch by hand.
|
56
|
-
doc = build_init_doc
|
57
|
-
patch = doc['patch']
|
58
|
-
|
59
|
-
title1_node = build_text_node(title1)
|
60
|
-
move_node(title1_node, -700, 300)
|
61
|
-
expose_node(title1_node, -10, -30)
|
62
|
-
|
63
|
-
add_node(patch, title1_node)
|
64
|
-
|
65
|
-
title2_node = build_text_node(title2)
|
66
|
-
move_node(title2_node, -700, 250)
|
67
|
-
expose_node(title2_node, -10, -45)
|
68
|
-
|
69
|
-
add_node(patch, title2_node)
|
70
|
-
|
71
|
-
o_input_node = build_input_node
|
72
|
-
o_input_node['name'] = ''
|
73
|
-
move_node(o_input_node, -700, 0)
|
74
|
-
expose_node(o_input_node, 0, 0)
|
75
|
-
add_node(patch, o_input_node)
|
76
|
-
|
77
|
-
o2hz_node = build_o2hz_node
|
78
|
-
move_node(o2hz_node, -700, -100)
|
79
|
-
add_node(patch, o2hz_node)
|
80
|
-
|
81
|
-
wire_output_to_input(patch, o_input_node, 0, o2hz_node, 0)
|
82
|
-
|
83
|
-
hertz_node = build_expr_node('clamp(hz, 0.0001, 12000)')
|
84
|
-
move_node(hertz_node, -700, -200)
|
85
|
-
add_node(patch, hertz_node)
|
86
|
-
|
87
|
-
wire_output_to_input(patch, o2hz_node, 0, hertz_node, 0)
|
88
|
-
|
89
|
-
phaser_node = build_simple_node('Phasor')
|
90
|
-
move_node(phaser_node, -500, 0)
|
91
|
-
add_node(patch, phaser_node)
|
92
|
-
|
93
|
-
wire_output_to_input(patch, hertz_node, 0, phaser_node, 0)
|
94
|
-
|
95
|
-
domain_scale_node = build_expr_node('x/2/pi')
|
96
|
-
move_node(domain_scale_node, -300, 0)
|
97
|
-
add_node(patch, domain_scale_node)
|
98
|
-
|
99
|
-
wire_output_to_input(patch, phaser_node, 0, domain_scale_node, 0)
|
100
|
-
|
101
|
-
# for each frequency band, resample using the method outlined above
|
102
|
-
frequencies = (0..7).map {|i| 55*2**i}
|
103
|
-
sample_sets = frequencies.map {|frequency|
|
104
|
-
Resample.resample_for_fundamental(44100, frequency, samples)
|
105
|
-
}
|
106
|
-
|
107
|
-
# normalize the samples
|
108
|
-
normalization_factor = 1.0 / sample_sets.flatten.map(&:abs).max
|
109
|
-
normalized_sample_sets = sample_sets.map {|sample_set|
|
110
|
-
sample_set.map {|sample| sample*normalization_factor}
|
111
|
-
}
|
112
|
-
|
113
|
-
# generate the actual spline nodes corresponding to each wavetable
|
114
|
-
spline_nodes =
|
115
|
-
normalized_sample_sets.each_with_index.map {|samples, i|
|
116
|
-
spline_node = build_spline_node_from_samples(samples)
|
117
|
-
move_node(spline_node, -100, i*200)
|
118
|
-
spline_node
|
119
|
-
}
|
120
|
-
|
121
|
-
add_nodes(patch, spline_nodes)
|
122
|
-
|
123
|
-
spline_nodes.each do |spline_node|
|
124
|
-
wire_output_to_input(patch, domain_scale_node, 0, spline_node, 0)
|
125
|
-
end
|
126
|
-
|
127
|
-
# generate the "picker," the node that determines which wavetable
|
128
|
-
# to used based on the desired output frequency
|
129
|
-
spline_picker_node = build_expr_node("clamp(log2(hz/55), 0, 8)")
|
130
|
-
move_node(spline_picker_node, -100, -100)
|
131
|
-
add_node(patch, spline_picker_node)
|
132
|
-
|
133
|
-
mux_node = build_xmux_node
|
134
|
-
move_node(mux_node, 400, 0)
|
135
|
-
add_node(patch, mux_node)
|
136
|
-
|
137
|
-
spline_nodes.each_with_index do |spline_node, i|
|
138
|
-
wire_output_to_input(patch, spline_node, 0, mux_node, i+1)
|
139
|
-
end
|
140
|
-
|
141
|
-
wire_output_to_input(patch, hertz_node, 0, spline_picker_node, 0)
|
142
|
-
wire_output_to_input(patch, spline_picker_node, 0, mux_node, 0)
|
143
|
-
|
144
|
-
range_scale_node = build_expr_node('x*2-1')
|
145
|
-
move_node(range_scale_node, 600, 0)
|
146
|
-
add_node(patch, range_scale_node)
|
147
|
-
|
148
|
-
wire_output_to_input(patch, mux_node, 0, range_scale_node, 0)
|
149
|
-
|
150
|
-
output_node = build_output_node
|
151
|
-
output_node['name'] = ''
|
152
|
-
move_node(output_node, 1100, 0)
|
153
|
-
expose_node(output_node, 50, 0)
|
154
|
-
add_node(patch, output_node)
|
155
|
-
|
156
|
-
wire_output_to_input(patch, range_scale_node, 0, output_node, 0)
|
157
|
-
|
158
|
-
doc
|
159
|
-
end
|
160
|
-
|
161
|
-
def self.build_spline_node_from_samples(samples)
|
162
|
-
spline_node = build_simple_node("Spline")
|
163
|
-
spline_node["controlPoints"] = samples.each_with_index.map {|sample, i|
|
164
|
-
{
|
165
|
-
"x" => i.to_f/(samples.count-1).to_f,
|
166
|
-
"y" => (sample+1)/2,
|
167
|
-
}
|
168
|
-
}
|
169
|
-
# move_node(spline_node, -100, i*200)
|
170
|
-
spline_node
|
171
|
-
end
|
172
|
-
|
173
|
-
XMUX_NODE = JSON.parse(File.read(File.join(File.dirname(__FILE__), 'xmux.audulus')))['patch']['nodes'][0]
|
174
|
-
def self.build_xmux_node
|
175
|
-
clone_node(XMUX_NODE)
|
176
|
-
end
|
177
|
-
|
178
|
-
O2HZ_NODE = JSON.parse(File.read(File.join(File.dirname(__FILE__), 'o2Hz.audulus')))['patch']['nodes'][0]
|
179
|
-
def self.build_o2hz_node
|
180
|
-
clone_node(O2HZ_NODE)
|
181
|
-
end
|
182
|
-
end
|
183
|
-
|
184
|
-
class Resample
|
185
|
-
require 'fftw3'
|
186
|
-
# sample_rate, Hz, e.g. 44100
|
187
|
-
# fundamental, Hz, e.g. 440
|
188
|
-
# samples: -1..1
|
189
|
-
def self.resample_for_fundamental(sample_rate, fundamental, samples)
|
190
|
-
fft = FFTW3.fft(NArray[samples]).to_a.flatten
|
191
|
-
dampened = dampen_higher_partials(sample_rate, fundamental, fft)
|
192
|
-
(FFTW3.ifft(NArray[dampened]) / samples.count).real.to_a.flatten
|
193
|
-
end
|
194
|
-
|
195
|
-
# kill everything higher than a scaled nyquist limit
|
196
|
-
# ease in/out everything else to minimize partials near nyquist
|
197
|
-
def self.dampen_higher_partials(sample_rate, fundamental, fft)
|
198
|
-
nyquist = sample_rate.to_f / 2
|
199
|
-
sample_fundamental = sample_rate.to_f / fft.count
|
200
|
-
scaled_nyquist = nyquist / fundamental * sample_fundamental
|
201
|
-
sample_duration = fft.count.to_f / sample_rate
|
202
|
-
sub_nyquist_sample_count = scaled_nyquist * sample_duration
|
203
|
-
fft.each_with_index.map {|power, i|
|
204
|
-
hz = i.to_f / fft.count * sample_rate.to_f
|
205
|
-
if hz < scaled_nyquist
|
206
|
-
scale_partial(i, sub_nyquist_sample_count, power)
|
207
|
-
else
|
208
|
-
0+0i
|
209
|
-
end
|
210
|
-
}
|
211
|
-
end
|
212
|
-
|
213
|
-
# dampen partials higher than a certain frequency using a smooth
|
214
|
-
# "ease-in-out" shape
|
215
|
-
def self.scale_partial(partial_index, partial_count, partial_value)
|
216
|
-
partial_value * (Math.cos(partial_index.to_f*Math::PI/2/partial_count)**2)
|
217
|
-
end
|
218
|
-
end
|
219
|
-
|
220
|
-
# Given a path to a single-cycle-waveform wav file, generate an Audulus wavetable
|
221
|
-
# node
|
222
|
-
def build_wavetable_patch_from_wav_file(path)
|
223
|
-
patch_data = build_patch_data(path)
|
224
|
-
|
225
|
-
# build the patch as a full patch
|
226
|
-
base_patch = WavetablePatch.build_patch(patch_data[:samples], patch_data[:title1], patch_data[:title2])['patch']
|
227
|
-
|
228
|
-
# wrap it up as a subpatch
|
229
|
-
final_patch = make_subpatch(base_patch)
|
230
|
-
|
231
|
-
# write the patch to a file as JSON (the format Audulus uses)
|
232
|
-
File.write(patch_data[:output_path], JSON.generate(final_patch))
|
233
|
-
end
|
234
|
-
|
235
|
-
# Build just a spline from the given samples. Intended for automation rather than
|
236
|
-
# for wavetables.
|
237
|
-
def build_spline_patch_from_wav_file(path)
|
238
|
-
patch_data = build_patch_data(path)
|
239
|
-
|
240
|
-
doc = build_init_doc
|
241
|
-
patch = doc['patch']
|
242
|
-
spline_node = WavetablePatch.build_spline_node_from_samples(patch_data[:samples])
|
243
|
-
add_node(patch, spline_node)
|
244
|
-
|
245
|
-
File.write(patch_data[:output_path], JSON.generate(doc))
|
246
|
-
end
|
247
|
-
|
248
|
-
def build_patch_data(path)
|
249
|
-
# break the path into directory and path so we can build the audulus file's name
|
250
|
-
parent, file = path.split("/")[-2..-1]
|
251
|
-
|
252
|
-
# load the samples from the WAV file
|
253
|
-
samples = Sox.load_samples(path)
|
254
|
-
|
255
|
-
# build the audulus patch name from the WAV file name
|
256
|
-
basename = File.basename(file, ".wav")
|
257
|
-
puts "building #{basename}.audulus"
|
258
|
-
audulus_patch_name = "#{basename}.audulus"
|
259
|
-
|
260
|
-
{ :output_path => audulus_patch_name,
|
261
|
-
:samples => samples,
|
262
|
-
:title1 => parent,
|
263
|
-
:title2 => basename,
|
264
|
-
}
|
265
|
-
end
|
266
|
-
|
267
|
-
# Make a set of random samples. Useful for generating a cyclic
|
268
|
-
# noise wavetable. This method would be used in place of loading
|
269
|
-
# the WAV file.
|
270
|
-
def make_random_samples(count)
|
271
|
-
count.times.map {
|
272
|
-
rand*2-1
|
273
|
-
}
|
274
|
-
end
|
275
|
-
|
276
|
-
# Make a set of samples conforming to a parabola. This method would
|
277
|
-
# be used in place of loading the WAV file.
|
278
|
-
def make_parabolic_samples(count)
|
279
|
-
f = ->(x) { -4*x**2 + 4*x }
|
280
|
-
count.times.map {|index|
|
281
|
-
index.to_f / (count-1)
|
282
|
-
}.map(&f).map {|sample|
|
283
|
-
sample*2-1
|
284
|
-
}
|
285
|
-
end
|
286
|
-
|
287
|
-
# Given a set of samples, build the Audulus wavetable node
|
288
|
-
def build_patch_from_samples(samples, title1, title2, output_path)
|
289
|
-
puts "building #{output_path}"
|
290
|
-
File.write(output_path, JSON.generate(make_subpatch(WavetablePatch.build_patch(samples, title1, title2)['patch'])))
|
291
|
-
end
|
292
|
-
|
293
|
-
require 'optparse'
|
294
|
-
|
295
|
-
def parse_arguments!(argv)
|
296
|
-
results = {
|
297
|
-
:spline_only => false
|
298
|
-
}
|
299
|
-
option_parser = OptionParser.new do |opts|
|
300
|
-
opts.banner = "build_audulus_wavetable_node [OPTIONS] WAV_FILE"
|
301
|
-
|
302
|
-
opts.on("-h", "--help", "Prints this help") do
|
303
|
-
results[:help] = opts.help
|
304
|
-
end
|
305
|
-
|
306
|
-
opts.on("-s", "--spline-only", "generate a patch containing only a spline corresponding to the samples in the provided WAV file") do
|
307
|
-
results[:spline_only] = true
|
308
|
-
end
|
309
|
-
end
|
310
|
-
|
311
|
-
option_parser.parse!(argv)
|
312
|
-
if argv.count != 1
|
313
|
-
results = {
|
314
|
-
:help => option_parser.help
|
315
|
-
}
|
316
|
-
end
|
317
|
-
|
318
|
-
results[:input_filename] = argv[0]
|
319
|
-
|
320
|
-
results
|
321
|
-
end
|
322
|
-
|
323
|
-
def command(argv)
|
324
|
-
arguments = argv.dup
|
325
|
-
options = parse_arguments!(arguments)
|
326
|
-
if options.has_key?(:help)
|
327
|
-
puts options[:help]
|
328
|
-
exit(0)
|
329
|
-
end
|
330
|
-
|
331
|
-
path = options[:input_filename]
|
332
|
-
unless File.exist?(path)
|
333
|
-
puts "Cannot find WAV file at #{path}"
|
334
|
-
exit(1)
|
335
|
-
end
|
336
|
-
|
337
|
-
if options[:spline_only]
|
338
|
-
build_spline_patch_from_wav_file(path)
|
339
|
-
else
|
340
|
-
build_wavetable_patch_from_wav_file(path)
|
341
|
-
end
|
342
|
-
end
|
343
|
-
|
344
|
-
# This code is the starting point.. if we run this file as
|
345
|
-
# its own program, do the following
|
346
|
-
if __FILE__ == $0
|
347
|
-
command(ARGV)
|
348
|
-
end
|
1
|
+
raise "Not suitable for library use yet"
|
data/lib/command.rb
ADDED
@@ -0,0 +1,99 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
require_relative 'audulus'
|
4
|
+
require_relative 'sox'
|
5
|
+
require_relative 'wavetable_patch'
|
6
|
+
require_relative 'spline_patch'
|
7
|
+
require_relative 'midi_patch'
|
8
|
+
require_relative 'spline_helper'
|
9
|
+
|
10
|
+
module Command
|
11
|
+
def self.build_patch_data(path, title, subtitle)
|
12
|
+
# break the path into directory and path so we can build the audulus file's name
|
13
|
+
parent, file = path.split("/")[-2..-1]
|
14
|
+
|
15
|
+
# load the samples from the WAV file
|
16
|
+
samples = Sox.load_samples(path)
|
17
|
+
|
18
|
+
# build the audulus patch name from the WAV file name
|
19
|
+
basename = File.basename(file, ".wav")
|
20
|
+
puts "building #{basename}.audulus"
|
21
|
+
audulus_patch_name = "#{basename}.audulus"
|
22
|
+
|
23
|
+
results = { :output_path => audulus_patch_name,
|
24
|
+
:samples => samples,
|
25
|
+
:title => title,
|
26
|
+
:subtitle => subtitle, }
|
27
|
+
|
28
|
+
results[:title] ||= parent
|
29
|
+
results[:subtitle] ||= basename
|
30
|
+
|
31
|
+
results
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.parse_arguments!(argv)
|
35
|
+
results = {
|
36
|
+
:spline_only => false
|
37
|
+
}
|
38
|
+
option_parser = OptionParser.new do |opts|
|
39
|
+
opts.banner = "build_audulus_wavetable_node [OPTIONS] WAV_FILE"
|
40
|
+
|
41
|
+
opts.on("-h", "--help", "Prints this help") do
|
42
|
+
results[:help] = opts.help
|
43
|
+
end
|
44
|
+
|
45
|
+
opts.on("-s", "--spline", "generate a patch containing only a spline corresponding to the samples in the provided WAV file") do
|
46
|
+
results[:spline_only] = true
|
47
|
+
end
|
48
|
+
|
49
|
+
opts.on("-m", "--midi", "generate a patch containing two splines based on the provided MIDI file") do |t|
|
50
|
+
results[:midi] = true
|
51
|
+
end
|
52
|
+
|
53
|
+
opts.on("-tTITLE", "--title=TITLE", "provide a title for the patch (defaults to parent directory)") do |t|
|
54
|
+
results[:title] = t
|
55
|
+
end
|
56
|
+
|
57
|
+
opts.on("-uSUBTITLE", "--subtitle=SUBTITLE", "provide a subtitle for the patch (defaults to file name, minus .wav)") do |u|
|
58
|
+
results[:subtitle] = u
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
option_parser.parse!(argv)
|
63
|
+
if argv.count != 1
|
64
|
+
results = {
|
65
|
+
:help => option_parser.help
|
66
|
+
}
|
67
|
+
end
|
68
|
+
|
69
|
+
results[:input_filename] = argv[0]
|
70
|
+
|
71
|
+
results
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.run(argv)
|
75
|
+
arguments = argv.dup
|
76
|
+
options = parse_arguments!(arguments)
|
77
|
+
if options.has_key?(:help)
|
78
|
+
puts options[:help]
|
79
|
+
exit(0)
|
80
|
+
end
|
81
|
+
|
82
|
+
path = options[:input_filename]
|
83
|
+
unless File.exist?(path)
|
84
|
+
puts "Cannot find WAV file at #{path}"
|
85
|
+
exit(1)
|
86
|
+
end
|
87
|
+
|
88
|
+
|
89
|
+
if options[:spline_only]
|
90
|
+
patch_data = build_patch_data(path, options[:title], options[:subtitle])
|
91
|
+
SplinePatch.build_patch(patch_data)
|
92
|
+
elsif options[:midi]
|
93
|
+
MidiPatch.build_patch(path)
|
94
|
+
else
|
95
|
+
patch_data = build_patch_data(path, options[:title], options[:subtitle])
|
96
|
+
WavetablePatch.build_patch(patch_data)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
data/lib/midi_patch.rb
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
require 'midilib'
|
2
|
+
require_relative 'audulus'
|
3
|
+
require_relative 'spline_helper'
|
4
|
+
require 'pry'
|
5
|
+
|
6
|
+
module MidiPatch
|
7
|
+
def self.build_patch(path)
|
8
|
+
seq = MIDI::Sequence.new()
|
9
|
+
|
10
|
+
File.open(path, 'rb') do |file|
|
11
|
+
seq.read(file)
|
12
|
+
end
|
13
|
+
|
14
|
+
doc = Audulus.build_init_doc
|
15
|
+
patch = doc['patch']
|
16
|
+
|
17
|
+
pitch_node = build_pitch_node(seq)
|
18
|
+
Audulus.add_node(patch, pitch_node)
|
19
|
+
Audulus.move_node(pitch_node, 0, 0)
|
20
|
+
|
21
|
+
scaler_node = Audulus.build_expr_node('x*10-5')
|
22
|
+
Audulus.add_node(patch, scaler_node)
|
23
|
+
Audulus.move_node(scaler_node, 425, 55)
|
24
|
+
Audulus.wire_output_to_input(patch, pitch_node, 0, scaler_node, 0)
|
25
|
+
|
26
|
+
pitch_label = Audulus.build_text_node("pitch")
|
27
|
+
Audulus.add_node(patch, pitch_label)
|
28
|
+
Audulus.move_node(pitch_label, -50, 100)
|
29
|
+
|
30
|
+
gate_node = build_gate_node(seq)
|
31
|
+
Audulus.add_node(patch, gate_node)
|
32
|
+
Audulus.move_node(gate_node, 0, 200)
|
33
|
+
|
34
|
+
pitch_label = Audulus.build_text_node("gate")
|
35
|
+
Audulus.add_node(patch, pitch_label)
|
36
|
+
Audulus.move_node(pitch_label, -50, 300)
|
37
|
+
|
38
|
+
_, file = File.expand_path(path).split("/")[-2..-1]
|
39
|
+
basename = File.basename(file, ".mid")
|
40
|
+
File.write("#{basename}.audulus", JSON.generate(doc))
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.build_pitch_node(seq)
|
44
|
+
note_on_events = seq.first.select {|e| MIDI::NoteOn === e}
|
45
|
+
|
46
|
+
times = note_on_events.map(&:time_from_start)
|
47
|
+
min_time, max_time = [times.min, times.max].map(&:to_f)
|
48
|
+
scaled_times = times.map {|time|
|
49
|
+
scale_time(time, min_time, max_time)
|
50
|
+
}
|
51
|
+
|
52
|
+
notes = note_on_events.map(&:note)
|
53
|
+
scaled_notes = notes.map {|note|
|
54
|
+
one_per_octave = (note.to_f - 69.0)/12.0
|
55
|
+
(one_per_octave+5)/10 # scale to be > 0
|
56
|
+
}
|
57
|
+
|
58
|
+
coordinates = scaled_times.zip(scaled_notes)
|
59
|
+
SplineHelper.build_spline_node_from_coordinates(coordinates)
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.build_gate_node(seq)
|
63
|
+
note_on_off_events = seq.first.select {|e| MIDI::NoteOn === e || MIDI::NoteOff === e}
|
64
|
+
|
65
|
+
note_stack = []
|
66
|
+
|
67
|
+
times = note_on_off_events.map(&:time_from_start)
|
68
|
+
min_time, max_time = [times.min, times.max].map(&:to_f)
|
69
|
+
coordinates = note_on_off_events.map {|event|
|
70
|
+
time = scale_time(event.time_from_start, min_time, max_time)
|
71
|
+
case event
|
72
|
+
when MIDI::NoteOn
|
73
|
+
add_to_stack(note_stack, event)
|
74
|
+
[time, scale_velocity(event.velocity)]
|
75
|
+
when MIDI::NoteOff
|
76
|
+
remove_from_stack(note_stack, event)
|
77
|
+
if note_stack.empty?
|
78
|
+
[time, 0.0]
|
79
|
+
else
|
80
|
+
[time, scale_velocity(note_stack[-1].velocity)]
|
81
|
+
end
|
82
|
+
end
|
83
|
+
}
|
84
|
+
|
85
|
+
SplineHelper.build_spline_node_from_coordinates(coordinates)
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.scale_time(time, min_time, max_time)
|
89
|
+
(time - min_time).to_f / (max_time - min_time).to_f
|
90
|
+
end
|
91
|
+
|
92
|
+
def self.scale_velocity(velocity)
|
93
|
+
velocity.to_f / 127.0
|
94
|
+
end
|
95
|
+
|
96
|
+
def self.add_to_stack(stack, event)
|
97
|
+
stack << event
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.remove_from_stack(stack, event)
|
101
|
+
stack.delete_if {|e| e.note == event.note}
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module SampleGenerator
|
2
|
+
# Make a set of random samples. Useful for generating a cyclic
|
3
|
+
# noise wavetable. This method would be used in place of loading
|
4
|
+
# the WAV file.
|
5
|
+
def make_random_samples(count)
|
6
|
+
count.times.map {
|
7
|
+
rand*2-1
|
8
|
+
}
|
9
|
+
end
|
10
|
+
|
11
|
+
# Make a set of samples conforming to a parabola. This method would
|
12
|
+
# be used in place of loading the WAV file.
|
13
|
+
def make_parabolic_samples(count)
|
14
|
+
f = ->(x) { -4*x**2 + 4*x }
|
15
|
+
count.times.map {|index|
|
16
|
+
index.to_f / (count-1)
|
17
|
+
}.map(&f).map {|sample|
|
18
|
+
sample*2-1
|
19
|
+
}
|
20
|
+
end
|
21
|
+
end
|
data/lib/sox.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
class Sox
|
2
|
+
# load the WAV file at `path` and turn it into a list of samples,
|
3
|
+
# -1 to 1 in value
|
4
|
+
def self.load_samples(path)
|
5
|
+
`sox "#{path}" -t dat -`.
|
6
|
+
lines.
|
7
|
+
reject {|l| l.start_with?(';')}.
|
8
|
+
map(&:strip).
|
9
|
+
map(&:split).
|
10
|
+
map(&:last).
|
11
|
+
map(&:to_f)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require_relative 'audulus'
|
2
|
+
|
3
|
+
module SplineHelper
|
4
|
+
# samples: list of values 0..1, assumed to be evenly spaced
|
5
|
+
# along the X axis
|
6
|
+
def self.build_spline_node_from_samples(samples)
|
7
|
+
coordinates = samples.each_with_index.map {|sample, i|
|
8
|
+
[i.to_f/(samples.count-1).to_f, sample]
|
9
|
+
}
|
10
|
+
build_spline_node_from_coordinates(coordinates)
|
11
|
+
end
|
12
|
+
|
13
|
+
# coordinates: list of x,y pairs, where x in [0..1] and y in [0..1]
|
14
|
+
def self.build_spline_node_from_coordinates(coordinates)
|
15
|
+
spline_node = Audulus.build_simple_node("Spline")
|
16
|
+
spline_node["controlPoints"] = coordinates.map { |x, sample|
|
17
|
+
{
|
18
|
+
"x" => x,
|
19
|
+
"y" => sample.to_f,
|
20
|
+
}
|
21
|
+
}
|
22
|
+
spline_node
|
23
|
+
end
|
24
|
+
end
|
data/lib/spline_patch.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
module SplinePatch
|
2
|
+
# Build just a spline from the given samples. Intended for automation rather than
|
3
|
+
# for wavetables.
|
4
|
+
def self.build_patch(patch_data)
|
5
|
+
doc = Audulus.build_init_doc
|
6
|
+
patch = doc['patch']
|
7
|
+
scaled_samples = patch_data[:samples].map {|sample| (sample.to_f + 1.0)/2.0}
|
8
|
+
spline_node = SplineHelper.build_spline_node_from_samples(scaled_samples)
|
9
|
+
Audulus.add_node(patch, spline_node)
|
10
|
+
|
11
|
+
File.write(patch_data[:output_path], JSON.generate(doc))
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,159 @@
|
|
1
|
+
# The following code builds an Audulus wavetable node given a single cycle waveform.
|
2
|
+
#
|
3
|
+
# The way it works is by building a spline node corresponding to the waveform, then
|
4
|
+
# building the support patch to drive a Phasor node at the desired frequency into the
|
5
|
+
# spline node to generate the output.
|
6
|
+
#
|
7
|
+
# The complexity in the patch comes from the fact that for any wavetable you will
|
8
|
+
# quickly reach a point where you are generating harmonics that are beyond the Nyquist
|
9
|
+
# limit. Without diving into details, the problem with this is that it will cause
|
10
|
+
# aliasing, or frequencies that are not actually part of the underlying waveform.
|
11
|
+
# These usually sound bad and one usually doesn't want them.
|
12
|
+
#
|
13
|
+
# The solution is as follows (glossing over important details):
|
14
|
+
# 1. determine a set of frequency bands we care about. In my case, 0-55Hz, and up by
|
15
|
+
# octaves for 8 octaves
|
16
|
+
# 2. for each frequency band, run the waveform through a Fast Fourier Transform
|
17
|
+
# 3. attenuate frequencies higher than the Nyquist limit for that frequency band
|
18
|
+
# 4. run an inverse FFT to get a new waveform
|
19
|
+
# 5. generate a wavetable for each frequency band
|
20
|
+
# 6. generate support patch to make sure the right wavetable is chosen for a given
|
21
|
+
# frequency
|
22
|
+
#
|
23
|
+
# Steps 2–4 behave like a very precise single-pole non-resonant low-pass-filter, and
|
24
|
+
# I probably could have used that, but this approach was more direct.
|
25
|
+
|
26
|
+
require_relative 'antialias'
|
27
|
+
|
28
|
+
class WavetablePatch
|
29
|
+
class <<self
|
30
|
+
include Audulus
|
31
|
+
end
|
32
|
+
|
33
|
+
# Take a list of samples corresponding to a single cycle wave form
|
34
|
+
# and generate an Audulus patch with a single wavetable node that
|
35
|
+
# has title1 and title2 as title and subtitle
|
36
|
+
def self.build_patch_helper(samples, title1, title2)
|
37
|
+
# The below code lays out the Audulus nodes as needed to build
|
38
|
+
# the patch. It should mostly be familiar to anyone who's built
|
39
|
+
# an Audulus patch by hand.
|
40
|
+
doc = build_init_doc
|
41
|
+
patch = doc['patch']
|
42
|
+
|
43
|
+
title1_node = build_text_node(title1)
|
44
|
+
move_node(title1_node, -700, 300)
|
45
|
+
expose_node(title1_node, -10, -30)
|
46
|
+
|
47
|
+
add_node(patch, title1_node)
|
48
|
+
|
49
|
+
title2_node = build_text_node(title2)
|
50
|
+
move_node(title2_node, -700, 250)
|
51
|
+
expose_node(title2_node, -10, -45)
|
52
|
+
|
53
|
+
add_node(patch, title2_node)
|
54
|
+
|
55
|
+
o_input_node = build_input_node
|
56
|
+
o_input_node['name'] = ''
|
57
|
+
move_node(o_input_node, -700, 0)
|
58
|
+
expose_node(o_input_node, 0, 0)
|
59
|
+
add_node(patch, o_input_node)
|
60
|
+
|
61
|
+
o2hz_node = build_o2hz_node
|
62
|
+
move_node(o2hz_node, -700, -100)
|
63
|
+
add_node(patch, o2hz_node)
|
64
|
+
|
65
|
+
wire_output_to_input(patch, o_input_node, 0, o2hz_node, 0)
|
66
|
+
|
67
|
+
hertz_node = build_expr_node('clamp(hz, 0.0001, 12000)')
|
68
|
+
move_node(hertz_node, -700, -200)
|
69
|
+
add_node(patch, hertz_node)
|
70
|
+
|
71
|
+
wire_output_to_input(patch, o2hz_node, 0, hertz_node, 0)
|
72
|
+
|
73
|
+
phaser_node = build_simple_node('Phasor')
|
74
|
+
move_node(phaser_node, -500, 0)
|
75
|
+
add_node(patch, phaser_node)
|
76
|
+
|
77
|
+
wire_output_to_input(patch, hertz_node, 0, phaser_node, 0)
|
78
|
+
|
79
|
+
domain_scale_node = build_expr_node('x/2/pi')
|
80
|
+
move_node(domain_scale_node, -300, 0)
|
81
|
+
add_node(patch, domain_scale_node)
|
82
|
+
|
83
|
+
wire_output_to_input(patch, phaser_node, 0, domain_scale_node, 0)
|
84
|
+
|
85
|
+
# for each frequency band, resample using the method outlined above
|
86
|
+
frequencies = (0..7).map {|i| 55*2**i}
|
87
|
+
sample_sets = frequencies.map {|frequency|
|
88
|
+
Antialias.antialias_for_fundamental(44100, frequency, samples)
|
89
|
+
}
|
90
|
+
|
91
|
+
# normalize the samples
|
92
|
+
normalization_factor = 1.0 / sample_sets.flatten.map(&:abs).max
|
93
|
+
normalized_sample_sets = sample_sets.map {|sample_set|
|
94
|
+
sample_set.map {|sample| sample*normalization_factor}
|
95
|
+
}
|
96
|
+
|
97
|
+
# generate the actual spline nodes corresponding to each wavetable
|
98
|
+
spline_nodes =
|
99
|
+
normalized_sample_sets.each_with_index.map {|sample_set, i|
|
100
|
+
scaled_samples = sample_set.map {|sample| (sample.to_f + 1.0)/2.0}
|
101
|
+
spline_node = SplineHelper.build_spline_node_from_samples(scaled_samples)
|
102
|
+
move_node(spline_node, -100, i*200)
|
103
|
+
spline_node
|
104
|
+
}
|
105
|
+
|
106
|
+
add_nodes(patch, spline_nodes)
|
107
|
+
|
108
|
+
spline_nodes.each do |spline_node|
|
109
|
+
wire_output_to_input(patch, domain_scale_node, 0, spline_node, 0)
|
110
|
+
end
|
111
|
+
|
112
|
+
# generate the "picker," the node that determines which wavetable
|
113
|
+
# to used based on the desired output frequency
|
114
|
+
spline_picker_node = build_expr_node("clamp(log2(hz/55), 0, 8)")
|
115
|
+
move_node(spline_picker_node, -100, -100)
|
116
|
+
add_node(patch, spline_picker_node)
|
117
|
+
|
118
|
+
mux_node = build_xmux_node
|
119
|
+
move_node(mux_node, 400, 0)
|
120
|
+
add_node(patch, mux_node)
|
121
|
+
|
122
|
+
spline_nodes.each_with_index do |spline_node, i|
|
123
|
+
wire_output_to_input(patch, spline_node, 0, mux_node, i+1)
|
124
|
+
end
|
125
|
+
|
126
|
+
wire_output_to_input(patch, hertz_node, 0, spline_picker_node, 0)
|
127
|
+
wire_output_to_input(patch, spline_picker_node, 0, mux_node, 0)
|
128
|
+
|
129
|
+
range_scale_node = build_expr_node('x*2-1')
|
130
|
+
move_node(range_scale_node, 600, 0)
|
131
|
+
add_node(patch, range_scale_node)
|
132
|
+
|
133
|
+
wire_output_to_input(patch, mux_node, 0, range_scale_node, 0)
|
134
|
+
|
135
|
+
output_node = build_output_node
|
136
|
+
output_node['name'] = ''
|
137
|
+
move_node(output_node, 1100, 0)
|
138
|
+
expose_node(output_node, 50, 0)
|
139
|
+
add_node(patch, output_node)
|
140
|
+
|
141
|
+
wire_output_to_input(patch, range_scale_node, 0, output_node, 0)
|
142
|
+
|
143
|
+
doc
|
144
|
+
end
|
145
|
+
|
146
|
+
# Given a path to a single-cycle-waveform wav file, generate an Audulus wavetable
|
147
|
+
# node
|
148
|
+
def self.build_patch(patch_data)
|
149
|
+
# build the patch as a full patch
|
150
|
+
base_patch = build_patch_helper(patch_data[:samples], patch_data[:title], patch_data[:subtitle])['patch']
|
151
|
+
|
152
|
+
# wrap it up as a subpatch
|
153
|
+
final_patch = Audulus.make_subpatch(base_patch)
|
154
|
+
|
155
|
+
# write the patch to a file as JSON (the format Audulus uses)
|
156
|
+
File.write(patch_data[:output_path], JSON.generate(final_patch))
|
157
|
+
end
|
158
|
+
|
159
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: build_audulus_wavetable_node
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jimmy Thrasher
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-05-
|
11
|
+
date: 2018-05-17 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: fftw3
|
@@ -24,6 +24,34 @@ dependencies:
|
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: '0.3'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: midilib
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '2.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '2.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: minitest
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '5.11'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '5.11'
|
27
55
|
description: ''
|
28
56
|
email: jimmy@jimmythrasher.com
|
29
57
|
executables:
|
@@ -32,11 +60,19 @@ extensions: []
|
|
32
60
|
extra_rdoc_files: []
|
33
61
|
files:
|
34
62
|
- bin/build_audulus_wavetable_node
|
63
|
+
- lib/antialias.rb
|
35
64
|
- lib/audulus.rb
|
36
65
|
- lib/build_audulus_wavetable_node.rb
|
37
66
|
- lib/clock.json
|
67
|
+
- lib/command.rb
|
68
|
+
- lib/midi_patch.rb
|
38
69
|
- lib/o2Hz.audulus
|
70
|
+
- lib/sample_generator.rb
|
71
|
+
- lib/sox.rb
|
72
|
+
- lib/spline_helper.rb
|
73
|
+
- lib/spline_patch.rb
|
39
74
|
- lib/via.audulus
|
75
|
+
- lib/wavetable_patch.rb
|
40
76
|
- lib/xmux.audulus
|
41
77
|
homepage: https://github.com/jjthrash/audulus_wave_table
|
42
78
|
licenses:
|