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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/README.md +204 -22
  4. data/exe/wavify +6 -0
  5. data/lib/wavify/adapters.rb +81 -0
  6. data/lib/wavify/audio.rb +1066 -120
  7. data/lib/wavify/cli.rb +237 -0
  8. data/lib/wavify/codecs/aiff.rb +388 -87
  9. data/lib/wavify/codecs/base.rb +98 -5
  10. data/lib/wavify/codecs/flac.rb +660 -170
  11. data/lib/wavify/codecs/ogg_vorbis.rb +515 -155
  12. data/lib/wavify/codecs/raw.rb +235 -45
  13. data/lib/wavify/codecs/registry.rb +272 -10
  14. data/lib/wavify/codecs/wav.rb +453 -74
  15. data/lib/wavify/core/duration.rb +54 -2
  16. data/lib/wavify/core/format.rb +96 -11
  17. data/lib/wavify/core/sample_buffer.rb +648 -60
  18. data/lib/wavify/core/stream.rb +590 -34
  19. data/lib/wavify/dsl.rb +712 -100
  20. data/lib/wavify/dsp/automation.rb +109 -0
  21. data/lib/wavify/dsp/effects/auto_pan.rb +71 -0
  22. data/lib/wavify/dsp/effects/bitcrusher.rb +74 -0
  23. data/lib/wavify/dsp/effects/chorus.rb +15 -4
  24. data/lib/wavify/dsp/effects/compressor.rb +133 -23
  25. data/lib/wavify/dsp/effects/delay.rb +46 -1
  26. data/lib/wavify/dsp/effects/distortion.rb +3 -2
  27. data/lib/wavify/dsp/effects/effect_base.rb +69 -2
  28. data/lib/wavify/dsp/effects/effect_chain.rb +94 -0
  29. data/lib/wavify/dsp/effects/envelope_controlled_effect.rb +93 -0
  30. data/lib/wavify/dsp/effects/eq.rb +102 -0
  31. data/lib/wavify/dsp/effects/expander.rb +90 -0
  32. data/lib/wavify/dsp/effects/flanger.rb +112 -0
  33. data/lib/wavify/dsp/effects/limiter.rb +259 -0
  34. data/lib/wavify/dsp/effects/mastering_chain.rb +31 -0
  35. data/lib/wavify/dsp/effects/noise_gate.rb +72 -0
  36. data/lib/wavify/dsp/effects/phaser.rb +112 -0
  37. data/lib/wavify/dsp/effects/podcast_chain.rb +32 -0
  38. data/lib/wavify/dsp/effects/reverb.rb +100 -14
  39. data/lib/wavify/dsp/effects/soft_limiter.rb +55 -0
  40. data/lib/wavify/dsp/effects/stereo_widener.rb +73 -0
  41. data/lib/wavify/dsp/effects/tremolo.rb +66 -0
  42. data/lib/wavify/dsp/effects/vibrato.rb +102 -0
  43. data/lib/wavify/dsp/effects.rb +106 -0
  44. data/lib/wavify/dsp/envelope.rb +90 -19
  45. data/lib/wavify/dsp/filter.rb +149 -8
  46. data/lib/wavify/dsp/headroom.rb +57 -0
  47. data/lib/wavify/dsp/lfo.rb +65 -0
  48. data/lib/wavify/dsp/loudness_meter.rb +281 -0
  49. data/lib/wavify/dsp/oscillator.rb +261 -22
  50. data/lib/wavify/dsp/processor.rb +90 -0
  51. data/lib/wavify/errors.rb +2 -2
  52. data/lib/wavify/sequencer/engine.rb +405 -89
  53. data/lib/wavify/sequencer/note_sequence.rb +45 -7
  54. data/lib/wavify/sequencer/pattern.rb +121 -12
  55. data/lib/wavify/sequencer/track.rb +149 -10
  56. data/lib/wavify/version.rb +1 -1
  57. data/lib/wavify.rb +18 -0
  58. data/sig/audio.rbs +86 -0
  59. data/sig/codecs.rbs +77 -0
  60. data/sig/core.rbs +108 -0
  61. data/sig/dsl.rbs +32 -0
  62. data/sig/dsp.rbs +87 -0
  63. data/sig/effects.rbs +129 -0
  64. data/sig/public_api.yml +111 -0
  65. data/sig/sequencer.rbs +126 -0
  66. data/sig/stream.rbs +30 -0
  67. data/sig/wavify.rbs +24 -0
  68. metadata +44 -58
  69. data/.serena/.gitignore +0 -1
  70. data/.serena/memories/project_overview.md +0 -5
  71. data/.serena/memories/style_and_completion.md +0 -5
  72. data/.serena/memories/suggested_commands.md +0 -11
  73. data/.serena/project.yml +0 -126
  74. data/.simplecov +0 -18
  75. data/.yardopts +0 -4
  76. data/Rakefile +0 -190
  77. data/benchmarks/README.md +0 -46
  78. data/benchmarks/benchmark_helper.rb +0 -112
  79. data/benchmarks/dsp_effects_benchmark.rb +0 -46
  80. data/benchmarks/flac_benchmark.rb +0 -74
  81. data/benchmarks/streaming_memory_benchmark.rb +0 -94
  82. data/benchmarks/wav_io_benchmark.rb +0 -110
  83. data/examples/audio_processing.rb +0 -73
  84. data/examples/cinematic_transition.rb +0 -118
  85. data/examples/drum_machine.rb +0 -74
  86. data/examples/format_convert.rb +0 -81
  87. data/examples/hybrid_arrangement.rb +0 -165
  88. data/examples/streaming_master_chain.rb +0 -129
  89. data/examples/synth_pad.rb +0 -42
  90. 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
