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,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Stereo-linked lookahead peak limiter with attack and release smoothing.
7
+ class Limiter < EffectBase
8
+ MAX_LOOKAHEAD_SECONDS = 1.0
9
+
10
+ # FIFO with an amortized O(1) maximum for streaming lookahead detection.
11
+ class LookaheadQueue
12
+ def initialize
13
+ @incoming = []
14
+ @outgoing = []
15
+ end
16
+
17
+ def push(frame, detector_peak, current_peak)
18
+ maximum = [detector_peak, @incoming.last&.fetch(3) || 0.0].max
19
+ @incoming << [frame, current_peak, detector_peak, maximum]
20
+ end
21
+
22
+ def shift
23
+ refill_outgoing if @outgoing.empty?
24
+ entry = @outgoing.pop
25
+ [entry.fetch(0), entry.fetch(1)]
26
+ end
27
+
28
+ def maximum
29
+ [@incoming.last&.fetch(3) || 0.0, @outgoing.last&.fetch(3) || 0.0].max
30
+ end
31
+
32
+ def length
33
+ @incoming.length + @outgoing.length
34
+ end
35
+
36
+ def empty?
37
+ @incoming.empty? && @outgoing.empty?
38
+ end
39
+
40
+ private
41
+
42
+ def refill_outgoing
43
+ until @incoming.empty?
44
+ frame, current_peak, detector_peak = @incoming.pop.first(3)
45
+ maximum = [detector_peak, @outgoing.last&.fetch(3) || 0.0].max
46
+ @outgoing << [frame, current_peak, detector_peak, maximum]
47
+ end
48
+ end
49
+ end
50
+ private_constant :LookaheadQueue
51
+
52
+ def initialize(ceiling: -1.0, input_gain: 0.0, attack: 0.001, release: 0.05, lookahead: 0.005,
53
+ oversampling: 1)
54
+ super()
55
+ @ceiling_db = validate_dbfs!(ceiling, :ceiling)
56
+ @input_gain_db = validate_finite_numeric!(input_gain, :input_gain).to_f
57
+ @attack = validate_time!(attack, :attack)
58
+ @release = validate_time!(release, :release)
59
+ @lookahead = validate_time!(lookahead, :lookahead)
60
+ @oversampling = validate_oversampling!(oversampling)
61
+ @ceiling = db_to_amplitude(@ceiling_db)
62
+ @input_gain = db_to_amplitude(@input_gain_db)
63
+ reset
64
+ end
65
+
66
+ # Applies the limiter on an isolated runtime without adding latency or
67
+ # changing any streaming state already held by this instance.
68
+ def apply(buffer)
69
+ build_runtime.send(:apply_offline, buffer)
70
+ end
71
+
72
+ # Processes a streaming chunk while preserving a lookahead delay.
73
+ def process(buffer)
74
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
75
+
76
+ float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
77
+ float_buffer = buffer.convert(float_format)
78
+ prepare_runtime_if_needed!(sample_rate: float_format.sample_rate, channels: float_format.channels)
79
+
80
+ output = []
81
+ float_buffer.samples.each_slice(@runtime_channels) do |frame|
82
+ delayed_frame = frame.map { |sample| sample * @input_gain }
83
+ limited_frame = enqueue_and_limit(delayed_frame)
84
+ output.concat(limited_frame || Array.new(@runtime_channels, 0.0))
85
+ end
86
+ Core::SampleBuffer.new(output, float_format).convert(buffer.format)
87
+ end
88
+
89
+ # Emits samples retained by the lookahead delay.
90
+ def flush(format: nil)
91
+ return nil unless runtime_prepared? && @delay_frames && @lookahead_frames.positive?
92
+
93
+ runtime_format = Core::Format.new(
94
+ channels: @runtime_channels,
95
+ sample_rate: @runtime_sample_rate,
96
+ bit_depth: 32,
97
+ sample_format: :float
98
+ )
99
+ output = flush_limiter_frames.flatten(1)
100
+ target_format = format || runtime_format
101
+ Core::SampleBuffer.new(output, runtime_format).convert(target_format)
102
+ end
103
+
104
+ def latency
105
+ @lookahead
106
+ end
107
+
108
+ def lookahead
109
+ @lookahead
110
+ end
111
+
112
+ def tail_duration
113
+ @lookahead
114
+ end
115
+
116
+ def process_sample(_sample, channel:, sample_rate:)
117
+ raise NotImplementedError, "Limiter requires frame-aware #apply or #process"
118
+ end
119
+
120
+ private
121
+
122
+ def apply_offline(buffer)
123
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
124
+
125
+ float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
126
+ float_buffer = buffer.convert(float_format)
127
+ prepare_runtime_if_needed!(sample_rate: float_format.sample_rate, channels: float_format.channels)
128
+ reset_limiter_state
129
+
130
+ frames = float_buffer.samples.each_slice(float_format.channels).map do |frame|
131
+ frame.map { |sample| sample * @input_gain }
132
+ end
133
+ output = limit_offline_frames(frames).flatten(1)
134
+ Core::SampleBuffer.new(output, float_format).convert(buffer.format)
135
+ end
136
+
137
+ def limit_offline_frames(frames)
138
+ delayed = frames.map do |frame|
139
+ enqueue_and_limit(frame) || Array.new(@runtime_channels, 0.0)
140
+ end
141
+ delayed.concat(flush_limiter_frames).drop(@lookahead_frames).first(frames.length)
142
+ end
143
+
144
+ def enqueue_and_limit(frame)
145
+ current_peak = frame_peak(frame)
146
+ @delay_frames.push(frame, detector_peak(frame, current_peak: current_peak), current_peak)
147
+ if @delay_frames.length <= @lookahead_frames
148
+ update_gain(@delay_frames.maximum)
149
+ return nil
150
+ end
151
+
152
+ delayed_frame, delayed_peak = @delay_frames.shift
153
+ limit_frame(delayed_frame, @delay_frames.maximum, current_peak: delayed_peak)
154
+ end
155
+
156
+ def flush_limiter_frames
157
+ silence = Array.new(@runtime_channels, 0.0)
158
+ output = Array.new(@lookahead_frames) do
159
+ enqueue_and_limit(silence.dup) || silence.dup
160
+ end
161
+ reset_limiter_state
162
+ output
163
+ end
164
+
165
+ def limit_frame(frame, peak, current_peak: frame_peak(frame))
166
+ smoothed_gain = update_gain(peak)
167
+ required_gain = current_peak.positive? ? [@ceiling / current_peak, 1.0].min : 1.0
168
+ applied_gain = [smoothed_gain, required_gain].min
169
+ frame.map { |sample| (sample * applied_gain).clamp(-@ceiling, @ceiling) }
170
+ end
171
+
172
+ def update_gain(peak)
173
+ target = peak > @ceiling ? @ceiling / peak : 1.0
174
+ coefficient = target < @gain ? @attack_coefficient : @release_coefficient
175
+ @gain = target + (coefficient * (@gain - target))
176
+ end
177
+
178
+ def frame_peak(frame)
179
+ frame.reduce(0.0) { |peak, sample| [peak, sample.abs].max }
180
+ end
181
+
182
+ def prepare_runtime_state(sample_rate:, channels:)
183
+ @lookahead_frames = (@lookahead * sample_rate).round
184
+ @attack_coefficient = time_coefficient(@attack, sample_rate)
185
+ @release_coefficient = time_coefficient(@release, sample_rate)
186
+ reset_limiter_state
187
+ end
188
+
189
+ def reset_runtime_state
190
+ @lookahead_frames = 0
191
+ @attack_coefficient = nil
192
+ @release_coefficient = nil
193
+ reset_limiter_state
194
+ end
195
+
196
+ def reset_limiter_state
197
+ @delay_frames = LookaheadQueue.new
198
+ @gain = 1.0
199
+ @true_peak_history = []
200
+ end
201
+
202
+ def detector_peak(frame, current_peak:)
203
+ return current_peak if @oversampling == 1
204
+
205
+ @true_peak_history.concat(frame)
206
+ history_samples = DSP::LoudnessMeter::TRUE_PEAK_RADIUS * 2 * @runtime_channels
207
+ @true_peak_history.shift(@true_peak_history.length - history_samples) if @true_peak_history.length > history_samples
208
+ DSP::LoudnessMeter.true_peak(
209
+ @true_peak_history,
210
+ sample_rate: @runtime_sample_rate,
211
+ channels: @runtime_channels,
212
+ oversampling: @oversampling
213
+ )
214
+ end
215
+
216
+ def time_coefficient(seconds, sample_rate)
217
+ return 0.0 if seconds.zero?
218
+
219
+ Math.exp(-1.0 / (seconds * sample_rate))
220
+ end
221
+
222
+ def validate_dbfs!(value, name)
223
+ numeric = validate_finite_numeric!(value, name)
224
+ raise InvalidParameterError, "#{name} must be <= 0 dBFS" if numeric.positive?
225
+
226
+ numeric.to_f
227
+ end
228
+
229
+ def validate_time!(value, name)
230
+ numeric = validate_finite_numeric!(value, name)
231
+ raise InvalidParameterError, "#{name} must be non-negative" if numeric.negative?
232
+ if name == :lookahead && numeric > MAX_LOOKAHEAD_SECONDS
233
+ raise InvalidParameterError, "lookahead must be <= #{MAX_LOOKAHEAD_SECONDS} seconds"
234
+ end
235
+
236
+ numeric.to_f
237
+ end
238
+
239
+ def validate_finite_numeric!(value, name)
240
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite?
241
+ raise InvalidParameterError, "#{name} must be a finite Numeric"
242
+ end
243
+
244
+ value
245
+ end
246
+
247
+ def validate_oversampling!(value)
248
+ return value if [1, 2, 4, 8].include?(value)
249
+
250
+ raise InvalidParameterError, "oversampling must be 1, 2, 4, or 8"
251
+ end
252
+
253
+ def db_to_amplitude(db)
254
+ 10.0**(db / 20.0)
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Small mastering-oriented preset chain.
7
+ class MasteringChain < EffectChain
8
+ # @param highpass [Numeric, nil]
9
+ # @param presence [Numeric, nil] peaking EQ gain in dB
10
+ # @param threshold [Numeric] compressor threshold in dBFS
11
+ # @param ratio [Numeric] compressor ratio
12
+ # @param ceiling [Numeric] limiter ceiling in dBFS
13
+ def initialize(highpass: 30.0, presence: 1.5, threshold: -18.0, ratio: 2.0, ceiling: -1.0)
14
+ super([
15
+ EQ.simple(highpass: highpass, presence: presence_filter(presence)),
16
+ Compressor.new(threshold: threshold, ratio: ratio, attack: 0.005, release: 0.08, makeup_gain: 1.5, knee: 6.0),
17
+ Limiter.new(ceiling: ceiling)
18
+ ])
19
+ end
20
+
21
+ private
22
+
23
+ def presence_filter(gain_db)
24
+ return nil unless gain_db
25
+
26
+ { cutoff: 3_500.0, q: 0.8, gain_db: gain_db }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Simple downward gate for suppressing low-level noise.
7
+ class NoiseGate < EffectBase
8
+ include EnvelopeControlledEffect
9
+
10
+ def initialize(threshold: -40.0, floor: -80.0, attack: 0.001, hold: 0.02, release: 0.05,
11
+ gain_attack: attack, gain_release: release)
12
+ super()
13
+ @threshold_db = validate_dbfs!(threshold, :threshold)
14
+ @floor_db = validate_dbfs!(floor, :floor)
15
+ @threshold = db_to_amplitude(@threshold_db)
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 = envelope < @threshold ? @floor_gain : 1.0
27
+ coefficient = target > @gain ? @gain_attack_coefficient : @gain_release_coefficient
28
+ @gain = target + (coefficient * (@gain - target))
29
+ end
30
+
31
+ def prepare_runtime_state(sample_rate:, channels:)
32
+ super
33
+ @gain_attack_coefficient = time_coefficient(@gain_attack, sample_rate)
34
+ @gain_release_coefficient = time_coefficient(@gain_release, sample_rate)
35
+ end
36
+
37
+ def reset_runtime_state
38
+ super
39
+ @gain = @floor_gain
40
+ @gain_attack_coefficient = nil
41
+ @gain_release_coefficient = nil
42
+ end
43
+
44
+ def validate_dbfs!(value, name)
45
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value <= 0.0
46
+ raise InvalidParameterError, "#{name} must be a finite Numeric <= 0 dBFS"
47
+ end
48
+
49
+ value.to_f
50
+ end
51
+
52
+ def validate_time!(value, name)
53
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
54
+ raise InvalidParameterError, "#{name} must be a non-negative finite Numeric"
55
+ end
56
+
57
+ value.to_f
58
+ end
59
+
60
+ def time_coefficient(seconds, sample_rate)
61
+ return 0.0 if seconds.zero?
62
+
63
+ Math.exp(-1.0 / (seconds * sample_rate))
64
+ end
65
+
66
+ def db_to_amplitude(db)
67
+ 10.0**(db / 20.0)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Modulated all-pass phase shifting effect.
7
+ class Phaser < EffectBase
8
+ MAX_STAGES = 32
9
+ TAIL_AMPLITUDE = 1.0e-6
10
+ MAX_ALLPASS_COEFFICIENT = 0.9
11
+ REFERENCE_SAMPLE_RATE = 44_100.0
12
+
13
+ def initialize(rate: 0.5, depth: 0.7, feedback: 0.2, mix: 0.5, stages: 4)
14
+ super()
15
+ @rate = validate_positive!(rate, :rate)
16
+ @depth = validate_unit!(depth, :depth)
17
+ @feedback = validate_feedback!(feedback)
18
+ @mix = validate_unit!(mix, :mix)
19
+ @stages = validate_stages!(stages)
20
+ reset
21
+ end
22
+
23
+ def process_sample(sample, channel:, sample_rate:)
24
+ dry = sample.to_f
25
+ coefficient = modulated_coefficient(channel)
26
+ wet = process_allpass_chain(dry + (@feedback_samples.fetch(channel) * @feedback), channel, coefficient)
27
+ @feedback_samples[channel] = wet
28
+ @lfo.next_value if channel == (@runtime_channels - 1)
29
+
30
+ (dry * (1.0 - @mix)) + (wet * @mix)
31
+ end
32
+
33
+ def tail_duration
34
+ return 0.0 if @mix.zero?
35
+
36
+ pole = [MAX_ALLPASS_COEFFICIENT, @feedback.abs].max
37
+ decay_samples = (Math.log(TAIL_AMPLITUDE) / Math.log(pole)).ceil
38
+ (@stages * decay_samples) / REFERENCE_SAMPLE_RATE
39
+ end
40
+
41
+ private
42
+
43
+ def prepare_runtime_state(sample_rate:, channels:)
44
+ @lfo = DSP::LFO.new(rate: @rate, sample_rate: sample_rate)
45
+ @stage_states = Array.new(channels) { Array.new(@stages, 0.0) }
46
+ @feedback_samples = Array.new(channels, 0.0)
47
+ end
48
+
49
+ def reset_runtime_state
50
+ @lfo = nil
51
+ @stage_states = []
52
+ @feedback_samples = []
53
+ end
54
+
55
+ def modulated_coefficient(channel)
56
+ mod = (@lfo.value_at(channel_phase_offset(channel)) + 1.0) / 2.0
57
+ (0.08 + (0.82 * @depth * mod)).clamp(0.02, 0.9)
58
+ end
59
+
60
+ def process_allpass_chain(input, channel, coefficient)
61
+ output = input
62
+ states = @stage_states.fetch(channel)
63
+ states.each_index do |stage|
64
+ delayed = states.fetch(stage)
65
+ y = delayed - (coefficient * output)
66
+ states[stage] = output + (coefficient * y)
67
+ output = y
68
+ end
69
+ output
70
+ end
71
+
72
+ def channel_phase_offset(channel)
73
+ return 0.0 if @runtime_channels.nil? || @runtime_channels <= 1
74
+
75
+ channel.to_f / @runtime_channels
76
+ end
77
+
78
+ def validate_positive!(value, name)
79
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
80
+ raise InvalidParameterError, "#{name} must be a positive finite Numeric"
81
+ end
82
+
83
+ value.to_f
84
+ end
85
+
86
+ def validate_unit!(value, name)
87
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
88
+ raise InvalidParameterError, "#{name} must be a finite Numeric in 0.0..1.0"
89
+ end
90
+
91
+ value.to_f
92
+ end
93
+
94
+ def validate_feedback!(value)
95
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(-0.95, 0.95)
96
+ raise InvalidParameterError, "feedback must be a finite Numeric in -0.95..0.95"
97
+ end
98
+
99
+ value.to_f
100
+ end
101
+
102
+ def validate_stages!(value)
103
+ unless value.is_a?(Integer) && value.between?(1, MAX_STAGES)
104
+ raise InvalidParameterError, "stages must be an Integer in 1..#{MAX_STAGES}"
105
+ end
106
+
107
+ value
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Speech-oriented cleanup preset for voice tracks.
7
+ class PodcastChain < EffectChain
8
+ # @param gate_threshold [Numeric] noise gate threshold in dBFS
9
+ # @param highpass [Numeric, nil]
10
+ # @param presence [Numeric, nil] peaking EQ gain in dB
11
+ # @param compression_threshold [Numeric] compressor threshold in dBFS
12
+ # @param ceiling [Numeric] limiter ceiling in dBFS
13
+ def initialize(gate_threshold: -45.0, highpass: 80.0, presence: 3.0, compression_threshold: -20.0, ceiling: -1.0)
14
+ super([
15
+ NoiseGate.new(threshold: gate_threshold, floor: -80.0),
16
+ EQ.simple(highpass: highpass, presence: presence_filter(presence)),
17
+ Compressor.new(threshold: compression_threshold, ratio: 3.0, attack: 0.003, release: 0.12, makeup_gain: 2.0, knee: 4.0),
18
+ Limiter.new(ceiling: ceiling)
19
+ ])
20
+ end
21
+
22
+ private
23
+
24
+ def presence_filter(gain_db)
25
+ return nil unless gain_db
26
+
27
+ { cutoff: 3_000.0, q: 0.9, gain_db: gain_db }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -8,14 +8,47 @@ module Wavify
8
8
  COMB_TAPS_44K = [1116, 1188, 1277, 1356].freeze # :nodoc:
