build_audulus_wavetable_node 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: dc6cb0de33ce2c8f428e96b62c10cc7f832b6271
4
+ data.tar.gz: 332b33ad78b2864070471bc72210785474b132a1
5
+ SHA512:
6
+ metadata.gz: 74cf1bffa00c88d72e298ce23ca195e90bab647acf20393aba4c878d351c6a03cde15e7cc51bba45ff743354bf269e7bc01e5a08aedcb020fadec6639ef1ea68
7
+ data.tar.gz: 50053a0cd0a593e5334f03db632700a4925482396aa7384d95178233aebef7b87693258a56925eb7f68cd2ec7abaa814ea7ab5eb788cd53b04303281564c5820
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'build_audulus_wavetable_node'
4
+ command(ARGV)
5
+
data/lib/audulus.rb ADDED
@@ -0,0 +1,260 @@
1
+ require 'json'
2
+ require 'yaml'
3
+ require 'securerandom'
4
+
5
+ def uuid?(string)
6
+ 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
7
+ end
8
+
9
+ # Node -> [ UUID ]
10
+ def scan_uuids(node)
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
+ []
24
+ end
25
+ end
26
+
27
+ def build_uuid_map(node)
28
+ existing_uuids = scan_uuids(node)
29
+ existing_uuids.reduce({}) {|h, uuid|
30
+ h[uuid] = SecureRandom.uuid()
31
+ h
32
+ }
33
+ end
34
+
35
+ def clone_node(node)
36
+ uuid_map = build_uuid_map(node)
37
+ clone_node_helper(node, uuid_map)
38
+ end
39
+
40
+ def clone_node_helper(node, uuid_map)
41
+ case node
42
+ when Hash
43
+ Hash[node.map {|key, elem|
44
+ if uuid?(elem)
45
+ [key, uuid_map[elem]]
46
+ else
47
+ [key, clone_node_helper(elem, uuid_map)]
48
+ end
49
+ }]
50
+ when Array
51
+ node.map {|elem|
52
+ clone_node_helper(elem, uuid_map)
53
+ }
54
+ else
55
+ node
56
+ end
57
+ end
58
+
59
+ def add_node(patch, node)
60
+ patch['nodes'] << node
61
+ patch
62
+ end
63
+
64
+ def add_nodes(patch, nodes)
65
+ nodes.each do |node|
66
+ add_node(patch, node)
67
+ end
68
+ end
69
+
70
+ def move_node(node, x, y)
71
+ node['position'] = {
72
+ 'x' => x,
73
+ 'y' => y,
74
+ }
75
+ node
76
+ end
77
+
78
+ def expose_node(node, x, y)
79
+ node['exposedPosition'] = {
80
+ 'x' => x,
81
+ 'y' => y,
82
+ }
83
+ node
84
+ end
85
+
86
+ def wire_output_to_input(patch, source_node, source_output, destination_node, destination_input)
87
+ patch['wires'] << {
88
+ "from" => source_node['id'],
89
+ "output" => source_output,
90
+ "to" => destination_node['id'],
91
+ "input": destination_input
92
+ }
93
+ end
94
+
95
+ def build_init_doc
96
+ result = clone_node(INIT_PATCH)
97
+ result
98
+ end
99
+
100
+ def make_subpatch(subpatch)
101
+ doc = build_init_doc
102
+ patch = doc['patch']
103
+
104
+ subpatch_node = build_subpatch_node
105
+ subpatch_node['subPatch'] = subpatch
106
+ add_node(patch, subpatch_node)
107
+ doc
108
+ end
109
+
110
+ def build_light_node
111
+ clone_node(LIGHT_NODE)
112
+ end
113
+
114
+ def build_trigger_node
115
+ clone_node(TRIGGER_NODE)
116
+ end
117
+
118
+ def build_subpatch_node
119
+ clone_node(SUBPATCH_NODE)
120
+ end
121
+
122
+ # gate output is output 0
123
+ def build_clock_node
124
+ clone_node(CLOCK_NODE)
125
+ end
126
+
127
+ def build_via_node
128
+ clone_node(VIA_NODE)
129
+ end
130
+
131
+ def build_input_node
132
+ clone_node(INPUT_NODE)
133
+ end
134
+
135
+ def build_output_node
136
+ result = build_simple_node("Output")
137
+ result['name'] = "Output"
138
+ result
139
+ end
140
+
141
+ def build_knob_node
142
+ result = build_simple_node("Knob")
143
+ result['knob'] = {
144
+ 'value' => 0.5,
145
+ 'min' => 0.0,
146
+ 'max' => 1.0,
147
+ }
148
+ expose_node(result, 0, 0)
149
+ result
150
+ end
151
+
152
+ def build_sample_and_hold_node
153
+ build_simple_node("Sample & Hold")
154
+ end
155
+
156
+ def build_mux_node
157
+ build_simple_node("Mux8")
158
+ end
159
+
160
+ def build_demux_node
161
+ build_simple_node("Demux8")
162
+ end
163
+
164
+ def build_expr_node(expr)
165
+ result = build_simple_node('Expr')
166
+ result['expr'] = expr
167
+ result
168
+ end
169
+
170
+ def build_text_node(text)
171
+ result = build_simple_node("Text")
172
+ result['text'] = text
173
+ result
174
+ end
175
+
176
+ def build_simple_node(type)
177
+ clone_node({
178
+ "type" => type,
179
+ "id" => "7e5486fc-994c-44f0-ae83-5ebba54d7e3b",
180
+ "position" => {
181
+ "x" => 0,
182
+ "y" => 0
183
+ }
184
+ })
185
+ end
186
+
187
+ INIT_PATCH = YAML.load <<YAML
188
+ ---
189
+ version: 1
190
+ patch:
191
+ id: 2aedc73c-4095-4d1b-ab1b-2121ea9ac19d
192
+ pan:
193
+ x: 0.0
194
+ y: 0.0
195
+ zoom: 1.0
196
+ nodes: []
197
+ wires: []
198
+ YAML
199
+
200
+ LIGHT_NODE = YAML.load <<YAML
201
+ ---
202
+ type: Light
203
+ id: b264602f-365b-48a2-9d25-9b95055a2c34
204
+ position:
205
+ x: 0.0
206
+ y: 0.0
207
+ YAML
208
+
209
+ TRIGGER_NODE = JSON.parse <<JSON
210
+ {
211
+ "type": "Trigger",
212
+ "id": "3e8e612d-3b7a-4c6d-a2ea-2e5f1a00161d",
213
+ "position": {
214
+ "x": 0,
215
+ "y": 0
216
+ },
217
+ "toggle": false,
218
+ "state": false
219
+ }
220
+ JSON
221
+
222
+ INPUT_NODE = JSON.parse <<JSON
223
+ {
224
+ "type": "Input",
225
+ "id": "3e8e612d-3b7a-4c6d-a2ea-2e5f1a00161d",
226
+ "position": {
227
+ "x": 0,
228
+ "y": 0
229
+ },
230
+ "exposedPosition": {
231
+ "x": 0,
232
+ "y": 0
233
+ },
234
+ "name": "input"
235
+ }
236
+ JSON
237
+
238
+ SUBPATCH_NODE = JSON.parse <<JSON
239
+ {
240
+ "type": "Patch",
241
+ "id": "0fe72e0e-2616-4366-8036-f852398d1c73",
242
+ "position": {
243
+ "x": -33.04297,
244
+ "y": -44.77734
245
+ },
246
+ "subPatch": {
247
+ "id": "0e096166-2c2d-4c0e-bce3-f9c5f42ce5c5",
248
+ "pan": {
249
+ "x": 0,
250
+ "y": 0
251
+ },
252
+ "zoom": 1,
253
+ "nodes": [],
254
+ "wires": []
255
+ }
256
+ }
257
+ JSON
258
+
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]
@@ -0,0 +1,290 @@
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 Patch
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_simple_node("Spline")
117
+ spline_node["controlPoints"] = samples.each_with_index.map {|sample, i|
118
+ {
119
+ "x" => i.to_f/(samples.count-1).to_f,
120
+ "y" => (sample+1)/2,
121
+ }
122
+ }
123
+ move_node(spline_node, -100, i*200)
124
+ spline_node
125
+ }
126
+
127
+ add_nodes(patch, spline_nodes)
128
+
129
+ spline_nodes.each do |spline_node|
130
+ wire_output_to_input(patch, domain_scale_node, 0, spline_node, 0)
131
+ end
132
+
133
+ # generate the "picker," the node that determines which wavetable
134
+ # to used based on the desired output frequency
135
+ spline_picker_node = build_expr_node("clamp(log2(hz/55), 0, 8)")
136
+ move_node(spline_picker_node, -100, -100)
137
+ add_node(patch, spline_picker_node)
138
+
139
+ mux_node = build_xmux_node
140
+ move_node(mux_node, 400, 0)
141
+ add_node(patch, mux_node)
142
+
143
+ spline_nodes.each_with_index do |spline_node, i|
144
+ wire_output_to_input(patch, spline_node, 0, mux_node, i+1)
145
+ end
146
+
147
+ wire_output_to_input(patch, hertz_node, 0, spline_picker_node, 0)
148
+ wire_output_to_input(patch, spline_picker_node, 0, mux_node, 0)
149
+
150
+ range_scale_node = build_expr_node('x*2-1')
151
+ move_node(range_scale_node, 600, 0)
152
+ add_node(patch, range_scale_node)
153
+
154
+ wire_output_to_input(patch, mux_node, 0, range_scale_node, 0)
155
+
156
+ output_node = build_output_node
157
+ output_node['name'] = ''
158
+ move_node(output_node, 1100, 0)
159
+ expose_node(output_node, 50, 0)
160
+ add_node(patch, output_node)
161
+
162
+ wire_output_to_input(patch, range_scale_node, 0, output_node, 0)
163
+
164
+ doc
165
+ end
166
+
167
+ XMUX_NODE = JSON.parse(File.read(File.join(File.dirname(__FILE__), 'xmux.audulus')))['patch']['nodes'][0]
168
+ def self.build_xmux_node
169
+ clone_node(XMUX_NODE)
170
+ end
171
+
172
+ O2HZ_NODE = JSON.parse(File.read(File.join(File.dirname(__FILE__), 'o2Hz.audulus')))['patch']['nodes'][0]
173
+ def self.build_o2hz_node
174
+ clone_node(O2HZ_NODE)
175
+ end
176
+ end
177
+
178
+ class Resample
179
+ require 'fftw3'
180
+ # sample_rate, Hz, e.g. 44100
181
+ # fundamental, Hz, e.g. 440
182
+ # samples: -1..1
183
+ def self.resample_for_fundamental(sample_rate, fundamental, samples)
184
+ fft = FFTW3.fft(NArray[samples]).to_a.flatten
185
+ dampened = dampen_higher_partials(sample_rate, fundamental, fft)
186
+ (FFTW3.ifft(NArray[dampened]) / samples.count).real.to_a.flatten
187
+ end
188
+
189
+ # kill everything higher than a scaled nyquist limit
190
+ # ease in/out everything else to minimize partials near nyquist
191
+ def self.dampen_higher_partials(sample_rate, fundamental, fft)
192
+ nyquist = sample_rate.to_f / 2
193
+ sample_fundamental = sample_rate.to_f / fft.count
194
+ scaled_nyquist = nyquist / fundamental * sample_fundamental
195
+ sample_duration = fft.count.to_f / sample_rate
196
+ sub_nyquist_sample_count = scaled_nyquist * sample_duration
197
+ fft.each_with_index.map {|power, i|
198
+ hz = i.to_f / fft.count * sample_rate.to_f
199
+ if hz < scaled_nyquist
200
+ scale_partial(i, sub_nyquist_sample_count, power)
201
+ else
202
+ 0+0i
203
+ end
204
+ }
205
+ end
206
+
207
+ # dampen partials higher than a certain frequency using a smooth
208
+ # "ease-in-out" shape
209
+ def self.scale_partial(partial_index, partial_count, partial_value)
210
+ partial_value * (Math.cos(partial_index.to_f*Math::PI/2/partial_count)**2)
211
+ end
212
+ end
213
+
214
+ # Given a path to a single-cycle-waveform wav file, generate an Audulus wavetable
215
+ # node
216
+ def build_patch_from_wav_file(path)
217
+ # break the path into directory and path so we can build the audulus file's name
218
+ parent, file = path.split("/")[-2..-1]
219
+
220
+ # load the samples from the WAV file
221
+ samples = Sox.load_samples(path)
222
+
223
+ # build the audulus patch name from the WAV file name
224
+ basename = File.basename(file, ".wav")
225
+ puts "building #{basename}.audulus"
226
+ audulus_patch_name = "#{basename}.audulus"
227
+
228
+ # build the patch as a full patch
229
+ base_patch = Patch.build_patch(samples, parent, basename)['patch']
230
+
231
+ # wrap it up as a subpatch
232
+ final_patch = make_subpatch(base_patch)
233
+
234
+ # write the patch to a file as JSON (the format Audulus uses)
235
+ File.write(audulus_patch_name, JSON.generate(final_patch))
236
+ end
237
+
238
+ # Make a set of random samples. Useful for generating a cyclic
239
+ # noise wavetable. This method would be used in place of loading
240
+ # the WAV file.
241
+ def make_random_samples(count)
242
+ count.times.map {
243
+ rand*2-1
244
+ }
245
+ end
246
+
247
+ # Make a set of samples conforming to a parabola. This method would
248
+ # be used in place of loading the WAV file.
249
+ def make_parabolic_samples(count)
250
+ f = ->(x) { -4*x**2 + 4*x }
251
+ count.times.map {|index|
252
+ index.to_f / (count-1)
253
+ }.map(&f).map {|sample|
254
+ sample*2-1
255
+ }
256
+ end
257
+
258
+ # Given a set of samples, build the Audulus wavetable node
259
+ def build_patch_from_samples(samples, title1, title2, output_path)
260
+ puts "building #{output_path}"
261
+ File.write(output_path, JSON.generate(make_subpatch(Patch.build_patch(samples, title1, title2)['patch'])))
262
+ end
263
+
264
+ def usage
265
+ <<-END
266
+ Usage: build_audulus_wavetable_node <wav_file>
267
+
268
+ Outputs an audulus patch built from the <wav_file>. Assumes the input is monophonic, containing a single-cycle waveform.
269
+ END
270
+ end
271
+
272
+ def command(argv)
273
+ if argv.count != 1
274
+ puts usage
275
+ else
276
+ path = argv[0]
277
+ unless File.exist?(path)
278
+ puts "Cannot find WAV file at #{path}"
279
+ exit(1)
280
+ end
281
+
282
+ build_patch_from_wav_file(path)
283
+ end
284
+ end
285
+
286
+ # This code is the starting point.. if we run this file as
287
+ # its own program, do the following
288
+ if __FILE__ == $0
289
+ command(ARGV)
290
+ end