wavify 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +40 -1
- data/README.md +204 -22
- data/exe/wavify +6 -0
- data/lib/wavify/adapters.rb +81 -0
- data/lib/wavify/audio.rb +1066 -120
- data/lib/wavify/cli.rb +237 -0
- data/lib/wavify/codecs/aiff.rb +388 -87
- data/lib/wavify/codecs/base.rb +98 -5
- data/lib/wavify/codecs/flac.rb +660 -170
- data/lib/wavify/codecs/ogg_vorbis.rb +515 -155
- data/lib/wavify/codecs/raw.rb +235 -45
- data/lib/wavify/codecs/registry.rb +272 -10
- data/lib/wavify/codecs/wav.rb +453 -74
- data/lib/wavify/core/duration.rb +54 -2
- data/lib/wavify/core/format.rb +96 -11
- data/lib/wavify/core/sample_buffer.rb +648 -60
- data/lib/wavify/core/stream.rb +590 -34
- data/lib/wavify/dsl.rb +712 -100
- data/lib/wavify/dsp/automation.rb +109 -0
- data/lib/wavify/dsp/effects/auto_pan.rb +71 -0
- data/lib/wavify/dsp/effects/bitcrusher.rb +74 -0
- data/lib/wavify/dsp/effects/chorus.rb +15 -4
- data/lib/wavify/dsp/effects/compressor.rb +133 -23
- data/lib/wavify/dsp/effects/delay.rb +46 -1
- data/lib/wavify/dsp/effects/distortion.rb +3 -2
- data/lib/wavify/dsp/effects/effect_base.rb +69 -2
- data/lib/wavify/dsp/effects/effect_chain.rb +94 -0
- data/lib/wavify/dsp/effects/envelope_controlled_effect.rb +93 -0
- data/lib/wavify/dsp/effects/eq.rb +102 -0
- data/lib/wavify/dsp/effects/expander.rb +90 -0
- data/lib/wavify/dsp/effects/flanger.rb +112 -0
- data/lib/wavify/dsp/effects/limiter.rb +259 -0
- data/lib/wavify/dsp/effects/mastering_chain.rb +31 -0
- data/lib/wavify/dsp/effects/noise_gate.rb +72 -0
- data/lib/wavify/dsp/effects/phaser.rb +112 -0
- data/lib/wavify/dsp/effects/podcast_chain.rb +32 -0
- data/lib/wavify/dsp/effects/reverb.rb +100 -14
- data/lib/wavify/dsp/effects/soft_limiter.rb +55 -0
- data/lib/wavify/dsp/effects/stereo_widener.rb +73 -0
- data/lib/wavify/dsp/effects/tremolo.rb +66 -0
- data/lib/wavify/dsp/effects/vibrato.rb +102 -0
- data/lib/wavify/dsp/effects.rb +106 -0
- data/lib/wavify/dsp/envelope.rb +90 -19
- data/lib/wavify/dsp/filter.rb +149 -8
- data/lib/wavify/dsp/headroom.rb +57 -0
- data/lib/wavify/dsp/lfo.rb +65 -0
- data/lib/wavify/dsp/loudness_meter.rb +281 -0
- data/lib/wavify/dsp/oscillator.rb +261 -22
- data/lib/wavify/dsp/processor.rb +90 -0
- data/lib/wavify/errors.rb +2 -2
- data/lib/wavify/sequencer/engine.rb +405 -89
- data/lib/wavify/sequencer/note_sequence.rb +45 -7
- data/lib/wavify/sequencer/pattern.rb +121 -12
- data/lib/wavify/sequencer/track.rb +149 -10
- data/lib/wavify/version.rb +1 -1
- data/lib/wavify.rb +18 -0
- data/sig/audio.rbs +86 -0
- data/sig/codecs.rbs +77 -0
- data/sig/core.rbs +108 -0
- data/sig/dsl.rbs +32 -0
- data/sig/dsp.rbs +87 -0
- data/sig/effects.rbs +129 -0
- data/sig/public_api.yml +111 -0
- data/sig/sequencer.rbs +126 -0
- data/sig/stream.rbs +30 -0
- data/sig/wavify.rbs +24 -0
- metadata +44 -58
- data/.serena/.gitignore +0 -1
- data/.serena/memories/project_overview.md +0 -5
- data/.serena/memories/style_and_completion.md +0 -5
- data/.serena/memories/suggested_commands.md +0 -11
- data/.serena/project.yml +0 -126
- data/.simplecov +0 -18
- data/.yardopts +0 -4
- data/Rakefile +0 -190
- data/benchmarks/README.md +0 -46
- data/benchmarks/benchmark_helper.rb +0 -112
- data/benchmarks/dsp_effects_benchmark.rb +0 -46
- data/benchmarks/flac_benchmark.rb +0 -74
- data/benchmarks/streaming_memory_benchmark.rb +0 -94
- data/benchmarks/wav_io_benchmark.rb +0 -110
- data/examples/audio_processing.rb +0 -73
- data/examples/cinematic_transition.rb +0 -118
- data/examples/drum_machine.rb +0 -74
- data/examples/format_convert.rb +0 -81
- data/examples/hybrid_arrangement.rb +0 -165
- data/examples/streaming_master_chain.rb +0 -129
- data/examples/synth_pad.rb +0 -42
- data/tools/fixture_writer.rb +0 -85
data/lib/wavify/dsl.rb
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
|
|
3
9
|
module Wavify
|
|
4
10
|
# Declarative music-building DSL that compiles to sequencer tracks and audio.
|
|
5
11
|
module DSL
|
|
6
12
|
# Immutable compiled song definition returned by {DSL.build_definition}.
|
|
7
13
|
class SongDefinition
|
|
8
14
|
# Arrangement section metadata (`name`, `bars`, active `tracks`).
|
|
9
|
-
Section = Struct.new(:name, :bars, :tracks, keyword_init: true)
|
|
15
|
+
Section = Struct.new(:name, :bars, :tracks, :repeat, :tempo, :beats_per_bar, :markers, keyword_init: true)
|
|
10
16
|
|
|
11
|
-
attr_reader :format, :tempo, :beats_per_bar, :tracks, :sections
|
|
17
|
+
attr_reader :format, :tempo, :beats_per_bar, :swing, :tracks, :sections, :default_bars, :random_seed
|
|
12
18
|
|
|
13
|
-
def initialize(format:, tempo:, beats_per_bar:, tracks:, sections:)
|
|
19
|
+
def initialize(format:, tempo:, beats_per_bar:, swing:, default_bars:, tracks:, sections:, random_seed:)
|
|
14
20
|
@format = format
|
|
15
21
|
@tempo = tempo
|
|
16
22
|
@beats_per_bar = beats_per_bar
|
|
17
|
-
@
|
|
18
|
-
@
|
|
23
|
+
@swing = swing
|
|
24
|
+
@default_bars = default_bars
|
|
25
|
+
@random_seed = random_seed
|
|
26
|
+
@tracks = tracks.map(&:finalize!).freeze
|
|
27
|
+
@sections = sections.map do |section|
|
|
28
|
+
section.tracks = section.tracks.dup.freeze
|
|
29
|
+
section.markers = section.markers.dup.freeze
|
|
30
|
+
section.freeze
|
|
31
|
+
end.freeze
|
|
32
|
+
freeze
|
|
19
33
|
end
|
|
20
34
|
|
|
21
35
|
def arrangement?
|
|
@@ -29,7 +43,8 @@ module Wavify
|
|
|
29
43
|
Wavify::Sequencer::Engine.new(
|
|
30
44
|
tempo: @tempo,
|
|
31
45
|
format: @format,
|
|
32
|
-
beats_per_bar: @beats_per_bar
|
|
46
|
+
beats_per_bar: @beats_per_bar,
|
|
47
|
+
swing: @swing
|
|
33
48
|
)
|
|
34
49
|
end
|
|
35
50
|
|
|
@@ -37,7 +52,30 @@ module Wavify
|
|
|
37
52
|
#
|
|
38
53
|
# @return [Array<Wavify::Sequencer::Track>]
|
|
39
54
|
def sequencer_tracks
|
|
40
|
-
@tracks.map(
|
|
55
|
+
@tracks.map { |track| with_track_context(track) { track.to_sequencer_track } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Validates the compiled song without rendering audio.
|
|
59
|
+
#
|
|
60
|
+
# @return [true]
|
|
61
|
+
def validate!(deep: false)
|
|
62
|
+
raise Wavify::SequencerError, "song must define at least one track" if @tracks.empty?
|
|
63
|
+
|
|
64
|
+
@tracks.each do |track|
|
|
65
|
+
with_track_context(track) do
|
|
66
|
+
track.to_sequencer_track
|
|
67
|
+
track.sample_pattern_map
|
|
68
|
+
if deep
|
|
69
|
+
track.samples.each_value do |path|
|
|
70
|
+
Wavify::Audio.metadata(path)
|
|
71
|
+
rescue Wavify::Error, SystemCallError => e
|
|
72
|
+
raise Wavify::SequencerError, "failed to inspect sample #{path.inspect}: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
timeline(default_bars: @default_bars)
|
|
78
|
+
true
|
|
41
79
|
end
|
|
42
80
|
|
|
43
81
|
# Returns arrangement sections as hashes accepted by the sequencer engine.
|
|
@@ -45,15 +83,33 @@ module Wavify
|
|
|
45
83
|
# @return [Array<Hash>]
|
|
46
84
|
def arrangement
|
|
47
85
|
@sections.map do |section|
|
|
48
|
-
{
|
|
86
|
+
{
|
|
87
|
+
name: section.name,
|
|
88
|
+
bars: section.bars,
|
|
89
|
+
tracks: section.tracks,
|
|
90
|
+
repeat: section.repeat || 1,
|
|
91
|
+
tempo: section.tempo,
|
|
92
|
+
beats_per_bar: section.beats_per_bar,
|
|
93
|
+
markers: section.markers
|
|
94
|
+
}.freeze
|
|
49
95
|
end
|
|
50
96
|
end
|
|
51
97
|
|
|
98
|
+
# Planned song duration derived from arrangement/default bars.
|
|
99
|
+
#
|
|
100
|
+
# @param default_bars [Integer]
|
|
101
|
+
# @return [Wavify::Core::Duration]
|
|
102
|
+
def duration(default_bars: @default_bars)
|
|
103
|
+
Core::Duration.new(total_seconds(default_bars: default_bars))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
alias length duration
|
|
107
|
+
|
|
52
108
|
# Builds a sequencer event timeline without rendering audio.
|
|
53
109
|
#
|
|
54
110
|
# @param default_bars [Integer]
|
|
55
111
|
# @return [Array<Hash>]
|
|
56
|
-
def timeline(default_bars:
|
|
112
|
+
def timeline(default_bars: @default_bars)
|
|
57
113
|
engine.build_timeline(
|
|
58
114
|
tracks: sequencer_tracks,
|
|
59
115
|
arrangement: arrangement? ? arrangement : nil,
|
|
@@ -61,22 +117,58 @@ module Wavify
|
|
|
61
117
|
)
|
|
62
118
|
end
|
|
63
119
|
|
|
120
|
+
# Serializes the sequencer timeline to JSON for visualization tooling.
|
|
121
|
+
#
|
|
122
|
+
# @param default_bars [Integer]
|
|
123
|
+
# @return [String]
|
|
124
|
+
def timeline_json(default_bars: @default_bars)
|
|
125
|
+
JSON.generate(timeline(default_bars: default_bars))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Renders the sequencer timeline as a compact text table.
|
|
129
|
+
#
|
|
130
|
+
# @param default_bars [Integer]
|
|
131
|
+
# @return [String]
|
|
132
|
+
def timeline_text(default_bars: @default_bars)
|
|
133
|
+
events = timeline(default_bars: default_bars)
|
|
134
|
+
rows = events.map { |event| timeline_text_row(event) }
|
|
135
|
+
(["time\tbar\ttrack\tkind\tdetail"] + rows).join("\n")
|
|
136
|
+
end
|
|
137
|
+
|
|
64
138
|
# Renders the song definition to an {Wavify::Audio} instance.
|
|
65
139
|
#
|
|
66
140
|
# @param default_bars [Integer]
|
|
141
|
+
# @param stems [Boolean] return track-name keyed stems instead of a mix
|
|
67
142
|
# @return [Wavify::Audio]
|
|
68
|
-
def render(default_bars:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
143
|
+
def render(default_bars: @default_bars, stems: false)
|
|
144
|
+
return render_stems(default_bars: default_bars) if stems
|
|
145
|
+
|
|
146
|
+
playable_tracks = sequencer_tracks.select { |track| sequencer_track_playable?(track) }
|
|
147
|
+
sequencer_audio = if playable_tracks.empty?
|
|
148
|
+
Wavify::Audio.silence(0, format: @format)
|
|
149
|
+
else
|
|
150
|
+
playable_names = playable_tracks.map(&:name)
|
|
151
|
+
playable_arrangement = if arrangement?
|
|
152
|
+
arrangement.map do |section|
|
|
153
|
+
section.merge(tracks: section.fetch(:tracks) & playable_names)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
engine.render(
|
|
157
|
+
tracks: playable_tracks,
|
|
158
|
+
arrangement: playable_arrangement,
|
|
159
|
+
default_bars: default_bars
|
|
160
|
+
)
|
|
161
|
+
end
|
|
74
162
|
sample_audio = render_sample_tracks(default_bars: default_bars)
|
|
75
163
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
164
|
+
rendered = if sample_audio.nil?
|
|
165
|
+
sequencer_audio
|
|
166
|
+
elsif sequencer_audio.sample_frame_count.zero?
|
|
167
|
+
sample_audio
|
|
168
|
+
else
|
|
169
|
+
Wavify::Audio.mix(sequencer_audio, sample_audio, strategy: :none)
|
|
170
|
+
end
|
|
171
|
+
pad_to_song_duration(rendered, default_bars: default_bars)
|
|
80
172
|
end
|
|
81
173
|
|
|
82
174
|
# Renders and writes the song to disk.
|
|
@@ -84,23 +176,100 @@ module Wavify
|
|
|
84
176
|
# @param path [String]
|
|
85
177
|
# @param default_bars [Integer]
|
|
86
178
|
# @return [Wavify::Audio]
|
|
87
|
-
def write(path, default_bars:
|
|
179
|
+
def write(path, default_bars: @default_bars)
|
|
88
180
|
render(default_bars: default_bars).write(path)
|
|
89
181
|
end
|
|
90
182
|
|
|
183
|
+
# Renders each track and writes stems as WAV files under a directory.
|
|
184
|
+
#
|
|
185
|
+
# @param directory [String]
|
|
186
|
+
# @param default_bars [Integer]
|
|
187
|
+
# @param overwrite [Boolean]
|
|
188
|
+
# @return [Hash<Symbol, String>]
|
|
189
|
+
def write_stems(directory, default_bars: @default_bars, overwrite: true)
|
|
190
|
+
raise Wavify::InvalidParameterError, "directory must be a String" unless directory.is_a?(String)
|
|
191
|
+
|
|
192
|
+
expanded_directory = File.expand_path(directory)
|
|
193
|
+
FileUtils.mkdir_p(File.dirname(expanded_directory))
|
|
194
|
+
stems = render(default_bars: default_bars, stems: true)
|
|
195
|
+
filenames = stems.keys.to_h { |track_name| [track_name, stem_filename(track_name)] }
|
|
196
|
+
if filenames.values.uniq.length != filenames.length
|
|
197
|
+
raise Wavify::SequencerError, "track names produce duplicate stem filenames"
|
|
198
|
+
end
|
|
199
|
+
paths = filenames.transform_values { |filename| File.join(expanded_directory, filename) }
|
|
200
|
+
if !overwrite && (existing = paths.values.find { |path| File.exist?(path) })
|
|
201
|
+
raise Wavify::InvalidParameterError, "output file already exists: #{existing}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
Dir.mktmpdir(".wavify-stems-", File.dirname(expanded_directory)) do |temporary_directory|
|
|
205
|
+
stems.each do |track_name, audio|
|
|
206
|
+
audio.write(File.join(temporary_directory, filenames.fetch(track_name)))
|
|
207
|
+
end
|
|
208
|
+
FileUtils.mkdir_p(expanded_directory)
|
|
209
|
+
paths.each do |track_name, path|
|
|
210
|
+
File.rename(File.join(temporary_directory, filenames.fetch(track_name)), path)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
paths
|
|
214
|
+
end
|
|
215
|
+
|
|
91
216
|
private
|
|
92
217
|
|
|
218
|
+
def render_stems(default_bars:)
|
|
219
|
+
@tracks.each_with_object({}) do |track, stems|
|
|
220
|
+
audio = render_track_stem(track, default_bars: default_bars)
|
|
221
|
+
stems[track.name] = pad_to_song_duration(audio, default_bars: default_bars)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def pad_to_song_duration(audio, default_bars:)
|
|
226
|
+
target_frames = (duration(default_bars: default_bars).total_seconds * audio.sample_rate).round
|
|
227
|
+
missing_frames = target_frames - audio.sample_frame_count
|
|
228
|
+
return audio unless missing_frames.positive?
|
|
229
|
+
|
|
230
|
+
audio.pad_end(missing_frames.to_f / audio.sample_rate)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def render_track_stem(track, default_bars:)
|
|
234
|
+
sequencer_audio = render_sequencer_track_stem(track, default_bars: default_bars)
|
|
235
|
+
sample_audio = render_sample_track(track, default_bars: default_bars)
|
|
236
|
+
|
|
237
|
+
if sample_audio && sequencer_audio.sample_frame_count.positive?
|
|
238
|
+
Wavify::Audio.mix(sequencer_audio, sample_audio, strategy: :none)
|
|
239
|
+
else
|
|
240
|
+
sample_audio || sequencer_audio
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def render_sequencer_track_stem(track, default_bars:)
|
|
245
|
+
arrangement_for_stem = arrangement? ? arrangement_for_track(track.name) : nil
|
|
246
|
+
sequencer_track = track.to_sequencer_track
|
|
247
|
+
unless sequencer_track.note_sequence || sequencer_track.chord_progression
|
|
248
|
+
return Wavify::Audio.silence(0, format: @format)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
engine.render(
|
|
252
|
+
tracks: [sequencer_track],
|
|
253
|
+
arrangement: arrangement_for_stem,
|
|
254
|
+
default_bars: default_bars
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def sequencer_track_playable?(track)
|
|
259
|
+
track.note_sequence || track.chord_progression
|
|
260
|
+
end
|
|
261
|
+
|
|
93
262
|
def render_sample_tracks(default_bars:)
|
|
94
263
|
rendered_tracks = @tracks.filter_map do |track|
|
|
95
264
|
render_sample_track(track, default_bars: default_bars)
|
|
96
265
|
end
|
|
97
266
|
return nil if rendered_tracks.empty?
|
|
98
267
|
|
|
99
|
-
Wavify::Audio.mix(*rendered_tracks)
|
|
268
|
+
Wavify::Audio.mix(*rendered_tracks, strategy: :none)
|
|
100
269
|
end
|
|
101
270
|
|
|
102
271
|
def render_sample_track(track, default_bars:)
|
|
103
|
-
patterns = track.sample_pattern_map
|
|
272
|
+
patterns = with_track_context(track) { track.sample_pattern_map }
|
|
104
273
|
return nil if patterns.empty?
|
|
105
274
|
|
|
106
275
|
sections = active_sections_for(track.name, default_bars: default_bars)
|
|
@@ -108,45 +277,48 @@ module Wavify
|
|
|
108
277
|
|
|
109
278
|
work_format = track_render_work_format
|
|
110
279
|
sample_cache = {}
|
|
111
|
-
|
|
280
|
+
mixed = []
|
|
281
|
+
event_count = 0
|
|
282
|
+
random = Random.new(derived_track_seed(track.name))
|
|
112
283
|
|
|
113
284
|
sections.each do |section|
|
|
285
|
+
section_engine = engine_for_section(section)
|
|
114
286
|
patterns.each do |sample_key, pattern|
|
|
115
287
|
sample_audio = sample_cache[sample_key] ||= load_sample_audio(track, sample_key, work_format)
|
|
116
|
-
step_duration = engine.step_duration_seconds(pattern.length)
|
|
117
288
|
|
|
118
289
|
(0...section.fetch(:bars)).each do |bar_offset|
|
|
119
|
-
|
|
120
|
-
bar_base_time = absolute_bar * engine.bar_duration_seconds
|
|
290
|
+
bar_base_time = section.fetch(:start_time) + (bar_offset * section_engine.bar_duration_seconds)
|
|
121
291
|
|
|
122
292
|
pattern.each do |step|
|
|
123
293
|
next unless step.trigger?
|
|
124
294
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
295
|
+
start_time = bar_base_time + section_engine.step_start_seconds(step.index, pattern.resolution)
|
|
296
|
+
duration = section_engine.step_duration_at(step.index, pattern.resolution)
|
|
297
|
+
section_engine.expand_pattern_step(step, start_time: start_time, duration: duration).each do |expanded|
|
|
298
|
+
next unless probability_hit?(expanded.fetch(:probability), random)
|
|
299
|
+
|
|
300
|
+
event_count += 1
|
|
301
|
+
if event_count > Wavify::Sequencer::Engine::MAX_EVENTS
|
|
302
|
+
raise Wavify::SequencerError, "sample track exceeds #{Wavify::Sequencer::Engine::MAX_EVENTS} events"
|
|
303
|
+
end
|
|
304
|
+
event = expanded.merge(sample_key: sample_key, sample_audio: sample_audio)
|
|
305
|
+
required_length = ((expanded.fetch(:start_time) * work_format.sample_rate).round * work_format.channels) +
|
|
306
|
+
sample_audio.buffer.samples.length
|
|
307
|
+
mixed.concat(Array.new(required_length - mixed.length, 0.0)) if required_length > mixed.length
|
|
308
|
+
overlay_sample_event!(mixed, event, work_format)
|
|
309
|
+
end
|
|
131
310
|
end
|
|
132
311
|
end
|
|
133
312
|
end
|
|
134
313
|
end
|
|
135
314
|
|
|
136
|
-
return nil if
|
|
315
|
+
return nil if event_count.zero?
|
|
137
316
|
|
|
138
|
-
total_end_time = events.map { |event| event[:start_time] + event.fetch(:sample_audio).duration.total_seconds }.max || 0.0
|
|
139
|
-
base_audio = Wavify::Audio.silence(total_end_time, format: work_format)
|
|
140
|
-
mixed = base_audio.buffer.samples.dup
|
|
141
|
-
|
|
142
|
-
events.each do |event|
|
|
143
|
-
overlay_sample_event!(mixed, event, work_format)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
mixed.map! { |sample| sample.clamp(-1.0, 1.0) }
|
|
147
317
|
rendered = Wavify::Audio.new(Wavify::Core::SampleBuffer.new(mixed, work_format))
|
|
148
318
|
rendered = rendered.gain(track.gain_db) if track.gain_db != 0.0
|
|
149
|
-
|
|
319
|
+
if track.pan_position != 0.0
|
|
320
|
+
rendered = rendered.channels == 1 ? rendered.pan(track.pan_position) : rendered.balance(track.pan_position)
|
|
321
|
+
end
|
|
150
322
|
rendered = track.effect_processors.reduce(rendered) { |audio, effect| audio.apply(effect) }
|
|
151
323
|
rendered.convert(@format)
|
|
152
324
|
end
|
|
@@ -154,15 +326,35 @@ module Wavify
|
|
|
154
326
|
def active_sections_for(track_name, default_bars:)
|
|
155
327
|
if arrangement?
|
|
156
328
|
cursor_bar = 0
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
329
|
+
cursor_time = 0.0
|
|
330
|
+
expanded_arrangement.each_with_object([]) do |section, result|
|
|
331
|
+
section_engine = engine_for_section(section)
|
|
332
|
+
if section.fetch(:tracks).include?(track_name)
|
|
333
|
+
result << {
|
|
334
|
+
bars: section.fetch(:bars),
|
|
335
|
+
start_bar: cursor_bar,
|
|
336
|
+
start_time: cursor_time,
|
|
337
|
+
tempo: section.fetch(:tempo),
|
|
338
|
+
beats_per_bar: section.fetch(:beats_per_bar)
|
|
339
|
+
}
|
|
340
|
+
end
|
|
341
|
+
cursor_bar += section.fetch(:bars)
|
|
342
|
+
cursor_time += section.fetch(:bars) * section_engine.bar_duration_seconds
|
|
160
343
|
end
|
|
161
344
|
else
|
|
162
|
-
[{ bars: default_bars, start_bar: 0 }]
|
|
345
|
+
[{ bars: default_bars, start_bar: 0, start_time: 0.0, tempo: @tempo, beats_per_bar: @beats_per_bar }]
|
|
163
346
|
end
|
|
164
347
|
end
|
|
165
348
|
|
|
349
|
+
def engine_for_section(section)
|
|
350
|
+
Wavify::Sequencer::Engine.new(
|
|
351
|
+
tempo: section.fetch(:tempo) || @tempo,
|
|
352
|
+
format: @format,
|
|
353
|
+
beats_per_bar: section.fetch(:beats_per_bar) || @beats_per_bar,
|
|
354
|
+
swing: @swing
|
|
355
|
+
)
|
|
356
|
+
end
|
|
357
|
+
|
|
166
358
|
def track_render_work_format
|
|
167
359
|
base = @format.channels == 2 ? @format : @format.with(channels: 2)
|
|
168
360
|
base.with(sample_format: :float, bit_depth: 32)
|
|
@@ -172,11 +364,71 @@ module Wavify
|
|
|
172
364
|
path = track.samples[sample_key]
|
|
173
365
|
raise Wavify::SequencerError, "missing sample mapping for pattern #{sample_key.inspect} on track #{track.name}" unless path
|
|
174
366
|
|
|
175
|
-
|
|
367
|
+
apply_sample_options(
|
|
368
|
+
Wavify::Audio.read(path),
|
|
369
|
+
track.sample_options.fetch(sample_key, {}),
|
|
370
|
+
work_format
|
|
371
|
+
)
|
|
176
372
|
rescue Wavify::Error => e
|
|
177
373
|
raise Wavify::SequencerError, "failed to load sample #{path.inspect} for track #{track.name}: #{e.message}"
|
|
178
374
|
end
|
|
179
375
|
|
|
376
|
+
def apply_sample_options(audio, options, work_format)
|
|
377
|
+
processed = audio
|
|
378
|
+
processed = slice_sample_option(processed, options)
|
|
379
|
+
processed = trim_sample_option(processed, options)
|
|
380
|
+
processed = processed.reverse if options[:reverse]
|
|
381
|
+
processed = pitch_sample_option(processed, options)
|
|
382
|
+
processed = processed.convert(work_format)
|
|
383
|
+
processed = processed.gain(options[:gain]) if options.key?(:gain)
|
|
384
|
+
if options.key?(:pan)
|
|
385
|
+
processed = processed.channels == 1 ? processed.pan(options[:pan]) : processed.balance(options[:pan])
|
|
386
|
+
end
|
|
387
|
+
processed
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def slice_sample_option(audio, options)
|
|
391
|
+
return audio.crop(start: options.fetch(:from, 0.0), duration: options[:duration]) if options.key?(:duration)
|
|
392
|
+
return audio.slice(from: options.fetch(:from, 0.0), to: options[:to]) if options.key?(:to)
|
|
393
|
+
|
|
394
|
+
audio
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def trim_sample_option(audio, options)
|
|
398
|
+
return audio unless options.key?(:trim)
|
|
399
|
+
return audio if options[:trim] == false
|
|
400
|
+
|
|
401
|
+
threshold = options[:trim] == true ? 0.01 : options[:trim]
|
|
402
|
+
audio.trim(threshold: threshold)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def pitch_sample_option(audio, options)
|
|
406
|
+
ratio = sample_pitch_ratio(options)
|
|
407
|
+
return audio if (ratio - 1.0).abs <= Float::EPSILON
|
|
408
|
+
|
|
409
|
+
pitched_format = audio.format.with(sample_rate: (audio.sample_rate * ratio).round)
|
|
410
|
+
reinterpreted = Wavify::Audio.new(Wavify::Core::SampleBuffer.new(audio.buffer.samples.dup, pitched_format))
|
|
411
|
+
reinterpreted.convert(audio.format)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def sample_pitch_ratio(options)
|
|
415
|
+
return validate_pitch_ratio!(options.fetch(:pitch_ratio)) if options.key?(:pitch_ratio)
|
|
416
|
+
return 1.0 unless options.key?(:pitch)
|
|
417
|
+
|
|
418
|
+
pitch = options.fetch(:pitch)
|
|
419
|
+
raise Wavify::SequencerError, "sample pitch must be Numeric semitones" unless pitch.is_a?(Numeric) && pitch.finite?
|
|
420
|
+
|
|
421
|
+
2.0**(pitch.to_f / 12.0)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def validate_pitch_ratio!(value)
|
|
425
|
+
unless value.is_a?(Numeric) && value.finite? && value.positive?
|
|
426
|
+
raise Wavify::SequencerError, "sample pitch_ratio must be a positive Numeric"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
value.to_f
|
|
430
|
+
end
|
|
431
|
+
|
|
180
432
|
def overlay_sample_event!(mixed, event, work_format)
|
|
181
433
|
sample_audio = event.fetch(:sample_audio)
|
|
182
434
|
velocity = event.fetch(:velocity).to_f
|
|
@@ -191,29 +443,121 @@ module Wavify
|
|
|
191
443
|
end
|
|
192
444
|
end
|
|
193
445
|
|
|
446
|
+
def probability_hit?(probability, random)
|
|
447
|
+
return false if probability <= 0.0
|
|
448
|
+
return true if probability >= 1.0
|
|
449
|
+
|
|
450
|
+
random.rand < probability
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def arrangement_for_track(track_name)
|
|
454
|
+
arrangement.map do |section|
|
|
455
|
+
tracks = section.fetch(:tracks).include?(track_name) ? [track_name] : []
|
|
456
|
+
section.merge(tracks: tracks)
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def total_bars(default_bars:)
|
|
461
|
+
return default_bars unless arrangement?
|
|
462
|
+
|
|
463
|
+
arrangement.sum { |section| section.fetch(:bars) * section.fetch(:repeat, 1) }
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def total_seconds(default_bars:)
|
|
467
|
+
return total_bars(default_bars: default_bars) * engine.bar_duration_seconds unless arrangement?
|
|
468
|
+
|
|
469
|
+
arrangement.sum do |section|
|
|
470
|
+
section_engine = Wavify::Sequencer::Engine.new(
|
|
471
|
+
tempo: section.fetch(:tempo) || @tempo,
|
|
472
|
+
format: @format,
|
|
473
|
+
beats_per_bar: section.fetch(:beats_per_bar) || @beats_per_bar,
|
|
474
|
+
swing: @swing
|
|
475
|
+
)
|
|
476
|
+
section.fetch(:bars) * section.fetch(:repeat, 1) * section_engine.bar_duration_seconds
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def expanded_arrangement
|
|
481
|
+
Enumerator.new do |yielder|
|
|
482
|
+
arrangement.each do |section|
|
|
483
|
+
section.fetch(:repeat, 1).times do |repeat_index|
|
|
484
|
+
yielder << section.merge(
|
|
485
|
+
name: repeated_section_name(section.fetch(:name), repeat_index),
|
|
486
|
+
repeat: 1
|
|
487
|
+
)
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def derived_track_seed(track_name)
|
|
494
|
+
Digest::SHA256.digest("#{@random_seed}:#{track_name}").unpack1("Q>")
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def stem_filename(track_name)
|
|
498
|
+
sanitized = track_name.to_s.gsub(/[^A-Za-z0-9_.-]+/, "_").sub(/\A[.]+/, "")
|
|
499
|
+
sanitized = "track" if sanitized.empty?
|
|
500
|
+
"#{sanitized}.wav"
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def repeated_section_name(name, repeat_index)
|
|
504
|
+
return name if repeat_index.zero?
|
|
505
|
+
|
|
506
|
+
:"#{name}_#{repeat_index + 1}"
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def with_track_context(track)
|
|
510
|
+
yield
|
|
511
|
+
rescue Wavify::Error => e
|
|
512
|
+
message = e.message.to_s
|
|
513
|
+
label = "track :#{track.name}"
|
|
514
|
+
raise if message.start_with?("#{label}:")
|
|
515
|
+
|
|
516
|
+
raise Wavify::SequencerError, "#{label}: #{message}"
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def timeline_text_row(event)
|
|
520
|
+
[
|
|
521
|
+
Kernel.format("%.3f", event.fetch(:start_time)),
|
|
522
|
+
event.fetch(:bar),
|
|
523
|
+
event.fetch(:track),
|
|
524
|
+
event.fetch(:kind),
|
|
525
|
+
timeline_text_detail(event)
|
|
526
|
+
].join("\t")
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def timeline_text_detail(event)
|
|
530
|
+
case event.fetch(:kind)
|
|
531
|
+
when :trigger
|
|
532
|
+
detail = "step=#{event.fetch(:step_index)} velocity=#{Kernel.format('%.2f', event.fetch(:velocity))}"
|
|
533
|
+
detail += " probability=#{Kernel.format('%.2f', event.fetch(:probability))}" if event.fetch(:probability, 1.0) < 1.0
|
|
534
|
+
detail += " ratchet=#{event.fetch(:ratchet_index) + 1}/#{event.fetch(:ratchet_count)}" if event.fetch(:ratchet_count, 1) > 1
|
|
535
|
+
detail
|
|
536
|
+
when :note
|
|
537
|
+
"step=#{event.fetch(:step_index)} midi=#{event.fetch(:midi_notes).join(',')}"
|
|
538
|
+
when :chord
|
|
539
|
+
"chord=#{event.fetch(:chord)} midi=#{event.fetch(:midi_notes).join(',')}"
|
|
540
|
+
when :marker
|
|
541
|
+
"marker=#{event.fetch(:marker)} section=#{event.fetch(:section)}"
|
|
542
|
+
else
|
|
543
|
+
"step=#{event.fetch(:step_index)}"
|
|
544
|
+
end
|
|
545
|
+
end
|
|
194
546
|
end
|
|
195
547
|
|
|
196
548
|
# :nodoc: all
|
|
197
549
|
# @api private
|
|
198
550
|
class TrackDefinition
|
|
199
|
-
# Internal mapping from DSL effect names to effect class constants.
|
|
200
|
-
EFFECT_CLASS_NAMES = {
|
|
201
|
-
delay: "Delay",
|
|
202
|
-
reverb: "Reverb",
|
|
203
|
-
chorus: "Chorus",
|
|
204
|
-
distortion: "Distortion",
|
|
205
|
-
compressor: "Compressor"
|
|
206
|
-
}.freeze
|
|
207
|
-
|
|
208
551
|
# Internal mutable track state compiled from DSL blocks.
|
|
209
552
|
#
|
|
210
553
|
# Readers are used by {SongDefinition} rendering and sequencer conversion.
|
|
211
|
-
attr_reader :name, :waveform, :gain_db, :pan_position, :pattern_resolution, :note_resolution,
|
|
554
|
+
attr_reader :name, :sample_folder, :waveform, :gain_db, :pan_position, :pattern_resolution, :note_resolution,
|
|
212
555
|
:default_octave, :envelope, :notes_notation, :chords_notation, :effects, :samples,
|
|
213
|
-
:named_patterns, :synth_options
|
|
556
|
+
:sample_options, :named_patterns, :synth_options, :key_root, :scale, :chord_voicing
|
|
214
557
|
|
|
215
|
-
def initialize(name)
|
|
558
|
+
def initialize(name, sample_folder: nil, key_root: nil, scale: nil, safe_paths: false)
|
|
216
559
|
@name = name.to_sym
|
|
560
|
+
@sample_folder = sample_folder
|
|
217
561
|
@waveform = :sine
|
|
218
562
|
@gain_db = 0.0
|
|
219
563
|
@pan_position = 0.0
|
|
@@ -225,9 +569,14 @@ module Wavify
|
|
|
225
569
|
@chords_notation = nil
|
|
226
570
|
@effects = []
|
|
227
571
|
@samples = {}
|
|
572
|
+
@sample_options = {}
|
|
228
573
|
@named_patterns = {}
|
|
229
574
|
@primary_pattern = nil
|
|
230
575
|
@synth_options = {}
|
|
576
|
+
@key_root = key_root
|
|
577
|
+
@scale = scale
|
|
578
|
+
@chord_voicing = nil
|
|
579
|
+
@safe_paths = safe_paths
|
|
231
580
|
end
|
|
232
581
|
|
|
233
582
|
# Returns the primary pattern notation for sequencer rendering.
|
|
@@ -253,14 +602,23 @@ module Wavify
|
|
|
253
602
|
end
|
|
254
603
|
|
|
255
604
|
# Stores chord progression notation and optional octave override.
|
|
256
|
-
def chords!(notation, default_octave: nil)
|
|
605
|
+
def chords!(notation, default_octave: nil, voicing: nil)
|
|
257
606
|
@chords_notation = notation
|
|
258
607
|
@default_octave = default_octave if default_octave
|
|
608
|
+
@chord_voicing = voicing.to_sym if voicing
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Stores note quantization key/scale settings.
|
|
612
|
+
def key!(root, scale = :major)
|
|
613
|
+
@key_root = root
|
|
614
|
+
@scale = scale
|
|
259
615
|
end
|
|
260
616
|
|
|
261
617
|
# Registers a sample path keyed by a symbolic name.
|
|
262
|
-
def sample!(key, path)
|
|
263
|
-
|
|
618
|
+
def sample!(key, path = nil, **options)
|
|
619
|
+
sample_key = key.to_sym
|
|
620
|
+
@samples[sample_key] = resolve_sample_path(sample_key, path)
|
|
621
|
+
@sample_options[sample_key] = normalize_sample_options!(options)
|
|
264
622
|
end
|
|
265
623
|
|
|
266
624
|
# Configures synth waveform and generator options.
|
|
@@ -271,22 +629,25 @@ module Wavify
|
|
|
271
629
|
|
|
272
630
|
# Sets gain (dB) applied after rendering the track.
|
|
273
631
|
def gain!(db)
|
|
632
|
+
unless db.is_a?(Numeric) && db.respond_to?(:finite?) && db.finite?
|
|
633
|
+
raise Wavify::SequencerError, "gain must be a finite Numeric"
|
|
634
|
+
end
|
|
635
|
+
|
|
274
636
|
@gain_db = db.to_f
|
|
275
637
|
end
|
|
276
638
|
|
|
277
639
|
# Sets stereo pan position (-1.0..1.0).
|
|
278
640
|
def pan!(position)
|
|
641
|
+
unless position.is_a?(Numeric) && position.respond_to?(:finite?) && position.finite? && position.between?(-1.0, 1.0)
|
|
642
|
+
raise Wavify::SequencerError, "pan must be a finite Numeric in -1.0..1.0"
|
|
643
|
+
end
|
|
644
|
+
|
|
279
645
|
@pan_position = position.to_f
|
|
280
646
|
end
|
|
281
647
|
|
|
282
648
|
# Builds and stores an ADSR envelope for sequencer notes.
|
|
283
|
-
def envelope!(
|
|
284
|
-
@envelope = Wavify::DSP::Envelope.new(
|
|
285
|
-
attack: attack,
|
|
286
|
-
decay: decay,
|
|
287
|
-
sustain: sustain,
|
|
288
|
-
release: release
|
|
289
|
-
)
|
|
649
|
+
def envelope!(**params)
|
|
650
|
+
@envelope = Wavify::DSP::Envelope.new(**params)
|
|
290
651
|
end
|
|
291
652
|
|
|
292
653
|
# Appends an effect configuration to the track.
|
|
@@ -302,13 +663,17 @@ module Wavify
|
|
|
302
663
|
note_sequence: @notes_notation,
|
|
303
664
|
chord_progression: @chords_notation,
|
|
304
665
|
waveform: @waveform,
|
|
666
|
+
synth_options: @synth_options,
|
|
305
667
|
gain_db: @gain_db,
|
|
306
668
|
pan_position: @pan_position,
|
|
307
669
|
pattern_resolution: @pattern_resolution,
|
|
308
670
|
note_resolution: @note_resolution,
|
|
309
671
|
default_octave: @default_octave,
|
|
310
672
|
envelope: @envelope,
|
|
311
|
-
effects: effect_processors
|
|
673
|
+
effects: effect_processors,
|
|
674
|
+
key: @key_root,
|
|
675
|
+
scale: @scale,
|
|
676
|
+
chord_voicing: @chord_voicing
|
|
312
677
|
)
|
|
313
678
|
end
|
|
314
679
|
|
|
@@ -321,6 +686,8 @@ module Wavify
|
|
|
321
686
|
|
|
322
687
|
if patterns.empty? && @primary_pattern && @samples.length == 1
|
|
323
688
|
patterns[@samples.keys.first] = Wavify::Sequencer::Pattern.new(@primary_pattern, resolution: @pattern_resolution)
|
|
689
|
+
elsif patterns.empty? && @primary_pattern && @samples.length > 1
|
|
690
|
+
raise Wavify::SequencerError, "primary sample pattern is ambiguous with multiple samples; use named patterns"
|
|
324
691
|
end
|
|
325
692
|
|
|
326
693
|
patterns
|
|
@@ -329,12 +696,75 @@ module Wavify
|
|
|
329
696
|
# Instantiates configured effect processor objects.
|
|
330
697
|
def effect_processors
|
|
331
698
|
@effects.map do |effect|
|
|
332
|
-
|
|
333
|
-
|
|
699
|
+
Wavify::Effects.build(effect.fetch(:name), **effect.fetch(:params))
|
|
700
|
+
rescue Wavify::Error => e
|
|
701
|
+
raise Wavify::SequencerError, e.message
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def finalize!
|
|
706
|
+
return self if frozen?
|
|
707
|
+
|
|
708
|
+
@sample_folder = @sample_folder&.dup&.freeze
|
|
709
|
+
@notes_notation = @notes_notation&.dup&.freeze
|
|
710
|
+
@chords_notation = deep_freeze(@chords_notation)
|
|
711
|
+
@effects = deep_freeze(@effects)
|
|
712
|
+
@samples = deep_freeze(@samples)
|
|
713
|
+
@sample_options = deep_freeze(@sample_options)
|
|
714
|
+
@named_patterns = deep_freeze(@named_patterns)
|
|
715
|
+
@primary_pattern = @primary_pattern&.dup&.freeze
|
|
716
|
+
@synth_options = deep_freeze(@synth_options)
|
|
717
|
+
@envelope&.freeze
|
|
718
|
+
freeze
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
private
|
|
722
|
+
|
|
723
|
+
def normalize_sample_options!(options)
|
|
724
|
+
supported = %i[gain pan trim reverse from to duration pitch pitch_ratio]
|
|
725
|
+
unknown = options.keys - supported
|
|
726
|
+
raise Wavify::SequencerError, "unsupported sample options: #{unknown.join(', ')}" unless unknown.empty?
|
|
727
|
+
validate_sample_numeric_option!(options, :pitch) if options.key?(:pitch)
|
|
728
|
+
validate_sample_numeric_option!(options, :pitch_ratio, positive: true) if options.key?(:pitch_ratio)
|
|
729
|
+
|
|
730
|
+
options.dup
|
|
731
|
+
end
|
|
334
732
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
733
|
+
def validate_sample_numeric_option!(options, key, positive: false)
|
|
734
|
+
value = options.fetch(key)
|
|
735
|
+
valid = value.is_a?(Numeric) && value.finite? && (!positive || value.positive?)
|
|
736
|
+
return if valid
|
|
737
|
+
|
|
738
|
+
requirement = positive ? "a positive Numeric" : "Numeric"
|
|
739
|
+
raise Wavify::SequencerError, "sample #{key} must be #{requirement}"
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
def resolve_sample_path(sample_key, path)
|
|
743
|
+
source = path.nil? ? "#{sample_key}.wav" : path.to_s
|
|
744
|
+
return source unless @sample_folder
|
|
745
|
+
|
|
746
|
+
joined = Pathname.new(source).absolute? ? source : File.join(@sample_folder, source)
|
|
747
|
+
return joined unless @safe_paths
|
|
748
|
+
|
|
749
|
+
folder = File.expand_path(@sample_folder)
|
|
750
|
+
candidate = File.expand_path(joined)
|
|
751
|
+
unless candidate.start_with?("#{folder}#{File::SEPARATOR}")
|
|
752
|
+
raise Wavify::SequencerError, "sample path escapes sample_folder: #{source.inspect}"
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
candidate
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def deep_freeze(value)
|
|
759
|
+
case value
|
|
760
|
+
when Array
|
|
761
|
+
value.map { |item| deep_freeze(item) }.freeze
|
|
762
|
+
when Hash
|
|
763
|
+
value.to_h { |key, item| [deep_freeze(key), deep_freeze(item)] }.freeze
|
|
764
|
+
when String
|
|
765
|
+
value.dup.freeze
|
|
766
|
+
else
|
|
767
|
+
value.freeze
|
|
338
768
|
end
|
|
339
769
|
end
|
|
340
770
|
end
|
|
@@ -353,8 +783,8 @@ module Wavify
|
|
|
353
783
|
end
|
|
354
784
|
|
|
355
785
|
# Registers a sample mapping.
|
|
356
|
-
def sample(name, path)
|
|
357
|
-
@track.sample!(name, path)
|
|
786
|
+
def sample(name, path = nil, **options)
|
|
787
|
+
@track.sample!(name, path, **options)
|
|
358
788
|
end
|
|
359
789
|
|
|
360
790
|
# Configures synth waveform/options.
|
|
@@ -368,8 +798,13 @@ module Wavify
|
|
|
368
798
|
end
|
|
369
799
|
|
|
370
800
|
# Defines chord notation.
|
|
371
|
-
def chords(notation, default_octave: nil)
|
|
372
|
-
@track.chords!(notation, default_octave: default_octave)
|
|
801
|
+
def chords(notation, default_octave: nil, voicing: nil)
|
|
802
|
+
@track.chords!(notation, default_octave: default_octave, voicing: voicing)
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Quantizes note and chord pitches to a key/scale.
|
|
806
|
+
def key(root, scale = :major)
|
|
807
|
+
@track.key!(root, scale)
|
|
373
808
|
end
|
|
374
809
|
|
|
375
810
|
# Defines an ADSR envelope and validates required parameters.
|
|
@@ -378,7 +813,11 @@ module Wavify
|
|
|
378
813
|
missing = required - params.keys
|
|
379
814
|
raise Wavify::SequencerError, "missing envelope params: #{missing.join(', ')}" unless missing.empty?
|
|
380
815
|
|
|
381
|
-
|
|
816
|
+
supported = required + %i[hold curve]
|
|
817
|
+
unknown = params.keys - supported
|
|
818
|
+
raise Wavify::SequencerError, "unsupported envelope params: #{unknown.join(', ')}" unless unknown.empty?
|
|
819
|
+
|
|
820
|
+
@track.envelope!(**params)
|
|
382
821
|
end
|
|
383
822
|
|
|
384
823
|
# Adds an effect to the track.
|
|
@@ -406,13 +845,31 @@ module Wavify
|
|
|
406
845
|
@sections = []
|
|
407
846
|
end
|
|
408
847
|
|
|
409
|
-
def section(name, bars:, tracks:)
|
|
410
|
-
|
|
848
|
+
def section(name, bars:, tracks:, repeat: 1, tempo: nil, beats_per_bar: nil, markers: [])
|
|
849
|
+
unless bars.is_a?(Integer) && bars.between?(1, Wavify::Sequencer::Engine::MAX_BARS)
|
|
850
|
+
raise Wavify::SequencerError, "bars must be an Integer in 1..#{Wavify::Sequencer::Engine::MAX_BARS}"
|
|
851
|
+
end
|
|
852
|
+
unless repeat.is_a?(Integer) && repeat.between?(1, Wavify::Sequencer::Engine::MAX_SECTION_REPEAT)
|
|
853
|
+
raise Wavify::SequencerError, "repeat must be an Integer in 1..#{Wavify::Sequencer::Engine::MAX_SECTION_REPEAT}"
|
|
854
|
+
end
|
|
855
|
+
if tempo && !(tempo.is_a?(Numeric) && tempo.respond_to?(:finite?) && tempo.finite? && tempo.positive?)
|
|
856
|
+
raise Wavify::SequencerError, "tempo must be a positive finite Numeric"
|
|
857
|
+
end
|
|
858
|
+
if beats_per_bar && !(beats_per_bar.is_a?(Integer) && beats_per_bar.positive?)
|
|
859
|
+
raise Wavify::SequencerError, "beats_per_bar must be a positive Integer"
|
|
860
|
+
end
|
|
411
861
|
|
|
412
862
|
names = Array(tracks).map(&:to_sym)
|
|
413
|
-
raise Wavify::SequencerError, "tracks must not be empty" if names.empty?
|
|
414
863
|
|
|
415
|
-
@sections << SongDefinition::Section.new(
|
|
864
|
+
@sections << SongDefinition::Section.new(
|
|
865
|
+
name: name.to_sym,
|
|
866
|
+
bars: bars,
|
|
867
|
+
tracks: names,
|
|
868
|
+
repeat: repeat,
|
|
869
|
+
tempo: tempo,
|
|
870
|
+
beats_per_bar: beats_per_bar,
|
|
871
|
+
markers: Array(markers).map(&:to_sym)
|
|
872
|
+
)
|
|
416
873
|
end
|
|
417
874
|
end
|
|
418
875
|
|
|
@@ -429,25 +886,43 @@ module Wavify
|
|
|
429
886
|
# @param beats_per_bar [Integer]
|
|
430
887
|
# @param default_bars [Integer]
|
|
431
888
|
# @return [SongDefinition]
|
|
432
|
-
def self.build_definition(format:, tempo: 120, beats_per_bar: 4, default_bars: 1,
|
|
433
|
-
|
|
889
|
+
def self.build_definition(format:, tempo: 120, beats_per_bar: 4, swing: 0.5, default_bars: 1,
|
|
890
|
+
random_seed: Random.new_seed, safe_paths: false, &block)
|
|
891
|
+
builder = new(
|
|
892
|
+
format: format,
|
|
893
|
+
tempo: tempo,
|
|
894
|
+
beats_per_bar: beats_per_bar,
|
|
895
|
+
swing: swing,
|
|
896
|
+
default_bars: default_bars,
|
|
897
|
+
random_seed: random_seed,
|
|
898
|
+
safe_paths: safe_paths
|
|
899
|
+
)
|
|
434
900
|
builder.instance_eval(&block) if block
|
|
435
901
|
builder.to_song_definition
|
|
436
902
|
end
|
|
437
903
|
|
|
438
|
-
def initialize(format:, tempo: 120, beats_per_bar: 4, default_bars: 1
|
|
904
|
+
def initialize(format:, tempo: 120, beats_per_bar: 4, swing: 0.5, default_bars: 1,
|
|
905
|
+
random_seed: Random.new_seed, safe_paths: false)
|
|
439
906
|
raise Wavify::InvalidParameterError, "format must be Core::Format" unless format.is_a?(Wavify::Core::Format)
|
|
440
907
|
|
|
441
908
|
@format = format
|
|
442
|
-
@tempo = tempo
|
|
443
|
-
@beats_per_bar = beats_per_bar
|
|
444
|
-
@
|
|
909
|
+
@tempo = validate_tempo!(tempo)
|
|
910
|
+
@beats_per_bar = validate_beats_per_bar!(beats_per_bar)
|
|
911
|
+
@swing = validate_swing!(swing)
|
|
912
|
+
@default_bars = validate_default_bars!(default_bars)
|
|
913
|
+
@random_seed = validate_random_seed!(random_seed)
|
|
914
|
+
@safe_paths = safe_paths == true
|
|
915
|
+
@sample_folder = nil
|
|
916
|
+
@key_root = nil
|
|
917
|
+
@scale = nil
|
|
445
918
|
@track_definitions = []
|
|
446
919
|
@arrangement_sections = []
|
|
447
920
|
end
|
|
448
921
|
|
|
449
922
|
def tempo(value)
|
|
450
|
-
|
|
923
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
|
|
924
|
+
raise Wavify::SequencerError, "tempo must be a positive finite Numeric"
|
|
925
|
+
end
|
|
451
926
|
|
|
452
927
|
@tempo = value.to_f
|
|
453
928
|
end
|
|
@@ -458,25 +933,87 @@ module Wavify
|
|
|
458
933
|
@beats_per_bar = value
|
|
459
934
|
end
|
|
460
935
|
|
|
936
|
+
def swing(value)
|
|
937
|
+
@swing = validate_swing!(value)
|
|
938
|
+
end
|
|
939
|
+
|
|
461
940
|
def bars(value)
|
|
462
|
-
|
|
941
|
+
unless value.is_a?(Integer) && value.between?(1, Wavify::Sequencer::Engine::MAX_BARS)
|
|
942
|
+
raise Wavify::SequencerError, "bars must be an Integer in 1..#{Wavify::Sequencer::Engine::MAX_BARS}"
|
|
943
|
+
end
|
|
463
944
|
|
|
464
945
|
@default_bars = value
|
|
465
946
|
end
|
|
466
947
|
|
|
948
|
+
def sample_folder(path)
|
|
949
|
+
raise Wavify::SequencerError, "sample_folder must be a String" unless path.is_a?(String) && !path.empty?
|
|
950
|
+
|
|
951
|
+
@sample_folder = path
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
def key(root, scale = :major)
|
|
955
|
+
@key_root = root
|
|
956
|
+
@scale = scale
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
def preset(name, **options)
|
|
960
|
+
case name.to_sym
|
|
961
|
+
when :lofi_drums
|
|
962
|
+
lofi_drums_preset(**options)
|
|
963
|
+
else
|
|
964
|
+
raise Wavify::SequencerError, "unsupported preset: #{name.inspect}"
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
|
|
467
968
|
# Defines a track block and stores its compiled configuration.
|
|
468
|
-
def track(name, &block)
|
|
469
|
-
|
|
470
|
-
|
|
969
|
+
def track(name, sample_folder: @sample_folder, &block)
|
|
970
|
+
normalized_name = name.to_sym
|
|
971
|
+
if @track_definitions.any? { |definition| definition.name == normalized_name }
|
|
972
|
+
raise Wavify::SequencerError, "duplicate track name: #{normalized_name}"
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
definition = TrackDefinition.new(
|
|
976
|
+
normalized_name,
|
|
977
|
+
sample_folder: sample_folder,
|
|
978
|
+
key_root: @key_root,
|
|
979
|
+
scale: @scale,
|
|
980
|
+
safe_paths: @safe_paths
|
|
981
|
+
)
|
|
982
|
+
begin
|
|
983
|
+
TrackBuilder.new(definition).instance_eval(&block) if block
|
|
984
|
+
rescue Wavify::Error => e
|
|
985
|
+
raise Wavify::SequencerError, "track :#{definition.name}: #{e.message}"
|
|
986
|
+
end
|
|
471
987
|
@track_definitions << definition
|
|
472
988
|
definition
|
|
473
989
|
end
|
|
474
990
|
|
|
991
|
+
def lofi_drums_preset(name: :lofi_drums, sample_folder: @sample_folder)
|
|
992
|
+
track(name, sample_folder: sample_folder) do
|
|
993
|
+
pattern :kick, "x---x---x---x---"
|
|
994
|
+
pattern :snare, "----x-------x---"
|
|
995
|
+
pattern :hat, "x-x-x-x-x-x-x-x-"
|
|
996
|
+
sample :kick, "kick.wav", gain: -2, trim: true
|
|
997
|
+
sample :snare, "snare.wav", gain: -4, trim: true
|
|
998
|
+
sample :hat, "hat.wav", gain: -9, pan: 0.15, trim: true
|
|
999
|
+
effect :compressor, threshold: -18, ratio: 2.5, attack: 0.005, release: 0.08, makeup_gain: 2
|
|
1000
|
+
gain(-3)
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
475
1004
|
# Defines arrangement sections for selective track playback by section.
|
|
476
1005
|
def arrange(&block)
|
|
477
1006
|
builder = ArrangementBuilder.new
|
|
478
|
-
|
|
1007
|
+
begin
|
|
1008
|
+
builder.instance_eval(&block) if block
|
|
1009
|
+
rescue Wavify::SequencerError => e
|
|
1010
|
+
raise Wavify::SequencerError, "arrangement: #{e.message}"
|
|
1011
|
+
end
|
|
479
1012
|
@arrangement_sections = builder.sections
|
|
1013
|
+
if @arrangement_sections.length > Wavify::Sequencer::Engine::MAX_ARRANGEMENT_SECTIONS
|
|
1014
|
+
raise Wavify::SequencerError,
|
|
1015
|
+
"arrangement exceeds #{Wavify::Sequencer::Engine::MAX_ARRANGEMENT_SECTIONS} sections"
|
|
1016
|
+
end
|
|
480
1017
|
end
|
|
481
1018
|
|
|
482
1019
|
# Finalizes and returns an immutable {SongDefinition}.
|
|
@@ -485,10 +1022,51 @@ module Wavify
|
|
|
485
1022
|
format: @format,
|
|
486
1023
|
tempo: @tempo,
|
|
487
1024
|
beats_per_bar: @beats_per_bar,
|
|
1025
|
+
swing: @swing,
|
|
1026
|
+
default_bars: @default_bars,
|
|
488
1027
|
tracks: @track_definitions,
|
|
489
|
-
sections: @arrangement_sections
|
|
1028
|
+
sections: @arrangement_sections,
|
|
1029
|
+
random_seed: @random_seed
|
|
490
1030
|
)
|
|
491
1031
|
end
|
|
1032
|
+
|
|
1033
|
+
private
|
|
1034
|
+
|
|
1035
|
+
def validate_swing!(value)
|
|
1036
|
+
unless value.is_a?(Numeric) && value.finite? && value >= 0.5 && value < 1.0
|
|
1037
|
+
raise Wavify::SequencerError, "swing must be a Numeric between 0.5 and 1.0"
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
value.to_f
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
def validate_tempo!(value)
|
|
1044
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
|
|
1045
|
+
raise Wavify::SequencerError, "tempo must be a positive finite Numeric"
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
value.to_f
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
def validate_beats_per_bar!(value)
|
|
1052
|
+
raise Wavify::SequencerError, "beats_per_bar must be a positive Integer" unless value.is_a?(Integer) && value.positive?
|
|
1053
|
+
|
|
1054
|
+
value
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
def validate_default_bars!(value)
|
|
1058
|
+
unless value.is_a?(Integer) && value.between?(1, Wavify::Sequencer::Engine::MAX_BARS)
|
|
1059
|
+
raise Wavify::SequencerError, "bars must be an Integer in 1..#{Wavify::Sequencer::Engine::MAX_BARS}"
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
value
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
def validate_random_seed!(value)
|
|
1066
|
+
raise Wavify::SequencerError, "random_seed must be an Integer" unless value.is_a?(Integer)
|
|
1067
|
+
|
|
1068
|
+
value
|
|
1069
|
+
end
|
|
492
1070
|
end
|
|
493
1071
|
|
|
494
1072
|
class << self
|
|
@@ -499,15 +1077,45 @@ module Wavify
|
|
|
499
1077
|
# @param beats_per_bar [Integer]
|
|
500
1078
|
# @param default_bars [Integer]
|
|
501
1079
|
# @return [SongDefinition]
|
|
502
|
-
def build_definition(format:, tempo: 120, beats_per_bar: 4, default_bars: 1,
|
|
1080
|
+
def build_definition(format:, tempo: 120, beats_per_bar: 4, swing: 0.5, default_bars: 1,
|
|
1081
|
+
random_seed: Random.new_seed, safe_paths: false, &block)
|
|
503
1082
|
Builder.build_definition(
|
|
504
1083
|
format: format,
|
|
505
1084
|
tempo: tempo,
|
|
506
1085
|
beats_per_bar: beats_per_bar,
|
|
1086
|
+
swing: swing,
|
|
507
1087
|
default_bars: default_bars,
|
|
1088
|
+
random_seed: random_seed,
|
|
1089
|
+
safe_paths: safe_paths,
|
|
508
1090
|
&block
|
|
509
1091
|
)
|
|
510
1092
|
end
|
|
1093
|
+
|
|
1094
|
+
# Validates a DSL block without rendering audio.
|
|
1095
|
+
#
|
|
1096
|
+
# @return [true]
|
|
1097
|
+
def validate(format:, tempo: 120, beats_per_bar: 4, swing: 0.5, default_bars: 1,
|
|
1098
|
+
random_seed: Random.new_seed, safe_paths: false, deep: false, &block)
|
|
1099
|
+
build_definition(
|
|
1100
|
+
format: format,
|
|
1101
|
+
tempo: tempo,
|
|
1102
|
+
beats_per_bar: beats_per_bar,
|
|
1103
|
+
swing: swing,
|
|
1104
|
+
default_bars: default_bars,
|
|
1105
|
+
random_seed: random_seed,
|
|
1106
|
+
safe_paths: safe_paths,
|
|
1107
|
+
&block
|
|
1108
|
+
).validate!(deep: deep)
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
# Registers an effect factory for DSL `effect :name` usage.
|
|
1112
|
+
#
|
|
1113
|
+
# @param name [Symbol, String]
|
|
1114
|
+
# @param factory [Class, #call]
|
|
1115
|
+
# @return [Class, #call]
|
|
1116
|
+
def effect(name, factory = nil, &block)
|
|
1117
|
+
Wavify::Effects.register(name, factory, &block)
|
|
1118
|
+
end
|
|
511
1119
|
end
|
|
512
1120
|
end
|
|
513
1121
|
|
|
@@ -520,12 +1128,16 @@ module Wavify
|
|
|
520
1128
|
# @param beats_per_bar [Integer]
|
|
521
1129
|
# @param default_bars [Integer]
|
|
522
1130
|
# @return [Audio]
|
|
523
|
-
def build(output_path = nil, format: Core::Format::CD_QUALITY, tempo: 120, beats_per_bar: 4, default_bars: 1,
|
|
1131
|
+
def build(output_path = nil, format: Core::Format::CD_QUALITY, tempo: 120, beats_per_bar: 4, swing: 0.5, default_bars: 1,
|
|
1132
|
+
random_seed: Random.new_seed, safe_paths: false, &block)
|
|
524
1133
|
song = DSL.build_definition(
|
|
525
1134
|
format: format,
|
|
526
1135
|
tempo: tempo,
|
|
527
1136
|
beats_per_bar: beats_per_bar,
|
|
1137
|
+
swing: swing,
|
|
528
1138
|
default_bars: default_bars,
|
|
1139
|
+
random_seed: random_seed,
|
|
1140
|
+
safe_paths: safe_paths,
|
|
529
1141
|
&block
|
|
530
1142
|
)
|
|
531
1143
|
|