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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/README.md +204 -22
- data/exe/wavify +6 -0
- data/lib/wavify/adapters.rb +81 -0
- data/lib/wavify/audio.rb +1066 -120
- data/lib/wavify/cli.rb +237 -0
- data/lib/wavify/codecs/aiff.rb +388 -87
- data/lib/wavify/codecs/base.rb +98 -5
- data/lib/wavify/codecs/flac.rb +660 -170
- data/lib/wavify/codecs/ogg_vorbis.rb +515 -155
- data/lib/wavify/codecs/raw.rb +235 -45
- data/lib/wavify/codecs/registry.rb +272 -10
- data/lib/wavify/codecs/wav.rb +453 -74
- data/lib/wavify/core/duration.rb +54 -2
- data/lib/wavify/core/format.rb +96 -11
- data/lib/wavify/core/sample_buffer.rb +648 -60
- data/lib/wavify/core/stream.rb +590 -34
- data/lib/wavify/dsl.rb +712 -100
- data/lib/wavify/dsp/automation.rb +109 -0
- data/lib/wavify/dsp/effects/auto_pan.rb +71 -0
- data/lib/wavify/dsp/effects/bitcrusher.rb +74 -0
- data/lib/wavify/dsp/effects/chorus.rb +15 -4
- data/lib/wavify/dsp/effects/compressor.rb +133 -23
- data/lib/wavify/dsp/effects/delay.rb +46 -1
- data/lib/wavify/dsp/effects/distortion.rb +3 -2
- data/lib/wavify/dsp/effects/effect_base.rb +69 -2
- data/lib/wavify/dsp/effects/effect_chain.rb +94 -0
- data/lib/wavify/dsp/effects/envelope_controlled_effect.rb +93 -0
- data/lib/wavify/dsp/effects/eq.rb +102 -0
- data/lib/wavify/dsp/effects/expander.rb +90 -0
- data/lib/wavify/dsp/effects/flanger.rb +112 -0
- data/lib/wavify/dsp/effects/limiter.rb +259 -0
- data/lib/wavify/dsp/effects/mastering_chain.rb +31 -0
- data/lib/wavify/dsp/effects/noise_gate.rb +72 -0
- data/lib/wavify/dsp/effects/phaser.rb +112 -0
- data/lib/wavify/dsp/effects/podcast_chain.rb +32 -0
- data/lib/wavify/dsp/effects/reverb.rb +100 -14
- data/lib/wavify/dsp/effects/soft_limiter.rb +55 -0
- data/lib/wavify/dsp/effects/stereo_widener.rb +73 -0
- data/lib/wavify/dsp/effects/tremolo.rb +66 -0
- data/lib/wavify/dsp/effects/vibrato.rb +102 -0
- data/lib/wavify/dsp/effects.rb +106 -0
- data/lib/wavify/dsp/envelope.rb +90 -19
- data/lib/wavify/dsp/filter.rb +149 -8
- data/lib/wavify/dsp/headroom.rb +57 -0
- data/lib/wavify/dsp/lfo.rb +65 -0
- data/lib/wavify/dsp/loudness_meter.rb +281 -0
- data/lib/wavify/dsp/oscillator.rb +261 -22
- data/lib/wavify/dsp/processor.rb +90 -0
- data/lib/wavify/errors.rb +2 -2
- data/lib/wavify/sequencer/engine.rb +405 -89
- data/lib/wavify/sequencer/note_sequence.rb +45 -7
- data/lib/wavify/sequencer/pattern.rb +121 -12
- data/lib/wavify/sequencer/track.rb +149 -10
- data/lib/wavify/version.rb +1 -1
- data/lib/wavify.rb +18 -0
- data/sig/audio.rbs +86 -0
- data/sig/codecs.rbs +77 -0
- data/sig/core.rbs +108 -0
- data/sig/dsl.rbs +32 -0
- data/sig/dsp.rbs +87 -0
- data/sig/effects.rbs +129 -0
- data/sig/public_api.yml +111 -0
- data/sig/sequencer.rbs +126 -0
- data/sig/stream.rbs +30 -0
- data/sig/wavify.rbs +24 -0
- metadata +44 -58
- data/.serena/.gitignore +0 -1
- data/.serena/memories/project_overview.md +0 -5
- data/.serena/memories/style_and_completion.md +0 -5
- data/.serena/memories/suggested_commands.md +0 -11
- data/.serena/project.yml +0 -126
- data/.simplecov +0 -18
- data/.yardopts +0 -4
- data/Rakefile +0 -190
- data/benchmarks/README.md +0 -46
- data/benchmarks/benchmark_helper.rb +0 -112
- data/benchmarks/dsp_effects_benchmark.rb +0 -46
- data/benchmarks/flac_benchmark.rb +0 -74
- data/benchmarks/streaming_memory_benchmark.rb +0 -94
- data/benchmarks/wav_io_benchmark.rb +0 -110
- data/examples/audio_processing.rb +0 -73
- data/examples/cinematic_transition.rb +0 -118
- data/examples/drum_machine.rb +0 -74
- data/examples/format_convert.rb +0 -81
- data/examples/hybrid_arrangement.rb +0 -165
- data/examples/streaming_master_chain.rb +0 -129
- data/examples/synth_pad.rb +0 -42
- 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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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] =
|
|
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
|