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
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ # Linear automation curve for time-varying parameters.
6
+ class Automation
7
+ Point = Struct.new(:time, :value, keyword_init: true)
8
+
9
+ attr_reader :points
10
+
11
+ def initialize(points)
12
+ normalized = Array(points).map { |point| coerce_point(point) }.sort_by(&:time)
13
+ raise InvalidParameterError, "automation points must not be empty" if normalized.empty?
14
+
15
+ @points = normalized.freeze
16
+ end
17
+
18
+ def value_at(time)
19
+ seconds = validate_time!(time)
20
+ return @points.first.value if seconds <= @points.first.time
21
+ return @points.last.value if seconds >= @points.last.time
22
+
23
+ right_index = @points.bsearch_index { |point| point.time >= seconds }
24
+ left = @points.fetch(right_index - 1)
25
+ right = @points.fetch(right_index)
26
+ span = right.time - left.time
27
+ return right.value if span.zero?
28
+
29
+ ratio = (seconds - left.time) / span
30
+ left.value + ((right.value - left.value) * ratio)
31
+ end
32
+
33
+ def apply(buffer)
34
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
35
+ raise InvalidParameterError, "automation apply requires a block" unless block_given?
36
+
37
+ format = buffer.format
38
+ point_index = 1
39
+ samples = []
40
+ buffer.samples.each_slice(format.channels).with_index do |frame, frame_index|
41
+ time = frame_index.to_f / format.sample_rate
42
+ point_index += 1 while point_index < @points.length && @points.fetch(point_index).time < time
43
+ value = value_between_points(time, point_index)
44
+ frame.each_with_index do |sample, channel|
45
+ samples << yield(sample, value, time, channel)
46
+ end
47
+ end
48
+ Core::SampleBuffer.new(samples, format)
49
+ end
50
+
51
+ def apply_gain(buffer, unit: :db)
52
+ mode = unit.to_sym
53
+ raise InvalidParameterError, "unit must be :db or :linear" unless %i[db linear].include?(mode)
54
+
55
+ float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
56
+ processed = apply(buffer.convert(float_format)) do |sample, value, _time, _channel|
57
+ factor = mode == :db ? (10.0**(value / 20.0)) : value
58
+ sample * factor
59
+ end
60
+ processed.convert(buffer.format)
61
+ rescue NoMethodError
62
+ raise InvalidParameterError, "unit must be Symbol/String"
63
+ end
64
+
65
+ private
66
+
67
+ def value_between_points(time, right_index)
68
+ return @points.first.value if time <= @points.first.time
69
+ return @points.last.value if right_index >= @points.length
70
+
71
+ left = @points.fetch(right_index - 1)
72
+ right = @points.fetch(right_index)
73
+ span = right.time - left.time
74
+ return right.value if span.zero?
75
+
76
+ ratio = (time - left.time) / span
77
+ left.value + ((right.value - left.value) * ratio)
78
+ end
79
+
80
+ def coerce_point(point)
81
+ time, value = case point
82
+ when Point
83
+ [point.time, point.value]
84
+ when Hash
85
+ [point.fetch(:time), point.fetch(:value)]
86
+ else
87
+ point.to_a
88
+ end
89
+ Point.new(time: validate_time!(time), value: validate_value!(value)).freeze
90
+ rescue KeyError, NoMethodError
91
+ raise InvalidParameterError, "automation point must provide time and value"
92
+ end
93
+
94
+ def validate_time!(time)
95
+ unless time.is_a?(Numeric) && time.respond_to?(:finite?) && time.finite? && time >= 0.0
96
+ raise InvalidParameterError, "automation time must be a non-negative finite Numeric"
97
+ end
98
+
99
+ time.to_f
100
+ end
101
+
102
+ def validate_value!(value)
103
+ raise InvalidParameterError, "automation value must be a finite Numeric" unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite?
104
+
105
+ value.to_f
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Stereo sine-LFO auto-panner.
7
+ class AutoPan < EffectBase
8
+ def initialize(rate: 0.5, depth: 1.0)
9
+ super()
10
+ @rate = validate_positive!(rate, :rate)
11
+ @depth = validate_unit!(depth, :depth)
12
+ reset
13
+ end
14
+
15
+ # Processes a single sample for one stereo channel.
16
+ #
17
+ # @param sample [Numeric]
18
+ # @param channel [Integer]
19
+ # @param sample_rate [Integer]
20
+ # @return [Float]
21
+ def process_sample(sample, channel:, sample_rate:)
22
+ raise InvalidParameterError, "AutoPan requires stereo input" unless @runtime_channels == 2
23
+
24
+ position = Math.sin(@phase) * @depth
25
+ left_gain, right_gain = constant_power_pan_gains(position)
26
+ output = sample.to_f * (channel.zero? ? left_gain : right_gain)
27
+ advance_phase! if channel == 1
28
+ output
29
+ end
30
+
31
+ private
32
+
33
+ def prepare_runtime_state(sample_rate:, channels:)
34
+ @phase = 0.0
35
+ @phase_step = (2.0 * Math::PI * @rate) / sample_rate
36
+ end
37
+
38
+ def reset_runtime_state
39
+ @phase = 0.0
40
+ @phase_step = 0.0
41
+ end
42
+
43
+ def constant_power_pan_gains(position)
44
+ angle = (position + 1.0) * (Math::PI / 4.0)
45
+ [Math.cos(angle), Math.sin(angle)]
46
+ end
47
+
48
+ def advance_phase!
49
+ @phase += @phase_step
50
+ @phase -= (2.0 * Math::PI) while @phase >= (2.0 * Math::PI)
51
+ end
52
+
53
+ def validate_positive!(value, name)
54
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
55
+ raise InvalidParameterError, "#{name} must be a positive finite Numeric"
56
+ end
57
+
58
+ value.to_f
59
+ end
60
+
61
+ def validate_unit!(value, name)
62
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
63
+ raise InvalidParameterError, "#{name} must be a finite Numeric in 0.0..1.0"
64
+ end
65
+
66
+ value.to_f
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Bit-depth reduction and sample-rate hold effect.
7
+ class Bitcrusher < EffectBase
8
+ def initialize(bit_depth: 8, downsample: 1, mix: 1.0)
9
+ super()
10
+ @bit_depth = validate_bit_depth!(bit_depth)
11
+ @downsample = validate_downsample!(downsample)
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
+ if (@counters.fetch(channel) % @downsample).zero?
25
+ @held_samples[channel] = quantize(dry)
26
+ end
27
+ @counters[channel] += 1
28
+
29
+ wet = @held_samples.fetch(channel)
30
+ (dry * (1.0 - @mix)) + (wet * @mix)
31
+ end
32
+
33
+ private
34
+
35
+ def prepare_runtime_state(sample_rate:, channels:)
36
+ @held_samples = Array.new(channels, 0.0)
37
+ @counters = Array.new(channels, 0)
38
+ @quantization_steps = (2**@bit_depth) - 1
39
+ end
40
+
41
+ def reset_runtime_state
42
+ @held_samples = []
43
+ @counters = []
44
+ @quantization_steps = nil
45
+ end
46
+
47
+ def quantize(value)
48
+ normalized = ((value.clamp(-1.0, 1.0) + 1.0) / 2.0)
49
+ ((normalized * @quantization_steps).round / @quantization_steps.to_f * 2.0) - 1.0
50
+ end
51
+
52
+ def validate_bit_depth!(value)
53
+ raise InvalidParameterError, "bit_depth must be an Integer in 1..24" unless value.is_a?(Integer) && value.between?(1, 24)
54
+
55
+ value
56
+ end
57
+
58
+ def validate_downsample!(value)
59
+ raise InvalidParameterError, "downsample must be a positive Integer" unless value.is_a?(Integer) && value.positive?
60
+
61
+ value
62
+ end
63
+
64
+ def validate_unit!(value, name)
65
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
66
+ raise InvalidParameterError, "#{name} must be a finite Numeric in 0.0..1.0"
67
+ end
68
+
69
+ value.to_f
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -31,11 +31,18 @@ module Wavify
31
31
 
