build_audulus_wavetable_node 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: d02df36b06e903541294901c553cef80d7830f41
4
- data.tar.gz: 281c8617db7cbd34ece938b92fb0a9f20acf3e43
3
+ metadata.gz: e3643e058d04f602f3abc06cc1546396fa81aa13
4
+ data.tar.gz: 633181be59ed58b990ab26f10b2d5cf8f4e2d3db
5
5
  SHA512:
6
- metadata.gz: c23714863dca46f257e47356b52b58af7a48038c4ab5ee93e631ec226e7c12bd74d585a30c2ffb222d62157a5e0fc6e183150fe0faa0bf496db8168ad0edb1ec
7
- data.tar.gz: ed944ee19c0ee119dda2429b62a9be2245037a6ad70e205c3b9604cc82313f7d061f85bdd30253b992829867ed85244867c07d64b88aea47b067a40c85178c18
6
+ metadata.gz: c3a585230dbd27317319aca2fff9088745e66d3171f1bbf45f906ada2180b1e16367c71ba3f893c59fed00be68542419ef8f56ba20614d4788ecc90b59958a46
7
+ data.tar.gz: cdba3131032642ec1c337b7dc82300a370aa3cf38476b1a16c36d560462a5f9680d85a35bdcfa5e51f7cdeb9cb8acd82707e3aaabce2efa9f9451e7c1218b8ce
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require 'build_audulus_wavetable_node'
4
- command(ARGV)
3
+ require 'command'
4
+ Command.run(ARGV)
5
5
 
@@ -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
+
@@ -2,189 +2,202 @@ require 'json'
2
2
  require 'yaml'
3
3
  require 'securerandom'
4
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
5
+ module Audulus
6
+ module_function
8
7
 
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
- []
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
- 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
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 clone_node(node)
36
- uuid_map = build_uuid_map(node)
37
- clone_node_helper(node, uuid_map)
38
- end
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 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)
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 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)
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 move_node(node, x, y)
71
- node['position'] = {
72
- 'x' => x,
73
- 'y' => y,
74
- }
75
- node
76
- end
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 expose_node(node, x, y)
79
- node['exposedPosition'] = {
80
- 'x' => x,
81
- 'y' => y,
82
- }
83
- node
84
- end
98
+ def build_init_doc
99
+ result = clone_node(INIT_PATCH)
100
+ result
101
+ end
85
102
 
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
103
+ def make_subpatch(subpatch)
104
+ doc = build_init_doc
105
+ patch = doc['patch']
94
106
 
95
- def build_init_doc
96
- result = clone_node(INIT_PATCH)
97
- result
98
- end
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 make_subpatch(subpatch)
101
- doc = build_init_doc
102
- patch = doc['patch']
113
+ def build_light_node
114
+ clone_node(LIGHT_NODE)
115
+ end
103
116
 
104
- subpatch_node = build_subpatch_node
105
- subpatch_node['subPatch'] = subpatch
106
- add_node(patch, subpatch_node)
107
- doc
108
- end
117
+ def build_trigger_node
118
+ clone_node(TRIGGER_NODE)
119
+ end
109
120
 
110
- def build_light_node
111
- clone_node(LIGHT_NODE)
112
- end
121
+ def build_subpatch_node
122
+ clone_node(SUBPATCH_NODE)
123
+ end
113
124
 
114
- def build_trigger_node
115
- clone_node(TRIGGER_NODE)
116
- end
125
+ # gate output is output 0
126
+ def build_clock_node
127
+ clone_node(CLOCK_NODE)
128
+ end
117
129
 
118
- def build_subpatch_node
119
- clone_node(SUBPATCH_NODE)
120
- end
130
+ def build_via_node
131
+ clone_node(VIA_NODE)
132
+ end
121
133
 
122
- # gate output is output 0
123
- def build_clock_node
124
- clone_node(CLOCK_NODE)
125
- end
134
+ def build_input_node
135
+ clone_node(INPUT_NODE)
136
+ end
126
137
 
127
- def build_via_node
128
- clone_node(VIA_NODE)
129
- end
138
+ def build_output_node
139
+ result = build_simple_node("Output")
140
+ result['name'] = "Output"
141
+ result
142
+ end
130
143
 
131
- def build_input_node
132
- clone_node(INPUT_NODE)
133
- end
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 build_output_node
136
- result = build_simple_node("Output")
137
- result['name'] = "Output"
138
- result
139
- end
155
+ def build_sample_and_hold_node
156
+ build_simple_node("Sample & Hold")
157
+ end
140
158
 
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
159
+ def build_mux_node
160
+ build_simple_node("Mux8")
161
+ end
151
162
 
152
- def build_sample_and_hold_node
153
- build_simple_node("Sample & Hold")
154
- end
163
+ def build_demux_node
164
+ build_simple_node("Demux8")
165
+ end
155
166
 
156
- def build_mux_node
157
- build_simple_node("Mux8")
158
- end
167
+ def build_expr_node(expr)
168
+ result = build_simple_node('Expr')
169
+ result['expr'] = expr
170
+ result
171
+ end
159
172
 
160
- def build_demux_node
161
- build_simple_node("Demux8")
162
- end
173
+ def build_text_node(text)
174
+ result = build_simple_node("Text")
175
+ result['text'] = text
176
+ result
177
+ end
163
178
 
164
- def build_expr_node(expr)
165
- result = build_simple_node('Expr')
166
- result['expr'] = expr
167
- result
168
- end
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
- def build_text_node(text)
171
- result = build_simple_node("Text")
172
- result['text'] = text
173
- result
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
- 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
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 <<YAML
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 <<YAML
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 <<JSON
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 <<JSON
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 <<JSON
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"
@@ -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
@@ -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
@@ -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
@@ -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.2.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-16 00:00:00.000000000 Z
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: