wavify 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.serena/.gitignore +1 -0
  3. data/.serena/memories/project_overview.md +5 -0
  4. data/.serena/memories/style_and_completion.md +5 -0
  5. data/.serena/memories/suggested_commands.md +11 -0
  6. data/.serena/project.yml +126 -0
  7. data/.simplecov +18 -0
  8. data/.yardopts +4 -0
  9. data/CHANGELOG.md +11 -0
  10. data/LICENSE +21 -0
  11. data/README.md +196 -0
  12. data/Rakefile +190 -0
  13. data/benchmarks/README.md +46 -0
  14. data/benchmarks/benchmark_helper.rb +112 -0
  15. data/benchmarks/dsp_effects_benchmark.rb +46 -0
  16. data/benchmarks/flac_benchmark.rb +74 -0
  17. data/benchmarks/streaming_memory_benchmark.rb +94 -0
  18. data/benchmarks/wav_io_benchmark.rb +110 -0
  19. data/examples/audio_processing.rb +73 -0
  20. data/examples/cinematic_transition.rb +118 -0
  21. data/examples/drum_machine.rb +74 -0
  22. data/examples/format_convert.rb +81 -0
  23. data/examples/hybrid_arrangement.rb +165 -0
  24. data/examples/streaming_master_chain.rb +129 -0
  25. data/examples/synth_pad.rb +42 -0
  26. data/lib/wavify/audio.rb +483 -0
  27. data/lib/wavify/codecs/aiff.rb +338 -0
  28. data/lib/wavify/codecs/base.rb +108 -0
  29. data/lib/wavify/codecs/flac.rb +1322 -0
  30. data/lib/wavify/codecs/ogg_vorbis.rb +1447 -0
  31. data/lib/wavify/codecs/raw.rb +193 -0
  32. data/lib/wavify/codecs/registry.rb +87 -0
  33. data/lib/wavify/codecs/wav.rb +459 -0
  34. data/lib/wavify/core/duration.rb +99 -0
  35. data/lib/wavify/core/format.rb +133 -0
  36. data/lib/wavify/core/sample_buffer.rb +216 -0
  37. data/lib/wavify/core/stream.rb +129 -0
  38. data/lib/wavify/dsl.rb +537 -0
  39. data/lib/wavify/dsp/effects/chorus.rb +98 -0
  40. data/lib/wavify/dsp/effects/compressor.rb +85 -0
  41. data/lib/wavify/dsp/effects/delay.rb +69 -0
  42. data/lib/wavify/dsp/effects/distortion.rb +64 -0
  43. data/lib/wavify/dsp/effects/effect_base.rb +68 -0
  44. data/lib/wavify/dsp/effects/reverb.rb +112 -0
  45. data/lib/wavify/dsp/effects.rb +21 -0
  46. data/lib/wavify/dsp/envelope.rb +97 -0
  47. data/lib/wavify/dsp/filter.rb +271 -0
  48. data/lib/wavify/dsp/oscillator.rb +123 -0
  49. data/lib/wavify/errors.rb +34 -0
  50. data/lib/wavify/sequencer/engine.rb +278 -0
  51. data/lib/wavify/sequencer/note_sequence.rb +132 -0
  52. data/lib/wavify/sequencer/pattern.rb +102 -0
  53. data/lib/wavify/sequencer/track.rb +298 -0
  54. data/lib/wavify/sequencer.rb +12 -0
  55. data/lib/wavify/version.rb +6 -0
  56. data/lib/wavify.rb +28 -0
  57. data/tools/fixture_writer.rb +85 -0
  58. metadata +129 -0