32
32
  line[write_index] = dry
33
33
  @write_indices[channel] = (write_index + 1) % line.length
34
- advance_lfo!
34
+ advance_lfo! if channel == (@runtime_channels - 1)
35
35
 
36
36
  (dry * (1.0 - @mix)) + (wet * @mix)
37
37
  end
38
38
 
39
+ # @return [Float] maximum modulation delay emitted after input ends
40
+ def tail_duration
41
+ return 0.0 if @mix.zero?
42
+
43
+ 0.03
44
+ end
45
+
39
46
  private
40
47
 
41
48
  def prepare_runtime_state(sample_rate:, channels:)
@@ -47,7 +54,7 @@ module Wavify
47
54
  @delay_lines = Array.new(channels) { Array.new(line_length, 0.0) }
48
55
  @write_indices = Array.new(channels, 0)
49
56
  @lfo_phase = 0.0
50
- @lfo_step = (2.0 * Math::PI * @rate) / (sample_rate * channels)
57
+ @lfo_step = (2.0 * Math::PI * @rate) / sample_rate
51
58
  end
52
59
 
53
60
  def reset_runtime_state
@@ -82,13 +89,17 @@ module Wavify
82
89
  end
83
90
 
84
91
  def validate_positive!(value, name)
85
- raise InvalidParameterError, "#{name} must be a positive Numeric" unless value.is_a?(Numeric) && value.positive?
92
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
93
+ raise InvalidParameterError, "#{name} must be a positive finite Numeric"
94
+ end
86
95
 
