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
@@ -5,12 +5,14 @@ module Wavify
5
5
  module Effects
6
6
  # Base class for sample-by-sample effects with runtime channel state.
7
7
  class EffectBase
8
+ TAIL_CHUNK_FRAMES = DSP::Processor::TAIL_CHUNK_FRAMES
9
+
8
10
  # Applies the effect to a sample buffer.
9
11
  #
10
12
  # @param buffer [Wavify::Core::SampleBuffer]
11
13
  # @return [Wavify::Core::SampleBuffer]
12
14
  def apply(buffer)
13
- process(buffer)
15
+ DSP::Processor.render(self, buffer)
14
16
  end
15
17
 
16
18
  def process(buffer)
@@ -27,12 +29,62 @@ module Wavify
27
29
  output = Array.new(float_buffer.samples.length)
28
30
  float_buffer.samples.each_with_index do |sample, sample_index|
29
31
  channel = sample_index % @runtime_channels
30
- output[sample_index] = process_sample(sample, channel: channel, sample_rate: @runtime_sample_rate).clamp(-1.0, 1.0)
32
+ output[sample_index] = process_sample(sample, channel: channel, sample_rate: @runtime_sample_rate)
31
33
  end
32
34
 
33
35
  Core::SampleBuffer.new(output, float_format).convert(buffer.format)
34
36
  end
35
37
 
38
+ # Emits effect tail after input has ended.
39
+ #
40
+ # @param format [Wavify::Core::Format, nil] output format for tail chunks
41
+ # @return [Enumerator<Wavify::Core::SampleBuffer>, nil]
42
+ def flush(format: nil)
43
+ return nil unless runtime_prepared?
44
+
45
+ frames = tail_frame_count
46
+ return nil if frames.zero?
47
+
48
+ target_format = format || Core::Format.new(
49
+ channels: @runtime_channels,
50
+ sample_rate: @runtime_sample_rate,
51
+ bit_depth: 32,
52
+ sample_format: :float
53
+ )
54
+ runtime_format = Core::Format.new(
55
+ channels: @runtime_channels,
56
+ sample_rate: @runtime_sample_rate,
57
+ bit_depth: 32,
58
+ sample_format: :float
59
+ )
60
+ Enumerator.new do |yielder|
61
+ remaining = frames
62
+ while remaining.positive?
63
+ chunk_frames = [remaining, TAIL_CHUNK_FRAMES].min
64
+ silence = Core::SampleBuffer.new(Array.new(chunk_frames * @runtime_channels, 0.0), runtime_format)
65
+ yielder << process(silence).convert(target_format)
66
+ remaining -= chunk_frames
67
+ end
68
+ ensure
69
+ reset
70
+ end
71
+ end
72
+
73
+ # @return [Float] tail duration in seconds emitted by {#flush}
74
+ def tail_duration
75
+ 0.0
76
+ end
77
+
78
+ # @return [Float] processing latency in seconds
79
+ def latency
80
+ 0.0
81
+ end
82
+
83
+ # @return [Float] lookahead duration in seconds
84
+ def lookahead
85
+ 0.0
86
+ end
87
+
36
88
  def process_sample(_sample, channel:, sample_rate:)
37
89
  raise NotImplementedError
38
90
  end
@@ -47,8 +99,23 @@ module Wavify
47
99
  self
48
100
  end
49
101
 
102
+ # Builds an independent runtime instance for offline rendering.
103
+ def build_runtime
104
+ dup.reset
105
+ end
106
+
50
107
  private
51
108
 
109
+ def runtime_prepared?
110
+ @runtime_sample_rate && @runtime_channels
111
+ end
112
+
113
+ def tail_frame_count
114
+ return 0 unless tail_duration.positive?
115
+
116
+ (@runtime_sample_rate * tail_duration).ceil
117
+ end
118
+
52
119
  def prepare_runtime_if_needed!(sample_rate:, channels:)
53
120
  return if @runtime_sample_rate == sample_rate && @runtime_channels == channels