- @tracks = tracks.freeze
18
- @sections = sections.freeze
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(&:to_sequencer_track)
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
- { name: section.name, bars: section.bars, tracks: section.tracks }
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: 1)
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: 1)
69
- sequencer_audio = engine.render(
70
- tracks: sequencer_tracks,
71
- arrangement: arrangement? ? arrangement : nil,
72
- default_bars: default_bars
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
- return sequencer_audio unless sample_audio
77
- return sample_audio if sequencer_audio.sample_frame_count.zero?
78
-
79
- Wavify::Audio.mix(sequencer_audio, sample_audio)
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: 1)
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
- events = []
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
- absolute_bar = section.fetch(:start_bar) + bar_offset
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
- events << {
126
- sample_key: sample_key,
127
- start_time: bar_base_time + (step.index * step_duration),
128
- velocity: step.velocity,
129
- sample_audio: sample_audio
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 events.empty?
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
- rendered = rendered.pan(track.pan_position) if track.pan_position != 0.0
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
- @sections.each_with_object([]) do |section, result|
158
- result << { bars: section.bars, start_bar: cursor_bar } if section.tracks.include?(track_name)
159
- cursor_bar += section.bars
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
- Wavify::Audio.read(path).convert(work_format)
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
- @samples[key.to_sym] = path.to_s
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!(attack:, decay:, sustain:, release:)
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
- effect_class_name = EFFECT_CLASS_NAMES[effect.fetch(:name)]
333
- raise Wavify::SequencerError, "unsupported effect: #{effect.fetch(:name)}" unless effect_class_name
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
- Wavify::Effects.const_get(effect_class_name).new(**effect.fetch(:params))
336
- rescue NameError
337
- raise Wavify::SequencerError, "effect class not found: #{effect_class_name}"
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
- @track.envelope!(**params.slice(*required))
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
- raise Wavify::SequencerError, "bars must be a positive Integer" unless bars.is_a?(Integer) && bars.positive?
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(name: name.to_sym, bars: bars, tracks: names)
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, &block)
433
- builder = new(format: format, tempo: tempo, beats_per_bar: beats_per_bar, default_bars: default_bars)
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.to_f
443
- @beats_per_bar = beats_per_bar
444
- @default_bars = default_bars
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
- raise Wavify::SequencerError, "tempo must be a positive Numeric" unless value.is_a?(Numeric) && value.positive?
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
- raise Wavify::SequencerError, "bars must be a positive Integer" unless value.is_a?(Integer) && value.positive?
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
- definition = TrackDefinition.new(name)
470
- TrackBuilder.new(definition).instance_eval(&block) if block
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
- builder.instance_eval(&block) if block
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, &block)
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, &block)
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