build_audulus_nodes 0.5.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.
@@ -0,0 +1,179 @@
1
+ require 'optparse'
2
+ require 'csv'
3
+
4
+ require_relative 'audulus'
5
+ require_relative 'sox'
6
+ require_relative 'wavetable_patch'
7
+ require_relative 'spline_patch'
8
+ require_relative 'midi_patch'
9
+ require_relative 'spline_helper'
10
+
11
+ module Command
12
+ def self.build_patch_data_from_wav_file(path, title=nil, subtitle=nil)
13
+ # break the path into directory and path so we can build the audulus file's name
14
+ parent, file = path.split("/")[-2..-1]
15
+
16
+ # load the samples from the WAV file
17
+ samples = Sox.load_samples(path)
18
+
19
+ # build the audulus patch name from the WAV file name
20
+ basename = File.basename(file, ".wav")
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.build_patch_data_from_csv_file(path)
35
+ basename = File.basename(path, ".csv")
36
+ audulus_patch_name = "#{basename}.audulus"
37
+
38
+ coordinates = normalize_coordinates(CSV.readlines(path))
39
+
40
+ results = { :output_path => audulus_patch_name,
41
+ :coordinates => coordinates }
42
+ end
43
+
44
+ def self.normalize_coordinates(coordinates)
45
+ floats = coordinates.map {|strings| strings.map(&:to_f)}
46
+ sorted = floats.sort_by(&:first)
47
+ min_x = sorted.map(&:first).min
48
+ max_x = sorted.map(&:first).max
49
+ min_y = sorted.map(&:last).min
50
+ max_y = sorted.map(&:last).max
51
+
52
+ sorted.map {|x, y|
53
+ [(x - min_x)/(max_x - min_x),
54
+ (y - min_y)/(max_y - min_y)]
55
+ }
56
+ end
57
+
58
+ def self.parse_arguments!(argv)
59
+ results = {
60
+ :spline_only => false
61
+ }
62
+ option_parser = OptionParser.new do |opts|
63
+ opts.banner = "#{$0} [OPTIONS] INPUT_FILE"
64
+
65
+ opts.on("-h", "--help", "Prints this help") do
66
+ results[:help] = opts.help
67
+ end
68
+
69
+ opts.on("-tTITLE", "--title=TITLE", "provide a title for the patch (defaults to parent directory)") do |t|
70
+ results[:title] = t
71
+ end
72
+
73
+ opts.on("-uSUBTITLE", "--subtitle=SUBTITLE", "provide a subtitle for the patch (defaults to file name, minus .wav)") do |u|
74
+ results[:subtitle] = u
75
+ end
76
+ end
77
+
78
+ option_parser.parse!(argv)
79
+ if argv.count != 1
80
+ results = {
81
+ :help => option_parser.help
82
+ }
83
+ end
84
+
85
+ results[:input_filename] = argv[0]
86
+
87
+ results
88
+ end
89
+
90
+ def self.run_build_wavetable_node(argv)
91
+ options = parse_arguments!(argv)
92
+ handle_base_options(options)
93
+
94
+ patch_data = build_patch_data_from_wav_file(options[:input_filename], options[:title], options[:subtitle])
95
+ WavetablePatch.build_patch(patch_data)
96
+ end
97
+
98
+ def self.parse_midi_arguments!(argv)
99
+ results = {}
100
+
101
+ option_parser = OptionParser.new do |opts|
102
+ opts.banner = "#{$0} [OPTIONS] INPUT_FILE"
103
+
104
+ opts.on("-h", "--help", "Prints this help") do
105
+ results[:help] = opts.help
106
+ end
107
+ end
108
+
109
+ option_parser.parse!(argv)
110
+ if argv.count != 1
111
+ results = {
112
+ :help => option_parser.help
113
+ }
114
+ end
115
+
116
+ results[:input_filename] = argv[0]
117
+
118
+ results
119
+ end
120
+
121
+ def self.run_build_midi_node(argv)
122
+ options = parse_midi_arguments!(argv)
123
+ handle_base_options(options)
124
+ MidiPatch.build_patch(options[:input_filename])
125
+ end
126
+
127
+ def self.parse_spline_arguments!(argv)
128
+ results = {}
129
+
130
+ option_parser = OptionParser.new do |opts|
131
+ opts.banner = "#{$0} [OPTIONS] INPUT_FILE"
132
+
133
+ opts.on("-h", "--help", "Prints this help") do
134
+ results[:help] = opts.help
135
+ end
136
+
137
+ opts.on("-c", "--csv", "Interpret the input file as CSV, not WAV") do
138
+ results[:csv] = true
139
+ end
140
+ end
141
+
142
+ option_parser.parse!(argv)
143
+ if argv.count != 1
144
+ results = {
145
+ :help => option_parser.help
146
+ }
147
+ end
148
+
149
+ results[:input_filename] = argv[0]
150
+
151
+ results
152
+ end
153
+
154
+ def self.run_build_spline_node(argv)
155
+ options = parse_spline_arguments!(argv)
156
+ handle_base_options(options)
157
+ if options[:csv]
158
+ patch_data = build_patch_data_from_csv_file(options[:input_filename])
159
+ else
160
+ patch_data = build_patch_data_from_wav_file(options[:input_filename])
161
+ end
162
+ SplinePatch.build_patch(patch_data)
163
+ end
164
+
165
+ def self.handle_base_options(options)
166
+ if options.has_key?(:help)
167
+ puts options[:help]
168
+ exit(0)
169
+ end
170
+
171
+ path = options[:input_filename]
172
+ unless File.exist?(path)
173
+ puts "Cannot find file at #{path}"
174
+ exit(1)
175
+ end
176
+
177
+ options
178
+ end
179
+ end
@@ -0,0 +1,118 @@
1
+ require 'midilib'
2
+ require_relative 'audulus'
3
+ require_relative 'spline_helper'
4
+ require 'pry'
5
+
6
+ module MidiPatch
7
+ STEPWISE_X_DELTA = 0.0001
8
+
9
+ def self.build_patch(path)
10
+ seq = MIDI::Sequence.new()
11
+
12
+ File.open(path, 'rb') do |file|
13
+ seq.read(file)
14
+ end
15
+
16
+ doc = Audulus.build_init_doc
17
+ patch = doc['patch']
18
+
19
+ pitch_node = build_pitch_node(seq)
20
+ Audulus.add_node(patch, pitch_node)
21
+ Audulus.move_node(pitch_node, 0, 0)
22
+
23
+ scaler_node = Audulus.build_expr_node('x*10-5')
24
+ Audulus.add_node(patch, scaler_node)
25
+ Audulus.move_node(scaler_node, 425, 55)
26
+ Audulus.wire_output_to_input(patch, pitch_node, 0, scaler_node, 0)
27
+
28
+ pitch_label = Audulus.build_text_node("pitch")
29
+ Audulus.add_node(patch, pitch_label)
30
+ Audulus.move_node(pitch_label, -50, 100)
31
+
32
+ gate_node = build_gate_node(seq)
33
+ Audulus.add_node(patch, gate_node)
34
+ Audulus.move_node(gate_node, 0, 200)
35
+
36
+ pitch_label = Audulus.build_text_node("gate")
37
+ Audulus.add_node(patch, pitch_label)
38
+ Audulus.move_node(pitch_label, -50, 300)
39
+
40
+ _, file = File.expand_path(path).split("/")[-2..-1]
41
+ basename = File.basename(file, ".mid")
42
+ File.write("#{basename}.audulus", JSON.generate(doc))
43
+ end
44
+
45
+ def self.build_pitch_node(seq)
46
+ note_on_events = seq.first.select {|e| MIDI::NoteOn === e}
47
+
48
+ times = note_on_events.map(&:time_from_start)
49
+ min_time, max_time = [times.min, times.max].map(&:to_f)
50
+ scaled_times = times.map {|time|
51
+ scale_time(time, min_time, max_time)
52
+ }
53
+
54
+ notes = note_on_events.map(&:note)
55
+ scaled_notes = notes.map {|note|
56
+ one_per_octave = (note.to_f - 69.0)/12.0
57
+ (one_per_octave+5)/10 # scale to be > 0
58
+ }
59
+
60
+ coordinates = scaled_times.zip(scaled_notes)
61
+ stepwise_coordinates = make_stepwise(coordinates)
62
+ SplineHelper.build_spline_node_from_coordinates(stepwise_coordinates)
63
+ end
64
+
65
+ def self.build_gate_node(seq)
66
+ note_on_off_events = seq.first.select {|e| MIDI::NoteOn === e || MIDI::NoteOff === e}
67
+
68
+ note_stack = []
69
+
70
+ times = note_on_off_events.map(&:time_from_start)
71
+ min_time, max_time = [times.min, times.max].map(&:to_f)
72
+ coordinates = note_on_off_events.map {|event|
73
+ time = scale_time(event.time_from_start, min_time, max_time)
74
+ case event
75
+ when MIDI::NoteOn
76
+ add_to_stack(note_stack, event)
77
+ [time, scale_velocity(event.velocity)]
78
+ when MIDI::NoteOff
79
+ remove_from_stack(note_stack, event)
80
+ if note_stack.empty?
81
+ [time, 0.0]
82
+ else
83
+ [time, scale_velocity(note_stack[-1].velocity)]
84
+ end
85
+ end
86
+ }
87
+
88
+ stepwise_coordinates = make_stepwise(coordinates)
89
+ SplineHelper.build_spline_node_from_coordinates(stepwise_coordinates)
90
+ end
91
+
92
+ def self.scale_time(time, min_time, max_time)
93
+ (time - min_time).to_f / (max_time - min_time).to_f
94
+ end
95
+
96
+ def self.scale_velocity(velocity)
97
+ velocity.to_f / 127.0
98
+ end
99
+
100
+ def self.add_to_stack(stack, event)
101
+ stack << event
102
+ end
103
+
104
+ def self.remove_from_stack(stack, event)
105
+ stack.delete_if {|e| e.note == event.note}
106
+ end
107
+
108
+ def self.make_stepwise(coordinates)
109
+ last_y = nil
110
+ coordinates.flat_map {|x, y|
111
+ result = []
112
+ result << [x-STEPWISE_X_DELTA, last_y] unless last_y.nil?
113
+ result << [x, y]
114
+ last_y = y
115
+ result
116
+ }
117
+ end
118
+ end
@@ -0,0 +1 @@
1
+ {"version":1,"patch":{"id":"866f52fd-60fe-4d50-9b21-47037516ecc0","pan":{"x":0.0,"y":0.0},"zoom":1.0,"nodes":[{"type":"Patch","id":"b466f3bf-8c76-42a4-86f5-ae9690dc48b0","position":{"x":0.0,"y":0.0},"subPatch":{"id":"67324aa6-f27a-41e4-a695-293cfc6c165c","pan":{"x":44.87227,"y":-127.11619},"zoom":1.08962,"nodes":[{"type":"Text","id":"a1b78696-87d6-4619-9982-adf41f919602","position":{"x":-68.38974,"y":186.83118},"exposedPosition":{"x":5.0,"y":-75.0},"text":"o2Hz","width":121.66254},{"type":"Input","id":"fb4e2398-c487-4ab7-ab6d-ca62dab2bdd7","position":{"x":-285.8692,"y":21.53328},"name":"","exposedPosition":{"x":-15.0,"y":-75.0}},{"type":"Output","id":"43cb2b53-8c5b-4d58-8652-22e7a9eab2e3","position":{"x":64.12784,"y":21.37727},"name":"","exposedPosition":{"x":50.0,"y":-75.0}},{"type":"Text","id":"cf932ecc-8d3e-4499-b08a-3d81369f73e3","position":{"x":165.173,"y":44.48822},"exposedPosition":{"x":45.0,"y":-75.0},"text":"Hz","width":256.0},{"type":"Text","id":"81b6f7ed-f744-495f-ad83-7a4ad746f51d","position":{"x":-323.56317,"y":42.14087},"exposedPosition":{"x":-20.0,"y":-75.0},"text":"o","width":256.0},{"type":"Expr","id":"bd81df0d-069d-41bf-996d-853457d7f208","position":{"x":-265.36447,"y":70.76501},"expr":"440"},{"type":"Expr","id":"bee65669-e211-498b-93f8-37c20e9e8001","position":{"x":-169.25708,"y":41.39349},"expr":"exp2(o)*RefHz"}],"wires":[{"from":"bee65669-e211-498b-93f8-37c20e9e8001","output":0,"to":"43cb2b53-8c5b-4d58-8652-22e7a9eab2e3","input":0},{"from":"fb4e2398-c487-4ab7-ab6d-ca62dab2bdd7","output":0,"to":"bee65669-e211-498b-93f8-37c20e9e8001","input":0},{"from":"bd81df0d-069d-41bf-996d-853457d7f208","output":0,"to":"bee65669-e211-498b-93f8-37c20e9e8001","input":1}]}}],"wires":[]}}
@@ -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,18 @@
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
+ spline_node =
8
+ if patch_data[:samples]
9
+ scaled_samples = patch_data[:samples].map {|sample| (sample.to_f + 1.0)/2.0}
10
+ SplineHelper.build_spline_node_from_samples(scaled_samples)
11
+ elsif patch_data[:coordinates]
12
+ SplineHelper.build_spline_node_from_coordinates(patch_data[:coordinates])
13
+ end
14
+ Audulus.add_node(patch, spline_node)
15
+
16
+ File.write(patch_data[:output_path], JSON.generate(doc))
17
+ end
18
+ end
@@ -0,0 +1 @@
1
+ {"version":1,"patch":{"id":"0e175c84-19b3-4de6-82d6-6e9bee5bceb4","pan":{"x":0.0,"y":0.0},"zoom":1.0,"nodes":[{"type":"Patch","id":"49a0be22-6a99-4381-a45a-ec98e8e68f5a","position":{"x":0.0,"y":0.0},"subPatch":{"id":"ca4bc506-54d0-4131-b83e-e92020914529","pan":{"x":56.28666,"y":48.45356},"zoom":1.7409,"nodes":[{"type":"Input","id":"a5c804e2-53a4-4cf0-af0f-31d9d48edf27","position":{"x":-138.01346,"y":-29.97073},"name":"","exposedPosition":{"x":-25.0,"y":-10.0}},{"type":"Output","id":"296a7cd7-7ea5-423c-996e-4472b890284e","position":{"x":-43.0237,"y":-28.49051},"name":"","exposedPosition":{"x":20.0,"y":-10.0}},{"type":"Text","id":"ee6b6fa7-2be2-41b6-ba01-5f6d7b69cef0","position":{"x":-113.80623,"y":-58.93321},"exposedPosition":{"x":-30.0,"y":-10.0},"text":">","width":256.0},{"type":"Text","id":"ee2f36b6-05f7-423f-939f-3e163257f78d","position":{"x":-22.11625,"y":-53.30904},"exposedPosition":{"x":15.0,"y":-10.0},"text":">","width":256.0}],"wires":[{"from":"a5c804e2-53a4-4cf0-af0f-31d9d48edf27","output":0,"to":"296a7cd7-7ea5-423c-996e-4472b890284e","input":0}]}}],"wires":[]}}
@@ -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