87
96
  value.to_f
88
97
  end
89
98
 
90
99
  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)
100
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
101
+ raise InvalidParameterError, "#{name} must be a finite Numeric in 0.0..1.0"
102
+ end
92
103
 
93
104
  value.to_f
94
105
  end
@@ -5,55 +5,116 @@ module Wavify
5
5
  module Effects
6
6
  # Peak compressor with threshold, ratio, attack, and release controls.
7
7
  class Compressor < EffectBase
8
- def initialize(threshold: -10, ratio: 4, attack: 0.01, release: 0.1)
8
+ def initialize(threshold: -10, ratio: 4, attack: 0.01, release: 0.1, makeup_gain: 0.0, knee: 0.0,
9
+ sidechain: nil, sidechain_end: :silence)
9
10
  super()
10
11
  @threshold_db = validate_numeric!(threshold, :threshold).to_f
11
12
  @ratio = validate_ratio!(ratio)
12
13
  @attack = validate_time!(attack, :attack)
13
14
  @release = validate_time!(release, :release)
15
+ @makeup_gain_db = validate_numeric!(makeup_gain, :makeup_gain).to_f
16
+ @knee_db = validate_knee!(knee)
17
+ @sidechain = validate_sidechain!(sidechain)
18
+ @sidechain_end = validate_sidechain_end!(sidechain_end)
14
19
  reset
15
20
  end
16
21
 
17
- # Processes a single sample for one channel.
22
+ def process(buffer)
23
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
24
+
25
+ float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
26
+ float_buffer = buffer.convert(float_format)
27
+ prepare_runtime_if_needed!(
28
+ sample_rate: float_format.sample_rate,
29
+ channels: float_buffer.format.channels
30
+ )
31
+
32
+ output = []
33
+ float_buffer.samples.each_slice(@runtime_channels) do |frame|
34
+ detector_level = @sidechain ? next_sidechain_level : frame.map(&:abs).max
35
+ gain = gain_for_detector_level(detector_level)
36
+ output.concat(frame.map { |sample| sample * gain })
37
+ end
38
+
39
+ Core::SampleBuffer.new(output, float_format).convert(buffer.format)
40
+ end
41
+
42
+ # Compression requires a complete frame so the detector can remain
43
+ # stereo-linked. Use #process with a SampleBuffer.
18
44
  #