9
9
  ALLPASS_TAPS_44K = [556, 441].freeze # :nodoc:
10
10
 
11
- def initialize(room_size: 0.5, damping: 0.5, mix: 0.3)
11
+ def initialize(room_size: 0.5, damping: 0.5, mix: 0.3, pre_delay: 0.0, width: 1.0)
12
12
  super()
13
13
  @room_size = validate_unit!(room_size, :room_size)
14
14
  @damping = validate_unit!(damping, :damping)
15
15
  @mix = validate_unit!(mix, :mix)
16
+ @pre_delay = validate_time!(pre_delay, :pre_delay)
17
+ @width = validate_width!(width)
16
18
  reset
17
19
  end
18
20
 
21
+ # Processes a sample buffer.
22
+ #
23
+ # Stereo buffers apply `width:` to the wet signal before mixing it with
24
+ # the dry input. Mono and multichannel buffers keep per-channel wet paths.
25
+ #
26
+ # @param buffer [Wavify::Core::SampleBuffer]
27
+ # @return [Wavify::Core::SampleBuffer]
28
+ def process(buffer)
29
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
30
+
31
+ float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
32
+ float_buffer = buffer.convert(float_format)
33
+ channels = float_buffer.format.channels
34
+ prepare_runtime_if_needed!(sample_rate: float_format.sample_rate, channels: channels)
35
+
36
+ output = Array.new(float_buffer.samples.length)
37
+ float_buffer.samples.each_slice(channels).with_index do |frame, frame_index|
38
+ wet_frame = frame.each_with_index.map do |sample, channel|
39
+ wet_sample_for(@channels_state.fetch(channel), sample.to_f)
40
+ end
41
+ wet_frame = apply_stereo_width(wet_frame) if channels == 2
42
+
43
+ base = frame_index * channels
44
+ frame.each_with_index do |dry, channel|
45
+ output[base + channel] = mix_dry_wet(dry.to_f, wet_frame.fetch(channel))
46
+ end
47
+ end
48
+
49
+ Core::SampleBuffer.new(output, float_format).convert(buffer.format)
50
+ end
51
+
19
52
  # Processes a single sample for one channel.
