build_audulus_wavetable_node 0.1.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 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