19
45
  # @param sample [Numeric]
20
46
  # @param channel [Integer]
21
47
  # @param sample_rate [Integer]
22
48
  # @return [Float]
23
49
  def process_sample(sample, channel:, sample_rate:)
24
- x = sample.to_f
25
- level = x.abs
26
-
27
- attack_coeff = time_coefficient(@attack, sample_rate)
28
- release_coeff = time_coefficient(@release, sample_rate)
29
- envelope = @envelopes.fetch(channel)
30
- coeff = level > envelope ? attack_coeff : release_coeff
31
- envelope += (1.0 - coeff) * (level - envelope)
32
- @envelopes[channel] = envelope
33
-
34
- gain = gain_for_envelope(envelope)
35
- x * gain
50
+ raise NotImplementedError, "Compressor requires frame-aware #apply or #process"
36
51
  end
37
52
 
38
53
  private
39
54
 
55
+ def gain_for_detector_level(level)
56
+ coeff = level > @envelope ? @attack_coefficient : @release_coefficient
57
+ @envelope += (1.0 - coeff) * (level - @envelope)
58
+
59
+ gain_for_envelope(@envelope)
60
+ end
61
+
40
62
  def prepare_runtime_state(sample_rate:, channels:)
41
- @envelopes = Array.new(channels, 0.0)
63
+ @envelope = 0.0
42
64
  @threshold_linear = 10.0**(@threshold_db / 20.0)
65
+ @attack_coefficient = time_coefficient(@attack, sample_rate)
66
+ @release_coefficient = time_coefficient(@release, sample_rate)
67
+ @sidechain_cursor = 0
68
+ @sidechain_runtime = if @sidechain
69
+ format = Core::Format.new(
70
+ channels: channels,
71
+ sample_rate: sample_rate,
72
+ bit_depth: 32,
73
+ sample_format: :float
74
+ )
75
+ @sidechain.convert(format)
76
+ end
43
77
  end
44
78
 
45
79
  def reset_runtime_state
46
- @envelopes = []
80
+ @envelope = 0.0
47
81
  @threshold_linear = nil
82
+ @attack_coefficient = nil
83
+ @release_coefficient = nil
84
+ @sidechain_cursor = 0
85
+ @sidechain_runtime = nil
48
86
  end
49
87
 
50
88
  def gain_for_envelope(envelope)
51
- return 1.0 if envelope <= 0.0 || envelope <= @threshold_linear
89
+ return makeup_gain if envelope <= 0.0
52
90
 
53
91
  input_db = 20.0 * Math.log10(envelope)
54
- output_db = @threshold_db + ((input_db - @threshold_db) / @ratio)
55
- gain_db = output_db - input_db
56
- 10.0**(gain_db / 20.0)
92
+ gain_reduction_db = compression_gain_reduction_db(input_db)
93
+ 10.0**((gain_reduction_db + @makeup_gain_db) / 20.0)
94
+ end
95
+
96
+ def compression_gain_reduction_db(input_db)
97
+ over_threshold = input_db - @threshold_db
98
+ if @knee_db.positive?
99
+ half_knee = @knee_db / 2.0
100
+ return 0.0 if over_threshold <= -half_knee
101
+ return hard_knee_gain_reduction_db(over_threshold) if over_threshold >= half_knee
102
+
103
+ knee_position = over_threshold + half_knee
104
+ ((1.0 / @ratio) - 1.0) * (knee_position * knee_position) / (2.0 * @knee_db)
105
+ else
106
+ hard_knee_gain_reduction_db(over_threshold)
107
+ end
108
+ end
109
+
110
+ def hard_knee_gain_reduction_db(over_threshold)
111
+ return 0.0 unless over_threshold.positive?
112
+
113
+ (over_threshold / @ratio) - over_threshold
114
+ end
115
+
116
+ def makeup_gain
117
+ 10.0**(@makeup_gain_db / 20.0)
57
118
  end