20
53
  #
21
54
  # @param sample [Numeric]
@@ -25,19 +58,14 @@ module Wavify
25
58
  def process_sample(sample, channel:, sample_rate:)
26
59
  dry = sample.to_f
27
60
  channel_state = @channels_state.fetch(channel)
61
+ mix_dry_wet(dry, wet_sample_for(channel_state, dry))
62
+ end
28
63
 
29
- comb_input = dry * @input_gain
30
- comb_sum = 0.0
31
- channel_state[:combs].each do |comb|
32
- comb_sum += process_comb(comb, comb_input)
33
- end
34
-
35
- wet = comb_sum / channel_state[:combs].length
36
- channel_state[:allpasses].each do |allpass|
37
- wet = process_allpass(allpass, wet)
38
- end
64
+ # @return [Float] estimated reverb tail duration in seconds
65
+ def tail_duration
66
+ return 0.0 if @mix.zero?
39
67
 
40
- (dry * (1.0 - @mix)) + (wet * @mix)
68
+ @pre_delay + 0.25 + (@room_size * 2.5)
41
69
  end
42
70
 
43
71
  private
@@ -48,6 +76,7 @@ module Wavify
48
76
  damping = @damping
49
77
 
50
78
  @input_gain = 0.35
79
+ pre_delay_frames = (@pre_delay * sample_rate).round
51
80
  @channels_state = Array.new(channels) do