54
121
 
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Processor that applies a fixed list of effects in sequence.
7
+ class EffectChain
8
+ attr_reader :effects
9
+
10
+ # @param effects [Array<#process,#call,#apply>]
11
+ def initialize(effects)
12
+ raise InvalidParameterError, "effects must be an Array" unless effects.is_a?(Array)
13
+
14
+ @effects = effects.map { |effect| validate_effect!(effect) }.freeze
15
+ end
16
+
17
+ # @param buffer [Wavify::Core::SampleBuffer]
18
+ # @return [Wavify::Core::SampleBuffer]
19
+ def process(buffer)
20
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
21
+
22
+ original_format = buffer.format
23
+ float_format = original_format.with(sample_format: :float, bit_depth: 32)
24
+ processed = @effects.reduce(buffer.convert(float_format)) { |current, effect| process_effect(effect, current) }
25
+ processed.convert(original_format)
26
+ end
27
+
28
+ # Applies every effect using its offline entrypoint when available.
29
+ def apply(buffer)
30
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
31
+
32
+ DSP::Processor.render(self, buffer)
33
+ end
34
+
35
+ # Flushes effect tails through every downstream processor.
36
+ def flush(format: nil)
37
+ Enumerator.new do |yielder|
38
+ @effects.each_with_index do |effect, index|
39
+ DSP::Processor.flush(effect, format: format).each do |tail|
40
+ next unless tail.sample_frame_count.positive?
41
+
42
+ processed = @effects.drop(index + 1).reduce(tail) do |current, downstream|
43
+ process_effect(downstream, current)
44
+ end
45
+ yielder << processed
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ # @return [EffectChain] self
52
+ def reset
53
+ @effects.each { |effect| effect.reset if effect.respond_to?(:reset) }
54
+ self
55
+ end
56
+
57
+ # Builds a chain whose processors do not share runtime state.
58
+ def build_runtime
59
+ runtime_effects = @effects.map { |effect| DSP::Processor.build_runtime(effect) }
60
+ runtime = dup
61
+ runtime.instance_variable_set(:@effects, runtime_effects.freeze)
62
+ runtime.reset
63
+ end
64
+
65
+ # @return [Float]
66
+ def latency
67
+ @effects.sum { |effect| DSP::Processor.duration(effect, :latency) }
68
+ end
69
+
70
+ # @return [Float]
71
+ def lookahead
72
+ @effects.sum { |effect| DSP::Processor.duration(effect, :lookahead) }
73
+ end
74
+
75
+ # @return [Float]
76
+ def tail_duration
77
+ @effects.sum { |effect| DSP::Processor.duration(effect, :tail_duration) }
78
+ end
79
+
80
+ private
81
+
82
+ def process_effect(effect, buffer)
83
+ DSP::Processor.process(effect, buffer)
84
+ end
85
+
86
+ def validate_effect!(effect)
87
+ return effect if effect.respond_to?(:process) || effect.respond_to?(:call) || effect.respond_to?(:apply)
88
+
89
+ raise InvalidParameterError, "chain effects must respond to :process, :call, or :apply"
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Peak envelope detector with attack, hold, and release timing.
7
+ class EnvelopeFollower
8
+ def initialize(attack:, hold:, release:)
9
+ @attack = validate_time!(attack, :attack)
10
+ @hold = validate_time!(hold, :hold)
11
+ @release = validate_time!(release, :release)
12
+ reset
13
+ end
14
+
15
+ def prepare(sample_rate)
16
+ @attack_coefficient = time_coefficient(@attack, sample_rate)
17
+ @release_coefficient = time_coefficient(@release, sample_rate)
18
+ @hold_frames = (@hold * sample_rate).round
19
+ reset
20
+ end
21
+
22
+ def follow(level)
23
+ if level > @envelope
24
+ @envelope = level + (@attack_coefficient * (@envelope - level))
25
+ @hold_remaining = @hold_frames
26
+ elsif @hold_remaining.positive?
27
+ @hold_remaining -= 1
28
+ else
29
+ @envelope = level + (@release_coefficient * (@envelope - level))
30
+ end
31
+ @envelope
32
+ end
33
+
34
+ def reset
35
+ @envelope = 0.0
36
+ @hold_remaining = 0
37
+ self
38
+ end
39
+
40
+ private
41
+
42
+ def time_coefficient(seconds, sample_rate)
43
+ return 0.0 if seconds.zero?
44
+
45
+ Math.exp(-1.0 / (seconds * sample_rate))
46
+ end
47
+
48
+ def validate_time!(value, name)
49
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
50
+ raise InvalidParameterError, "#{name} must be a non-negative finite Numeric"
51
+ end
52
+
53
+ value.to_f
54
+ end
55
+ end
56
+ private_constant :EnvelopeFollower
57
+
58
+ # Shared frame-linked processing for gate and expansion effects.
59
+ module EnvelopeControlledEffect
60
+ def process(buffer)
61
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
62
+
63
+ float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
64
+ float_buffer = buffer.convert(float_format)
65
+ prepare_runtime_if_needed!(sample_rate: float_format.sample_rate, channels: float_format.channels)
66
+
67
+ output = []
68
+ float_buffer.samples.each_slice(@runtime_channels) do |frame|
69
+ envelope = @envelope_follower.follow(frame.reduce(0.0) { |peak, sample| [peak, sample.abs].max })
70
+ gain = gain_for_envelope(envelope)
71
+ output.concat(frame.map { |sample| sample * gain })
72
+ end
73
+ Core::SampleBuffer.new(output, float_format).convert(buffer.format)
74
+ end
75
+
76
+ def process_sample(_sample, channel:, sample_rate:)
77
+ raise NotImplementedError, "#{self.class} requires frame-aware #apply or #process"
78
+ end
79
+
80
+ private
81
+
82
+ def prepare_runtime_state(sample_rate:, channels:)
83
+ @envelope_follower.prepare(sample_rate)
84
+ end
85
+
86
+ def reset_runtime_state
87
+ @envelope_follower.reset
88
+ end
89
+ end
90
+ private_constant :EnvelopeControlledEffect
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../filter"
4
+
5
+ module Wavify
6
+ module DSP
7
+ module Effects
8
+ # Ordered chain of biquad filters for simple EQ curves.
9
+ class EQ
10
+ def initialize(*filters)
11
+ @filters = filters.flatten
12
+ raise InvalidParameterError, "at least one filter is required" if @filters.empty?
13
+
14
+ @filters.each do |filter|
15
+ raise InvalidParameterError, "filters must respond to :apply or :process" unless filter.respond_to?(:apply) || filter.respond_to?(:process)
16
+ end
17
+ @runtime_format = nil
18
+ end
19
+
20
+ # Convenience constructor for common tone-shaping bands.
21
+ #
22
+ # @param highpass [Numeric, nil]
23
+ # @param lowpass [Numeric, nil]
24
+ # @param presence [Hash, nil] `{ cutoff:, q:, gain_db: }`
25
+ # @return [EQ]
26
+ def self.simple(highpass: nil, lowpass: nil, presence: nil)
27
+ filters = []
28
+ filters << Wavify::DSP::Filter.highpass(cutoff: highpass) if highpass
29
+ filters << Wavify::DSP::Filter.lowpass(cutoff: lowpass) if lowpass
30
+ filters << Wavify::DSP::Filter.peaking(**presence) if presence
31
+ new(filters)
32
+ end
33
+
34
+ # Applies all filters in order.
35
+ #
36
+ # @param buffer [Wavify::Core::SampleBuffer]
37
+ # @return [Wavify::Core::SampleBuffer]
38
+ def process(buffer)
39
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
40
+
41
+ original_format = buffer.format
42
+ float_format = original_format.with(sample_format: :float, bit_depth: 32)
43
+ processed = @filters.reduce(buffer.convert(float_format)) do |current, filter|
44
+ DSP::Processor.process(filter, current)
45
+ end.convert(original_format)
46
+ @runtime_format = buffer.format
47
+ processed
48
+ end
49
+
50
+ def apply(buffer)
51
+ DSP::Processor.render(self, buffer)
52
+ end
53
+
54
+ # Resets stateful filters in the chain.
55
+ #
56
+ # @return [EQ] self
57
+ def reset
58
+ @filters.each { |filter| filter.reset if filter.respond_to?(:reset) }
59
+ @runtime_format = nil
60
+ self
61
+ end
62
+
63
+ def build_runtime
64
+ self.class.new(@filters.map { |filter| DSP::Processor.build_runtime(filter) })
65
+ end
66
+
67
+ # Drains the filters' IIR state through the complete EQ chain.
68
+ def flush(format: nil)
69
+ return nil unless @runtime_format
70
+
71
+ Enumerator.new do |yielder|
72
+ @filters.each_with_index do |filter, index|
73
+ DSP::Processor.flush(filter, format: format || @runtime_format).each do |tail|
74
+ processed = @filters.drop(index + 1).reduce(tail) do |current, downstream|
75
+ DSP::Processor.process(downstream, current)
76
+ end
77
+ yielder << processed
78
+ end
79
+ end
80
+ ensure
81
+ @runtime_format = nil
82
+ end
83
+ end
84
+
85
+ # @return [Float]
86
+ def latency
87
+ @filters.sum { |filter| DSP::Processor.duration(filter, :latency) }
88
+ end
89
+
90
+ # @return [Float]
91
+ def lookahead
92
+ @filters.sum { |filter| DSP::Processor.duration(filter, :lookahead) }
93
+ end
94
+
95
+ # @return [Float]
96
+ def tail_duration
97
+ @filters.sum { |filter| DSP::Processor.duration(filter, :tail_duration) }
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Downward expander that reduces low-level material below a threshold.
7
+ class Expander < EffectBase
8
+ include EnvelopeControlledEffect
9
+
10
+ def initialize(threshold: -40.0, ratio: 2.0, floor: -80.0, attack: 0.001, hold: 0.02, release: 0.05,
11
+ gain_attack: 0.001, gain_release: 0.05)
12
+ super()
13
+ @threshold_db = validate_dbfs!(threshold, :threshold)
14
+ @ratio = validate_ratio!(ratio)
15
+ @floor_db = validate_dbfs!(floor, :floor)
16
+ @floor_gain = db_to_amplitude(@floor_db)
17
+ @gain_attack = validate_time!(gain_attack, :gain_attack)
18
+ @gain_release = validate_time!(gain_release, :gain_release)
19
+ @envelope_follower = EnvelopeFollower.new(attack: attack, hold: hold, release: release)
20
+ reset
21
+ end
22
+
23
+ private
24
+
25
+ def gain_for_envelope(envelope)
26
+ target = target_gain_for_envelope(envelope)
27
+ coefficient = target > @gain ? @gain_attack_coefficient : @gain_release_coefficient
28
+ @gain = target + (coefficient * (@gain - target))
29
+ end
30
+
31
+ def target_gain_for_envelope(envelope)
32
+ return 1.0 if envelope <= 0.0
33
+
34
+ input_db = 20.0 * Math.log10(envelope)
35
+ return 1.0 if input_db >= @threshold_db
36
+
37
+ gain_reduction_db = (@threshold_db - input_db) * (@ratio - 1.0)
38
+ [db_to_amplitude(-gain_reduction_db), @floor_gain].max
39
+ end
40
+
41
+ def prepare_runtime_state(sample_rate:, channels:)
42
+ super
43
+ @gain_attack_coefficient = time_coefficient(@gain_attack, sample_rate)
44
+ @gain_release_coefficient = time_coefficient(@gain_release, sample_rate)
45
+ end
46
+
47
+ def reset_runtime_state
48
+ super
49
+ @gain = 1.0
50
+ @gain_attack_coefficient = nil
51
+ @gain_release_coefficient = nil
52
+ end
53
+
54
+ def validate_dbfs!(value, name)
55
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value <= 0.0
56
+ raise InvalidParameterError, "#{name} must be a finite Numeric <= 0 dBFS"
57
+ end
58
+
59
+ value.to_f
60
+ end
61
+
62
+ def validate_ratio!(value)
63
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 1.0
64
+ raise InvalidParameterError, "ratio must be a finite Numeric >= 1.0"
65
+ end
66
+
67
+ value.to_f
68
+ end
69
+
70
+ def validate_time!(value, name)
71
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
72
+ raise InvalidParameterError, "#{name} must be a non-negative finite Numeric"
73
+ end
74
+
75
+ value.to_f
76
+ end
77
+
78
+ def time_coefficient(seconds, sample_rate)
79
+ return 0.0 if seconds.zero?
80
+
81
+ Math.exp(-1.0 / (seconds * sample_rate))
82
+ end
83
+
84
+ def db_to_amplitude(db)
85
+ 10.0**(db / 20.0)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Short modulated comb delay effect.
7
+ class Flanger < EffectBase
8
+ MAX_DELAY_SECONDS = 0.006
9
+ TAIL_AMPLITUDE = 1.0e-6
10
+
11
+ def initialize(rate: 0.5, depth: 0.7, feedback: 0.35, mix: 0.5)
12
+ super()
13
+ @rate = validate_positive!(rate, :rate)
14
+ @depth = validate_unit!(depth, :depth)
15
+ @feedback = validate_feedback!(feedback)
16
+ @mix = validate_unit!(mix, :mix)
17
+ reset
18
+ end
19
+
20
+ def process_sample(sample, channel:, sample_rate:)
21
+ dry = sample.to_f
22
+ line = @delay_lines.fetch(channel)
23
+ write_index = @write_indices.fetch(channel)
24
+
25
+ mod = @lfo.value_at(channel_phase_offset(channel))
26
+ delay_samples = @base_delay_samples + (@depth_delay_samples * ((mod + 1.0) / 2.0))
27
+ wet = read_fractional_delay(line, write_index, delay_samples)
28
+
29
+ line[write_index] = dry + (wet * @feedback)
30
+ @write_indices[channel] = (write_index + 1) % line.length
31
+ @lfo.next_value if channel == (@runtime_channels - 1)
32
+
33
+ (dry * (1.0 - @mix)) + (wet * @mix)
34
+ end
35
+
36
+ def tail_duration
37
+ return 0.0 if @mix.zero?
38
+
39
+ repetitions = if @feedback.zero?
40
+ 1
41
+ else
42
+ (Math.log(TAIL_AMPLITUDE) / Math.log(@feedback.abs)).ceil
43
+ end
44
+ MAX_DELAY_SECONDS * [repetitions, 1].max
45
+ end
46
+
47
+ def latency
48
+ @mix >= 1.0 ? MAX_DELAY_SECONDS : 0.0
49
+ end
50
+
51
+ private
52
+
53
+ def prepare_runtime_state(sample_rate:, channels:)
54
+ @base_delay_samples = [(sample_rate * 0.0015).round, 1].max
55
+ @depth_delay_samples = (sample_rate * (MAX_DELAY_SECONDS - 0.0015) * @depth).to_f
56
+ line_length = [(@base_delay_samples + @depth_delay_samples.ceil + 3), 8].max
57
+ @delay_lines = Array.new(channels) { Array.new(line_length, 0.0) }
58
+ @write_indices = Array.new(channels, 0)
59
+ @lfo = DSP::LFO.new(rate: @rate, sample_rate: sample_rate)
60
+ end
61
+
62
+ def reset_runtime_state
63
+ @delay_lines = []
64
+ @write_indices = []
65
+ @base_delay_samples = nil
66
+ @depth_delay_samples = nil
67
+ @lfo = nil
68
+ end
69
+
70
+ def read_fractional_delay(line, write_index, delay_samples)
71
+ integer = delay_samples.floor
72
+ fraction = delay_samples - integer
73
+ idx_a = (write_index - integer - 1) % line.length
74
+ idx_b = (idx_a - 1) % line.length
75
+ a = line[idx_a]
76
+ b = line[idx_b]
77
+ a + ((b - a) * fraction)
78
+ end
79
+
80
+ def channel_phase_offset(channel)
81
+ return 0.0 if @runtime_channels.nil? || @runtime_channels <= 1
82
+
83
+ channel.to_f / @runtime_channels
84
+ end
85
+
86
+ def validate_positive!(value, name)
87
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
88
+ raise InvalidParameterError, "#{name} must be a positive finite Numeric"
89
+ end
90
+
91
+ value.to_f
92
+ end
93
+
94
+ def validate_unit!(value, name)
95
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
96
+ raise InvalidParameterError, "#{name} must be a finite Numeric in 0.0..1.0"
97
+ end
98
+
99
+ value.to_f
100
+ end
101
+
102
+ def validate_feedback!(value)
103
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(-0.95, 0.95)
104
+ raise InvalidParameterError, "feedback must be a finite Numeric in -0.95..0.95"
105
+ end
106
+
107
+ value.to_f
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end