58
119
 
59
120
  def time_coefficient(seconds, sample_rate)
@@ -63,22 +124,71 @@ module Wavify
63
124
  end
64
125
 
65
126
  def validate_numeric!(value, name)
66
- raise InvalidParameterError, "#{name} must be Numeric" unless value.is_a?(Numeric)
127
+ raise InvalidParameterError, "#{name} must be a finite Numeric" unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite?
67
128
 
68
129
  value
69
130
  end
70
131
 
71
132
  def validate_ratio!(value)
72
- raise InvalidParameterError, "ratio must be >= 1.0" unless value.is_a?(Numeric) && value >= 1.0
133
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 1.0
134
+ raise InvalidParameterError, "ratio must be a finite Numeric >= 1.0"
135
+ end
73
136
 
74
137
  value.to_f
75
138
  end
76
139
 
77
140
  def validate_time!(value, name)
78
- raise InvalidParameterError, "#{name} must be a non-negative Numeric" unless value.is_a?(Numeric) && value >= 0.0
141
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
142
+ raise InvalidParameterError, "#{name} must be a non-negative finite Numeric"
143
+ end
79
144
 
80
145
  value.to_f
81
146
  end
147
+
148
+ def validate_knee!(value)
149
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
150
+ raise InvalidParameterError, "knee must be a non-negative finite Numeric"
151
+ end
152
+
153
+ value.to_f
154
+ end
155
+
156
+ def validate_sidechain!(value)
157
+ return nil if value.nil?
158
+ return value.buffer if defined?(Wavify::Audio) && value.is_a?(Wavify::Audio)
159
+ return value if value.is_a?(Core::SampleBuffer)
160
+
161
+ raise InvalidParameterError, "sidechain must be Audio or Core::SampleBuffer"
162
+ end
163
+
164
+ def validate_sidechain_end!(value)
165
+ normalized = value.to_sym if value.respond_to?(:to_sym)
166
+ return normalized if %i[silence hold loop].include?(normalized)
167
+
168
+ raise InvalidParameterError, "sidechain_end must be :silence, :hold, or :loop"
169
+ end
170
+
171
+ def next_sidechain_level
172
+ frame_count = @sidechain_runtime.sample_frame_count
173
+ frame_index = sidechain_frame_index(frame_count)
174
+ @sidechain_cursor += 1
175
+ return 0.0 unless frame_index
176
+
177
+ start_index = frame_index * @runtime_channels
178
+ frame = @sidechain_runtime.samples.slice(start_index, @runtime_channels) || []
179
+ frame.map(&:abs).max || 0.0
180
+ end
181
+
182
+ def sidechain_frame_index(frame_count)
183
+ return nil if frame_count.zero?
184
+ return @sidechain_cursor if @sidechain_cursor < frame_count
185
+
186
+ case @sidechain_end
187
+ when :silence then nil
188
+ when :hold then frame_count - 1
189
+ when :loop then @sidechain_cursor % frame_count
190
+ end
191
+ end
82
192
  end
83
193
  end
84
194
  end
@@ -5,6 +5,34 @@ module Wavify
5
5
  module Effects
6
6
  # Simple feedback delay effect.
7
7
  class Delay < EffectBase