52
81
  combs = COMB_TAPS_44K.map do |tap|
53
82
  length = [(tap * scale).round, 8].max
@@ -67,7 +96,12 @@ module Wavify
67
96
  feedback: 0.5
68
97
  }
69
98
  end
70
- { combs: combs, allpasses: allpasses }
99
+ {
100
+ combs: combs,
101
+ allpasses: allpasses,
102
+ pre_delay_buffer: Array.new(pre_delay_frames, 0.0),
103
+ pre_delay_index: 0
104
+ }
71
105
  end
72
106
  end
73
107
 
@@ -76,6 +110,42 @@ module Wavify
76
110
  @input_gain = nil
77
111
  end
78
112
 
113
+ def pre_delay_sample(channel_state, input_sample)
114
+ buffer = channel_state[:pre_delay_buffer]
115
+ return input_sample if buffer.empty?
116
+
117
+ index = channel_state[:pre_delay_index]
118
+ delayed = buffer[index]
119
+ buffer[index] = input_sample
120
+ channel_state[:pre_delay_index] = (index + 1) % buffer.length
121
+ delayed
122
+ end
123
+
124
+ def wet_sample_for(channel_state, dry)
125
+ comb_input = pre_delay_sample(channel_state, dry) * @input_gain
126
+ comb_sum = 0.0
127
+ channel_state[:combs].each do |comb|
128
+ comb_sum += process_comb(comb, comb_input)
129
+ end
130
+
131
+ wet = comb_sum / channel_state[:combs].length
132
+ channel_state[:allpasses].each do |allpass|
133
+ wet = process_allpass(allpass, wet)
134
+ end
135
+ wet
136
+ end
137
+
138
+ def apply_stereo_width(wet_frame)
139
+ left, right = wet_frame
140
+ mid = (left + right) * 0.5
141
+ side = ((left - right) * 0.5) * @width
142
+ [mid + side, mid - side]
143
+ end
144
+
145
+ def mix_dry_wet(dry, wet)
146
+ (dry * (1.0 - @mix)) + (wet * @mix)
147
+ end
148
+
79
149
  def process_comb(comb, input_sample)
80
150
  buffer = comb[:buffer]
81
151
  index = comb[:index]
@@ -83,7 +153,7 @@ module Wavify
83
153
 
84
154
  filter_store = (delayed * (1.0 - comb[:damping])) + (comb[:filter_store] * comb[:damping])
85
155
  comb[:filter_store] = filter_store
86
- buffer[index] = (input_sample + (filter_store * comb[:feedback])).clamp(-1.0, 1.0)
156
+ buffer[index] = input_sample + (filter_store * comb[:feedback])
87
157
  comb[:index] = (index + 1) % buffer.length
88
158
 
89
159
  delayed
@@ -106,6 +176,22 @@ module Wavify
106
176
 
107
177
  value.to_f
108
178
  end
179
+
180
+ def validate_time!(value, name)
181
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
182
+ raise InvalidParameterError, "#{name} must be a non-negative finite Numeric"
183
+ end
184
+
185
+ value.to_f
186
+ end
187
+
188
+ def validate_width!(value)
189
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 2.0)
190
+ raise InvalidParameterError, "width must be a finite Numeric in 0.0..2.0"
191
+ end
192
+
193
+ value.to_f
194
+ end
109
195
  end
110
196
  end
111
197
  end