data/lib/wavify/dsl.rb ADDED
@@ -0,0 +1,537 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ # Declarative music-building DSL that compiles to sequencer tracks and audio.
5
+ module DSL
6
+ # Immutable compiled song definition returned by {DSL.build_definition}.
7
+ class SongDefinition
8
+ # Arrangement section metadata (`name`, `bars`, active `tracks`).
9
+ Section = Struct.new(:name, :bars, :tracks, keyword_init: true)
10
+
11
+ attr_reader :format, :tempo, :beats_per_bar, :tracks, :sections
12
+
13
+ def initialize(format:, tempo:, beats_per_bar:, tracks:, sections:)
14
+ @format = format
15
+ @tempo = tempo
16
+ @beats_per_bar = beats_per_bar
17
+ @tracks = tracks.freeze
18
+ @sections = sections.freeze
19
+ end
20
+
21
+ def arrangement?
22
+ !@sections.empty?
23
+ end
24
+
25
+ # Returns a sequencer engine configured from the song definition.
26
+ #
27
+ # @return [Wavify::Sequencer::Engine]
28
+ def engine
29
+ Wavify::Sequencer::Engine.new(
30
+ tempo: @tempo,
31
+ format: @format,
32
+ beats_per_bar: @beats_per_bar
33
+ )
34
+ end
35
+
36
+ # Returns sequencer tracks converted from DSL track definitions.
37
+ #
38
+ # @return [Array<Wavify::Sequencer::Track>]
39
+ def sequencer_tracks
40
+ @tracks.map(&:to_sequencer_track)
41
+ end
42
+
43
+ # Returns arrangement sections as hashes accepted by the sequencer engine.
44
+ #
45
+ # @return [Array<Hash>]
46
+ def arrangement
47
+ @sections.map do |section|
48
+ { name: section.name, bars: section.bars, tracks: section.tracks }
49
+ end
50
+ end
51
+
52
+ # Builds a sequencer event timeline without rendering audio.
53
+ #
54
+ # @param default_bars [Integer]
55
+ # @return [Array<Hash>]
56
+ def timeline(default_bars: 1)
57
+ engine.build_timeline(
58
+ tracks: sequencer_tracks,
59
+ arrangement: arrangement? ? arrangement : nil,
60
+ default_bars: default_bars
61
+ )
62
+ end
63
+
64
+ # Renders the song definition to an {Wavify::Audio} instance.
65
+ #
66
+ # @param default_bars [Integer]
67
+ # @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
+ )
74
+ sample_audio = render_sample_tracks(default_bars: default_bars)
75
+
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)
80
+ end
81
+
82
+ # Renders and writes the song to disk.
83
+ #
84
+ # @param path [String]
85
+ # @param default_bars [Integer]
86
+ # @return [Wavify::Audio]
87
+ def write(path, default_bars: 1)
88
+ render(default_bars: default_bars).write(path)
89
+ end
90
+
91
+ private
92
+
93
+ def render_sample_tracks(default_bars:)
94
+ rendered_tracks = @tracks.filter_map do |track|
95
+ render_sample_track(track, default_bars: default_bars)
96
+ end
97
+ return nil if rendered_tracks.empty?
98
+
99
+ Wavify::Audio.mix(*rendered_tracks)
100
+ end
101
+
102
+ def render_sample_track(track, default_bars:)
103
+ patterns = track.sample_pattern_map
104
+ return nil if patterns.empty?
105
+
106
+ sections = active_sections_for(track.name, default_bars: default_bars)
107
+ return nil if sections.empty?
108
+
109
+ work_format = track_render_work_format
110
+ sample_cache = {}
111
+ events = []
112
+
113
+ sections.each do |section|
114
+ patterns.each do |sample_key, pattern|
115
+ sample_audio = sample_cache[sample_key] ||= load_sample_audio(track, sample_key, work_format)
116
+ step_duration = engine.step_duration_seconds(pattern.length)
117
+
118
+ (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
121
+
122
+ pattern.each do |step|
123
+ next unless step.trigger?
124
+
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
+ }
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ return nil if events.empty?
137
+
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
+ rendered = Wavify::Audio.new(Wavify::Core::SampleBuffer.new(mixed, work_format))
148
+ 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
150
+ rendered = track.effect_processors.reduce(rendered) { |audio, effect| audio.apply(effect) }
151
+ rendered.convert(@format)
152
+ end
153
+
154
+ def active_sections_for(track_name, default_bars:)
155
+ if arrangement?
156
+ 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
160
+ end
161
+ else
162
+ [{ bars: default_bars, start_bar: 0 }]
163
+ end
164
+ end
165
+
166
+ def track_render_work_format
167
+ base = @format.channels == 2 ? @format : @format.with(channels: 2)
168
+ base.with(sample_format: :float, bit_depth: 32)
169
+ end
170
+
171
+ def load_sample_audio(track, sample_key, work_format)
172
+ path = track.samples[sample_key]
173
+ raise Wavify::SequencerError, "missing sample mapping for pattern #{sample_key.inspect} on track #{track.name}" unless path
174
+
175
+ Wavify::Audio.read(path).convert(work_format)
176
+ rescue Wavify::Error => e
177
+ raise Wavify::SequencerError, "failed to load sample #{path.inspect} for track #{track.name}: #{e.message}"
178
+ end
179
+
180
+ def overlay_sample_event!(mixed, event, work_format)
181
+ sample_audio = event.fetch(:sample_audio)
182
+ velocity = event.fetch(:velocity).to_f
183
+ start_frame = (event.fetch(:start_time) * work_format.sample_rate).round
184
+ start_index = start_frame * work_format.channels
185
+
186
+ sample_audio.buffer.samples.each_with_index do |sample, index|
187
+ target_index = start_index + index
188
+ break if target_index >= mixed.length
189
+
190
+ mixed[target_index] += sample * velocity
191
+ end
192
+ end
193
+
194
+ end
195
+
196
+ # :nodoc: all
197
+ # @api private
198
+ 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
+ # Internal mutable track state compiled from DSL blocks.
209
+ #
210
+ # Readers are used by {SongDefinition} rendering and sequencer conversion.
211
+ attr_reader :name, :waveform, :gain_db, :pan_position, :pattern_resolution, :note_resolution,
212
+ :default_octave, :envelope, :notes_notation, :chords_notation, :effects, :samples,
213
+ :named_patterns, :synth_options
214
+
215
+ def initialize(name)
216
+ @name = name.to_sym
217
+ @waveform = :sine
218
+ @gain_db = 0.0
219
+ @pan_position = 0.0
220
+ @pattern_resolution = 16
221
+ @note_resolution = 8
222
+ @default_octave = 4
223
+ @envelope = nil
224
+ @notes_notation = nil
225
+ @chords_notation = nil
226
+ @effects = []
227
+ @samples = {}
228
+ @named_patterns = {}
229
+ @primary_pattern = nil
230
+ @synth_options = {}
231
+ end
232
+
233
+ # Returns the primary pattern notation for sequencer rendering.
234
+ def primary_pattern
235
+ @primary_pattern || @named_patterns.values.first
236
+ end
237
+
238
+ # Registers a primary or named rhythm pattern.
239
+ def pattern!(name_or_notation, notation = nil, resolution: nil)
240
+ if notation.nil?
241
+ @primary_pattern = name_or_notation.to_s
242
+ else
243
+ @named_patterns[name_or_notation.to_sym] = notation.to_s
244
+ end
245
+ @pattern_resolution = resolution if resolution
246
+ end
247
+
248
+ # Stores note notation and optional parser settings.
249
+ def notes!(notation, resolution: nil, default_octave: nil)
250
+ @notes_notation = notation.to_s
251
+ @note_resolution = resolution if resolution
252
+ @default_octave = default_octave if default_octave
253
+ end
254
+
255
+ # Stores chord progression notation and optional octave override.
256
+ def chords!(notation, default_octave: nil)
257
+ @chords_notation = notation
258
+ @default_octave = default_octave if default_octave
259
+ end
260
+
261
+ # Registers a sample path keyed by a symbolic name.
262
+ def sample!(key, path)
263
+ @samples[key.to_sym] = path.to_s
264
+ end
265
+
266
+ # Configures synth waveform and generator options.
267
+ def synth!(waveform, **options)
268
+ @waveform = waveform.to_sym
269
+ @synth_options.merge!(options)
270
+ end
271
+
272
+ # Sets gain (dB) applied after rendering the track.
273
+ def gain!(db)
274
+ @gain_db = db.to_f
275
+ end
276
+
277
+ # Sets stereo pan position (-1.0..1.0).
278
+ def pan!(position)
279
+ @pan_position = position.to_f
280
+ end
281
+
282
+ # 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
+ )
290
+ end
291
+
292
+ # Appends an effect configuration to the track.
293
+ def effect!(name, **params)
294
+ @effects << { name: name.to_sym, params: params }
295
+ end
296
+
297
+ # Converts DSL settings into a {Wavify::Sequencer::Track}.
298
+ def to_sequencer_track
299
+ Wavify::Sequencer::Track.new(
300
+ @name,
301
+ pattern: primary_pattern,
302
+ note_sequence: @notes_notation,
303
+ chord_progression: @chords_notation,
304
+ waveform: @waveform,
305
+ gain_db: @gain_db,
306
+ pan_position: @pan_position,
307
+ pattern_resolution: @pattern_resolution,
308
+ note_resolution: @note_resolution,
309
+ default_octave: @default_octave,
310
+ envelope: @envelope,
311
+ effects: effect_processors
312
+ )
313
+ end
314
+
315
+ # Builds sample trigger patterns from named/primary pattern notation.
316
+ def sample_pattern_map
317
+ patterns = {}
318
+ @named_patterns.each do |name, notation|
319
+ patterns[name] = Wavify::Sequencer::Pattern.new(notation, resolution: @pattern_resolution)
320
+ end
321
+
322
+ if patterns.empty? && @primary_pattern && @samples.length == 1
323
+ patterns[@samples.keys.first] = Wavify::Sequencer::Pattern.new(@primary_pattern, resolution: @pattern_resolution)
324
+ end
325
+
326
+ patterns
327
+ end
328
+
329
+ # Instantiates configured effect processor objects.
330
+ def effect_processors
331
+ @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
334
+
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}"
338
+ end
339
+ end
340
+ end
341
+
342
+ # :nodoc: all
343
+ # @api private
344
+ class TrackBuilder
345
+ # @param track_definition [TrackDefinition]
346
+ def initialize(track_definition)
347
+ @track = track_definition
348
+ end
349
+
350
+ # Delegates pattern definition to the underlying track.
351
+ def pattern(name_or_notation, notation = nil, resolution: nil)
352
+ @track.pattern!(name_or_notation, notation, resolution: resolution)
353
+ end
354
+
355
+ # Registers a sample mapping.
356
+ def sample(name, path)
357
+ @track.sample!(name, path)
358
+ end
359
+
360
+ # Configures synth waveform/options.
361
+ def synth(waveform, **options)
362
+ @track.synth!(waveform, **options)
363
+ end
364
+
365
+ # Defines note notation and parser options.
366
+ def notes(notation, resolution: nil, default_octave: nil)
367
+ @track.notes!(notation, resolution: resolution, default_octave: default_octave)
368
+ end
369
+
370
+ # Defines chord notation.
371
+ def chords(notation, default_octave: nil)
372
+ @track.chords!(notation, default_octave: default_octave)
373
+ end
374
+
375
+ # Defines an ADSR envelope and validates required parameters.
376
+ def envelope(**params)
377
+ required = %i[attack decay sustain release]
378
+ missing = required - params.keys
379
+ raise Wavify::SequencerError, "missing envelope params: #{missing.join(', ')}" unless missing.empty?
380
+
381
+ @track.envelope!(**params.slice(*required))
382
+ end
383
+
384
+ # Adds an effect to the track.
385
+ def effect(name, **params)
386
+ @track.effect!(name, **params)
387
+ end
388
+
389
+ # Sets track gain in dB.
390
+ def gain(db)
391
+ @track.gain!(db)
392
+ end
393
+
394
+ # Sets track pan position.
395
+ def pan(position)
396
+ @track.pan!(position)
397
+ end
398
+ end
399
+
400
+ # :nodoc: all
401
+ # @api private
402
+ class ArrangementBuilder
403
+ attr_reader :sections
404
+
405
+ def initialize
406
+ @sections = []
407
+ end
408
+
409
+ def section(name, bars:, tracks:)
410
+ raise Wavify::SequencerError, "bars must be a positive Integer" unless bars.is_a?(Integer) && bars.positive?
411
+
412
+ names = Array(tracks).map(&:to_sym)
413
+ raise Wavify::SequencerError, "tracks must not be empty" if names.empty?
414
+
415
+ @sections << SongDefinition::Section.new(name: name.to_sym, bars: bars, tracks: names)
416
+ end
417
+ end
418
+
419
+ # :nodoc: all
420
+ # @api private
421
+ class Builder
422
+ # Internal builder state used by the public {DSL.build_definition} entrypoint.
423
+ attr_reader :format, :default_bars
424
+
425
+ # Compiles a DSL block into a {SongDefinition}.
426
+ #
427
+ # @param format [Wavify::Core::Format]
428
+ # @param tempo [Numeric]
429
+ # @param beats_per_bar [Integer]
430
+ # @param default_bars [Integer]
431
+ # @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)
434
+ builder.instance_eval(&block) if block
435
+ builder.to_song_definition
436
+ end
437
+
438
+ def initialize(format:, tempo: 120, beats_per_bar: 4, default_bars: 1)
439
+ raise Wavify::InvalidParameterError, "format must be Core::Format" unless format.is_a?(Wavify::Core::Format)
440
+
441
+ @format = format
442
+ @tempo = tempo.to_f
443
+ @beats_per_bar = beats_per_bar
444
+ @default_bars = default_bars
445
+ @track_definitions = []
446
+ @arrangement_sections = []
447
+ end
448
+
449
+ def tempo(value)
450
+ raise Wavify::SequencerError, "tempo must be a positive Numeric" unless value.is_a?(Numeric) && value.positive?
451
+
452
+ @tempo = value.to_f
453
+ end
454
+
455
+ def beats_per_bar(value)
456
+ raise Wavify::SequencerError, "beats_per_bar must be a positive Integer" unless value.is_a?(Integer) && value.positive?
457
+
458
+ @beats_per_bar = value
459
+ end
460
+
461
+ def bars(value)
462
+ raise Wavify::SequencerError, "bars must be a positive Integer" unless value.is_a?(Integer) && value.positive?
463
+
464
+ @default_bars = value
465
+ end
466
+
467
+ # 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
471
+ @track_definitions << definition
472
+ definition
473
+ end
474
+
475
+ # Defines arrangement sections for selective track playback by section.
476
+ def arrange(&block)
477
+ builder = ArrangementBuilder.new
478
+ builder.instance_eval(&block) if block
479
+ @arrangement_sections = builder.sections
480
+ end
481
+
482
+ # Finalizes and returns an immutable {SongDefinition}.
483
+ def to_song_definition
484
+ SongDefinition.new(
485
+ format: @format,
486
+ tempo: @tempo,
487
+ beats_per_bar: @beats_per_bar,
488
+ tracks: @track_definitions,
489
+ sections: @arrangement_sections
490
+ )
491
+ end
492
+ end
493
+
494
+ class << self
495
+ # Public DSL entry that returns a compiled {SongDefinition}.
496
+ #
497
+ # @param format [Wavify::Core::Format]
498
+ # @param tempo [Numeric]
499
+ # @param beats_per_bar [Integer]
500
+ # @param default_bars [Integer]
501
+ # @return [SongDefinition]
502
+ def build_definition(format:, tempo: 120, beats_per_bar: 4, default_bars: 1, &block)
503
+ Builder.build_definition(
504
+ format: format,
505
+ tempo: tempo,
506
+ beats_per_bar: beats_per_bar,
507
+ default_bars: default_bars,
508
+ &block
509
+ )
510
+ end
511
+ end
512
+ end
513
+
514
+ class << self
515
+ # Renders audio from the DSL and optionally writes it to disk.
516
+ #
517
+ # @param output_path [String, nil]
518
+ # @param format [Core::Format]
519
+ # @param tempo [Numeric]
520
+ # @param beats_per_bar [Integer]
521
+ # @param default_bars [Integer]
522
+ # @return [Audio]
523
+ def build(output_path = nil, format: Core::Format::CD_QUALITY, tempo: 120, beats_per_bar: 4, default_bars: 1, &block)
524
+ song = DSL.build_definition(
525
+ format: format,
526
+ tempo: tempo,
527
+ beats_per_bar: beats_per_bar,
528
+ default_bars: default_bars,
529
+ &block
530
+ )
531
+
532
+ audio = song.render(default_bars: default_bars)
533
+ audio.write(output_path) if output_path
534
+ audio
535
+ end
536
+ end
537
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Modulated delay chorus effect.
7
+ class Chorus < EffectBase
8
+ def initialize(rate: 1.0, depth: 0.5, mix: 0.5)
9
+ super()
10
+ @rate = validate_positive!(rate, :rate)
11
+ @depth = validate_unit!(depth, :depth)
12
+ @mix = validate_unit!(mix, :mix)
13
+ reset
14
+ end
15
+
16
+ # Processes a single sample for one channel.
17
+ #
18
+ # @param sample [Numeric]
19
+ # @param channel [Integer]
20
+ # @param sample_rate [Integer]
21
+ # @return [Float]
22
+ def process_sample(sample, channel:, sample_rate:)
23
+ dry = sample.to_f
24
+ line = @delay_lines.fetch(channel)
25
+ write_index = @write_indices.fetch(channel)
26
+
27
+ mod_phase = @lfo_phase + channel_phase_offset(channel)
28
+ mod = Math.sin(mod_phase)
29
+ delay_samples = @base_delay_samples + (@depth_delay_samples * ((mod + 1.0) / 2.0))
30
+ wet = read_fractional_delay(line, write_index, delay_samples)
31
+
32
+ line[write_index] = dry
33
+ @write_indices[channel] = (write_index + 1) % line.length
34
+ advance_lfo!
35
+
36
+ (dry * (1.0 - @mix)) + (wet * @mix)
37
+ end
38
+
39
+ private
40
+
41
+ def prepare_runtime_state(sample_rate:, channels:)
42
+ max_delay_seconds = 0.03
43
+ @base_delay_samples = [(sample_rate * 0.012).round, 1].max
44
+ @depth_delay_samples = (sample_rate * max_delay_seconds * @depth * 0.6).to_f
45
+ line_length = [(@base_delay_samples + @depth_delay_samples.ceil + 3), 8].max
46
+
47
+ @delay_lines = Array.new(channels) { Array.new(line_length, 0.0) }
48
+ @write_indices = Array.new(channels, 0)
49
+ @lfo_phase = 0.0
50
+ @lfo_step = (2.0 * Math::PI * @rate) / (sample_rate * channels)
51
+ end
52
+
53
+ def reset_runtime_state
54
+ @delay_lines = []
55
+ @write_indices = []
56
+ @base_delay_samples = nil
57
+ @depth_delay_samples = nil
58
+ @lfo_phase = 0.0
59
+ @lfo_step = 0.0
60
+ end
61
+
62
+ def read_fractional_delay(line, write_index, delay_samples)
63
+ integer = delay_samples.floor
64
+ fraction = delay_samples - integer
65
+
66
+ idx_a = (write_index - integer - 1) % line.length
67
+ idx_b = (idx_a - 1) % line.length
68
+ a = line[idx_a]
69
+ b = line[idx_b]
70
+ a + ((b - a) * fraction)
71
+ end
72
+
73
+ def channel_phase_offset(channel)
74
+ return 0.0 if @runtime_channels.nil? || @runtime_channels <= 1
75
+
76
+ (2.0 * Math::PI * channel) / @runtime_channels
77
+ end
78
+
79
+ def advance_lfo!
80
+ @lfo_phase += @lfo_step
81
+ @lfo_phase -= (2.0 * Math::PI) if @lfo_phase >= (2.0 * Math::PI)
82
+ end
83
+
84
+ def validate_positive!(value, name)
85
+ raise InvalidParameterError, "#{name} must be a positive Numeric" unless value.is_a?(Numeric) && value.positive?
86
+
87
+ value.to_f
88
+ end
89
+
90
+ def validate_unit!(value, name)
91
+ raise InvalidParameterError, "#{name} must be Numeric in 0.0..1.0" unless value.is_a?(Numeric) && value.between?(0.0, 1.0)
92
+
93
+ value.to_f
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end