8
+ NOTE_MULTIPLIERS = {
9
+ whole: 4.0,
10
+ half: 2.0,
11
+ quarter: 1.0,
12
+ eighth: 0.5,
13
+ sixteenth: 0.25,
14
+ thirty_second: 0.125
15
+ }.freeze
16
+
17
+ # Builds a tempo-synced delay.
18
+ #
19
+ # @param note [Symbol, String] note value such as `:quarter` or `:eighth`
20
+ # @param tempo [Numeric] beats per minute
21
+ # @param dotted [Boolean] multiply by 1.5
22
+ # @param triplet [Boolean] multiply by 2/3
23
+ # @return [Delay]
24
+ def self.beat(note, tempo:, dotted: false, triplet: false, feedback: 0.5, mix: 0.3)
25
+ multiplier = note_multiplier!(note)
26
+ unless tempo.is_a?(Numeric) && tempo.respond_to?(:finite?) && tempo.finite? && tempo.positive?
27
+ raise InvalidParameterError, "tempo must be a positive finite Numeric"
28
+ end
29
+ raise InvalidParameterError, "dotted and triplet cannot both be true" if dotted && triplet
30
+
31
+ multiplier *= 1.5 if dotted
32
+ multiplier *= (2.0 / 3.0) if triplet
33
+ new(time: (60.0 / tempo.to_f) * multiplier, feedback: feedback, mix: mix)
34
+ end
35
+
8
36
  def initialize(time: 0.3, feedback: 0.5, mix: 0.3)
9
37
  super()
10
38
  @time = validate_time!(time)
@@ -27,14 +55,31 @@ module Wavify
27
55
  wet = delayed
28
56
 
29
57
  output = (dry * (1.0 - @mix)) + (wet * @mix)
30
- line[index] = (dry + (wet * @feedback)).clamp(-1.0, 1.0)
58
+ line[index] = dry + (wet * @feedback)
31
59
  @write_indices[channel] = (index + 1) % line.length
32
60
 
33
61
  output
34
62
  end
35
63
 
64
+ # @return [Float] estimated feedback tail duration in seconds
65
+ def tail_duration
66
+ return 0.0 if @mix.zero?
67
+ return @time if @feedback.zero?
68
+
69
+ repeats = (Math.log(0.001) / Math.log(@feedback)).ceil
70
+ @time * [[repeats, 1].max, 32].min
71
+ end
72
+
36
73
  private
37
74
 
75
+ def self.note_multiplier!(note)
76
+ normalized = note.to_sym if note.respond_to?(:to_sym)
77
+ return NOTE_MULTIPLIERS.fetch(normalized) if NOTE_MULTIPLIERS.key?(normalized)
78
+
79
+ raise InvalidParameterError, "note must be one of: #{NOTE_MULTIPLIERS.keys.join(', ')}"
80
+ end
81
+ private_class_method :note_multiplier!
82
+
38
83
  def prepare_runtime_state(sample_rate:, channels:)
39
84
  delay_samples = [(sample_rate * @time).round, 1].max
40
85
  @delay_lines = Array.new(channels) { Array.new(delay_samples, 0.0) }
@@ -24,9 +24,8 @@ module Wavify
24
24
  wet = Math.tanh(dry * pre_gain)
25
25
 
26
26
  # One-pole low-pass on the distorted signal for tone shaping.
27
- coeff = tone_coefficient(sample_rate)
28
27
  previous = @tone_state.fetch(channel)
29
- filtered = previous + (coeff * (wet - previous))
28
+ filtered = previous + (@tone_coefficient * (wet - previous))
30
29
  @tone_state[channel] = filtered
31
30
 
32
31
  (dry * (1.0 - @mix)) + (filtered * @mix)
@@ -36,10 +35,12 @@ module Wavify
36
35
 
37
36
  def prepare_runtime_state(sample_rate:, channels:)
38
37
  @tone_state = Array.new(channels, 0.0)
38
+ @tone_coefficient = tone_coefficient(sample_rate)
39
39
  end
40
40
 
41
41
  def reset_runtime_state
42
42
  @tone_state = []
43
+ @tone_coefficient = nil
43
44
  end
44
45
 
45
46
  def pre_gain