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 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: