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
data/lib/wavify/dsp/filter.rb
CHANGED
|
@@ -5,6 +5,13 @@ module Wavify
|
|
|
5
5
|
module DSP
|
|
6
6
|
# Stateful biquad filter with common factory constructors.
|
|
7
7
|
class Filter
|
|
8
|
+
# Minimum tail window used to drain an IIR filter.
|
|
9
|
+
DEFAULT_TAIL_SECONDS = 0.05
|
|
10
|
+
# Hard limit on automatic filter tail rendering.
|
|
11
|
+
MAX_TAIL_SECONDS = 10.0
|
|
12
|
+
# Amplitude below which an IIR tail is treated as silent.
|
|
13
|
+
TAIL_AMPLITUDE = 1.0e-6
|
|
14
|
+
|
|
8
15
|
attr_reader :type, :cutoff, :q, :gain_db
|
|
9
16
|
|
|
10
17
|
# Builds a low-pass filter.
|
|
@@ -21,8 +28,15 @@ module Wavify
|
|
|
21
28
|
new(:highpass, cutoff: cutoff, q: q)
|
|
22
29
|
end
|
|
23
30
|
|
|
24
|
-
def self.bandpass(center
|
|
25
|
-
|
|
31
|
+
def self.bandpass(center: nil, bandwidth: nil, cutoff: nil, q: nil)
|
|
32
|
+
return new(:bandpass, cutoff: cutoff, q: q || 0.707) if cutoff
|
|
33
|
+
|
|
34
|
+
unless center.is_a?(Numeric) && center.respond_to?(:finite?) && center.finite? && center.positive?
|
|
35
|
+
raise InvalidParameterError, "center must be a positive finite Numeric"
|
|
36
|
+
end
|
|
37
|
+
unless bandwidth.is_a?(Numeric) && bandwidth.respond_to?(:finite?) && bandwidth.finite? && bandwidth.positive?
|
|
38
|
+
raise InvalidParameterError, "bandwidth must be a positive finite Numeric"
|
|
39
|
+
end
|
|
26
40
|
|
|
27
41
|
new(:bandpass, cutoff: center, q: center.to_f / bandwidth)
|
|
28
42
|
end
|
|
@@ -64,13 +78,20 @@ module Wavify
|
|
|
64
78
|
@coefficients = nil
|
|
65
79
|
@coeff_sample_rate = nil
|
|
66
80
|
@channel_states = []
|
|
81
|
+
@runtime_channels = nil
|
|
67
82
|
end
|
|
68
83
|
|
|
69
84
|
def apply(buffer)
|
|
85
|
+
DSP::Processor.render(self, buffer)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Processes a streaming chunk while preserving filter state.
|
|
89
|
+
def process(buffer)
|
|
70
90
|
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
71
91
|
|
|
72
92
|
float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
|
|
73
93
|
float_buffer = buffer.convert(float_format)
|
|
94
|
+
prepare_runtime_format!(sample_rate: float_format.sample_rate, channels: float_format.channels)
|
|
74
95
|
processed = process_interleaved(
|
|
75
96
|
float_buffer.samples,
|
|
76
97
|
sample_rate: float_format.sample_rate,
|
|
@@ -81,12 +102,14 @@ module Wavify
|
|
|
81
102
|
end
|
|
82
103
|
|
|
83
104
|
def process_sample(sample, sample_rate:, channel: 0)
|
|
84
|
-
|
|
105
|
+
unless sample.is_a?(Numeric) && sample.respond_to?(:finite?) && sample.finite?
|
|
106
|
+
raise InvalidParameterError, "sample must be a finite Numeric"
|
|
107
|
+
end
|
|
85
108
|
raise InvalidParameterError, "channel must be a non-negative Integer" unless channel.is_a?(Integer) && channel >= 0
|
|
86
109
|
raise InvalidParameterError, "sample_rate must be a positive Integer" unless sample_rate.is_a?(Integer) && sample_rate.positive?
|
|
87
110
|
|
|
88
111
|
update_coefficients!(sample_rate)
|
|
89
|
-
|
|
112
|
+
ensure_sample_channel_state!(channel)
|
|
90
113
|
state = @channel_states[channel]
|
|
91
114
|
x = sample.to_f
|
|
92
115
|
y = compute_biquad(state, x)
|
|
@@ -94,15 +117,95 @@ module Wavify
|
|
|
94
117
|
y
|
|
95
118
|
end
|
|
96
119
|
|
|
120
|
+
# Emits the decaying IIR state after the input ends.
|
|
121
|
+
#
|
|
122
|
+
# @param format [Core::Format, nil] optional target format
|
|
123
|
+
# @return [Enumerator<Core::SampleBuffer>, nil]
|
|
124
|
+
def flush(format: nil)
|
|
125
|
+
return nil unless @coeff_sample_rate && filter_state_active?
|
|
126
|
+
if format && !format.is_a?(Core::Format)
|
|
127
|
+
raise InvalidParameterError, "format must be Core::Format"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
channels = @channel_states.length
|
|
131
|
+
runtime_format = Core::Format.new(
|
|
132
|
+
channels: channels,
|
|
133
|
+
sample_rate: @coeff_sample_rate,
|
|
134
|
+
bit_depth: 32,
|
|
135
|
+
sample_format: :float
|
|
136
|
+
)
|
|
137
|
+
remaining = tail_frame_count(@coeff_sample_rate)
|
|
138
|
+
target_format = format || runtime_format
|
|
139
|
+
Enumerator.new do |yielder|
|
|
140
|
+
while remaining.positive?
|
|
141
|
+
frames = [remaining, DSP::Processor::TAIL_CHUNK_FRAMES].min
|
|
142
|
+
silence = Array.new(frames * channels, 0.0)
|
|
143
|
+
processed = process_interleaved(silence, sample_rate: @coeff_sample_rate, channels: channels)
|
|
144
|
+
yielder << Core::SampleBuffer.new(processed, runtime_format).convert(target_format)
|
|
145
|
+
remaining -= frames
|
|
146
|
+
end
|
|
147
|
+
ensure
|
|
148
|
+
reset
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Estimated time for the biquad poles to decay below -120 dB.
|
|
153
|
+
def tail_duration
|
|
154
|
+
return DEFAULT_TAIL_SECONDS unless @coeff_sample_rate && @coefficients
|
|
155
|
+
|
|
156
|
+
tail_frame_count(@coeff_sample_rate).to_f / @coeff_sample_rate
|
|
157
|
+
end
|
|
158
|
+
|
|
97
159
|
# Clears internal filter state for all channels.
|
|
98
160
|
#
|
|
99
161
|
# @return [void]
|
|
100
162
|
def reset
|
|
163
|
+
@coefficients = nil
|
|
164
|
+
@coeff_sample_rate = nil
|
|
101
165
|
@channel_states = []
|
|
166
|
+
@runtime_channels = nil
|
|
167
|
+
self
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def build_runtime
|
|
171
|
+
dup.reset
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def latency
|
|
175
|
+
0.0
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def lookahead
|
|
179
|
+
0.0
|
|
102
180
|
end
|
|
103
181
|
|
|
104
182
|
private
|
|
105
183
|
|
|
184
|
+
def filter_state_active?
|
|
185
|
+
@channel_states.any? { |state| state.values.any? { |value| !value.zero? } }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def tail_frame_count(sample_rate)
|
|
189
|
+
radius = maximum_pole_radius
|
|
190
|
+
frames = if radius <= 0.0
|
|
191
|
+
2
|
|
192
|
+
elsif radius >= 1.0
|
|
193
|
+
(MAX_TAIL_SECONDS * sample_rate).ceil
|
|
194
|
+
else
|
|
195
|
+
(Math.log(TAIL_AMPLITUDE) / Math.log(radius)).ceil
|
|
196
|
+
end
|
|
197
|
+
frames.clamp(2, (MAX_TAIL_SECONDS * sample_rate).ceil)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def maximum_pole_radius
|
|
201
|
+
_b0, _b1, _b2, a1, a2 = @coefficients
|
|
202
|
+
discriminant = (a1 * a1) - (4.0 * a2)
|
|
203
|
+
return Math.sqrt(a2.abs) if discriminant.negative?
|
|
204
|
+
|
|
205
|
+
root = Math.sqrt(discriminant)
|
|
206
|
+
[(-a1 + root) / 2.0, (-a1 - root) / 2.0].map(&:abs).max
|
|
207
|
+
end
|
|
208
|
+
|
|
106
209
|
def process_interleaved(samples, sample_rate:, channels:)
|
|
107
210
|
update_coefficients!(sample_rate)
|
|
108
211
|
ensure_channel_states!(channels)
|
|
@@ -122,17 +225,41 @@ module Wavify
|
|
|
122
225
|
def update_coefficients!(sample_rate)
|
|
123
226
|
return if @coeff_sample_rate == sample_rate && @coefficients
|
|
124
227
|
|
|
228
|
+
if @coeff_sample_rate && @coeff_sample_rate != sample_rate
|
|
229
|
+
raise InvalidParameterError, "sample rate changed; call reset before processing a new format"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
validate_cutoff_below_nyquist!(sample_rate)
|
|
125
233
|
@coefficients = coefficients_for(sample_rate)
|
|
126
234
|
@coeff_sample_rate = sample_rate
|
|
127
|
-
reset
|
|
128
235
|
end
|
|
129
236
|
|
|
130
237
|
def ensure_channel_states!(channels)
|
|
131
238
|
return if @channel_states.length == channels
|
|
132
239
|
|
|
240
|
+
unless @channel_states.empty?
|
|
241
|
+
raise InvalidParameterError, "channel count changed; call reset before processing a new format"
|
|
242
|
+
end
|
|
243
|
+
|
|
133
244
|
@channel_states = Array.new(channels) { { x1: 0.0, x2: 0.0, y1: 0.0, y2: 0.0 } }
|
|
134
245
|
end
|
|
135
246
|
|
|
247
|
+
def ensure_sample_channel_state!(channel)
|
|
248
|
+
until @channel_states.length > channel
|
|
249
|
+
@channel_states << { x1: 0.0, x2: 0.0, y1: 0.0, y2: 0.0 }
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def prepare_runtime_format!(sample_rate:, channels:)
|
|
254
|
+
if @runtime_channels && @runtime_channels != channels
|
|
255
|
+
raise InvalidParameterError, "channel count changed; call reset before processing a new format"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
update_coefficients!(sample_rate)
|
|
259
|
+
ensure_channel_states!(channels)
|
|
260
|
+
@runtime_channels = channels
|
|
261
|
+
end
|
|
262
|
+
|
|
136
263
|
def compute_biquad(state, x)
|
|
137
264
|
b0, b1, b2, a1, a2 = @coefficients
|
|
138
265
|
(b0 * x) + (b1 * state[:x1]) + (b2 * state[:x2]) - (a1 * state[:y1]) - (a2 * state[:y2])
|
|
@@ -250,22 +377,36 @@ module Wavify
|
|
|
250
377
|
end
|
|
251
378
|
|
|
252
379
|
def validate_cutoff!(cutoff)
|
|
253
|
-
|
|
380
|
+
unless cutoff.is_a?(Numeric) && cutoff.respond_to?(:finite?) && cutoff.finite? && cutoff.positive?
|
|
381
|
+
raise InvalidParameterError, "cutoff must be a positive finite Numeric"
|
|
382
|
+
end
|
|
254
383
|
|
|
255
384
|
cutoff.to_f
|
|
256
385
|
end
|
|
257
386
|
|
|
258
387
|
def validate_q!(q)
|
|
259
|
-
|
|
388
|
+
unless q.is_a?(Numeric) && q.respond_to?(:finite?) && q.finite? && q.positive?
|
|
389
|
+
raise InvalidParameterError, "q must be a positive finite Numeric"
|
|
390
|
+
end
|
|
260
391
|
|
|
261
392
|
q.to_f
|
|
262
393
|
end
|
|
263
394
|
|
|
264
395
|
def validate_gain!(gain_db)
|
|
265
|
-
|
|
396
|
+
unless gain_db.is_a?(Numeric) && gain_db.respond_to?(:finite?) && gain_db.finite?
|
|
397
|
+
raise InvalidParameterError, "gain_db must be a finite Numeric"
|
|
398
|
+
end
|
|
266
399
|
|
|
267
400
|
gain_db.to_f
|
|
268
401
|
end
|
|
402
|
+
|
|
403
|
+
def validate_cutoff_below_nyquist!(sample_rate)
|
|
404
|
+
nyquist = sample_rate / 2.0
|
|
405
|
+
return if @cutoff < nyquist
|
|
406
|
+
|
|
407
|
+
raise InvalidParameterError,
|
|
408
|
+
"cutoff must be below Nyquist frequency (#{nyquist} Hz) for sample_rate #{sample_rate}"
|
|
409
|
+
end
|
|
269
410
|
end
|
|
270
411
|
end
|
|
271
412
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
# Applies only the gain needed to keep each mixed frame within full scale.
|
|
6
|
+
module Headroom
|
|
7
|
+
# Default look-behind/look-ahead smoothing window for gain transitions.
|
|
8
|
+
DEFAULT_SMOOTHING_SECONDS = 0.005
|
|
9
|
+
|
|
10
|
+
def self.apply!(samples, channels:, sample_rate:, smoothing_seconds: DEFAULT_SMOOTHING_SECONDS)
|
|
11
|
+
smoothing = validate_smoothing!(smoothing_seconds)
|
|
12
|
+
gains = frame_gains(samples, channels: channels, sample_rate: sample_rate, smoothing_seconds: smoothing)
|
|
13
|
+
samples.each_index do |index|
|
|
14
|
+
samples[index] = (samples.fetch(index) * gains.fetch(index / channels)).clamp(-1.0, 1.0)
|
|
15
|
+
end
|
|
16
|
+
samples
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.frame_gains(samples, channels:, sample_rate:, smoothing_seconds:)
|
|
20
|
+
gains = samples.each_slice(channels).map do |frame|
|
|
21
|
+
peak = frame.reduce(0.0) { |maximum, sample| [maximum, sample.abs].max }
|
|
22
|
+
peak > 1.0 ? 1.0 / peak : 1.0
|
|
23
|
+
end
|
|
24
|
+
return gains if gains.length < 2
|
|
25
|
+
|
|
26
|
+
smoothing_frames = (sample_rate * smoothing_seconds).round
|
|
27
|
+
return gains if smoothing_frames < 2
|
|
28
|
+
|
|
29
|
+
smooth_gain_transitions(gains, smoothing_frames)
|
|
30
|
+
end
|
|
31
|
+
private_class_method :frame_gains
|
|
32
|
+
|
|
33
|
+
def self.smooth_gain_transitions(targets, smoothing_frames)
|
|
34
|
+
gains = targets.dup
|
|
35
|
+
step = 1.0 / smoothing_frames
|
|
36
|
+
|
|
37
|
+
1.upto(gains.length - 1) do |index|
|
|
38
|
+
gains[index] = [gains.fetch(index), gains.fetch(index - 1) + step].min
|
|
39
|
+
end
|
|
40
|
+
(gains.length - 2).downto(0) do |index|
|
|
41
|
+
gains[index] = [gains.fetch(index), gains.fetch(index + 1) + step].min
|
|
42
|
+
end
|
|
43
|
+
gains
|
|
44
|
+
end
|
|
45
|
+
private_class_method :smooth_gain_transitions
|
|
46
|
+
|
|
47
|
+
def self.validate_smoothing!(value)
|
|
48
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
|
|
49
|
+
raise InvalidParameterError, "headroom smoothing must be a non-negative finite Numeric"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
value.to_f
|
|
53
|
+
end
|
|
54
|
+
private_class_method :validate_smoothing!
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
# Low frequency oscillator for modulation sources.
|
|
6
|
+
class LFO
|
|
7
|
+
WAVEFORMS = %i[sine triangle square sawtooth].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(rate:, sample_rate:, waveform: :sine, phase: 0.0)
|
|
10
|
+
@rate = validate_positive!(rate, :rate)
|
|
11
|
+
@sample_rate = validate_positive!(sample_rate, :sample_rate)
|
|
12
|
+
@waveform = validate_waveform!(waveform)
|
|
13
|
+
@initial_phase = phase.to_f % 1.0
|
|
14
|
+
reset
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def next_value
|
|
18
|
+
value = value_at_phase(@phase)
|
|
19
|
+
@phase = (@phase + (@rate / @sample_rate)) % 1.0
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def value_at(offset = 0.0)
|
|
24
|
+
value_at_phase((@phase + offset.to_f) % 1.0)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reset
|
|
28
|
+
@phase = @initial_phase
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def value_at_phase(phase)
|
|
35
|
+
case @waveform
|
|
36
|
+
when :sine
|
|
37
|
+
Math.sin(2.0 * Math::PI * phase)
|
|
38
|
+
when :triangle
|
|
39
|
+
1.0 - (4.0 * (phase - 0.5).abs)
|
|
40
|
+
when :square
|
|
41
|
+
phase < 0.5 ? 1.0 : -1.0
|
|
42
|
+
when :sawtooth
|
|
43
|
+
(2.0 * phase) - 1.0
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_positive!(value, name)
|
|
48
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
|
|
49
|
+
raise InvalidParameterError, "#{name} must be a positive finite Numeric"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
value.to_f
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_waveform!(waveform)
|
|
56
|
+
value = waveform.to_sym
|
|
57
|
+
return value if WAVEFORMS.include?(value)
|
|
58
|
+
|
|
59
|
+
raise InvalidParameterError, "unsupported LFO waveform: #{waveform.inspect}"
|
|
60
|
+
rescue NoMethodError
|
|
61
|
+
raise InvalidParameterError, "waveform must be Symbol/String: #{waveform.inspect}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
# ITU-R BS.1770 integrated loudness meter with EBU-style gating.
|
|
6
|
+
class LoudnessMeter
|
|
7
|
+
LOUDNESS_OFFSET = -0.691
|
|
8
|
+
ABSOLUTE_GATE_LUFS = -70.0
|
|
9
|
+
RELATIVE_GATE_LU = -10.0
|
|
10
|
+
BLOCK_SECONDS = 0.4
|
|
11
|
+
STEP_SECONDS = 0.1
|
|
12
|
+
SHORT_TERM_SECONDS = 3.0
|
|
13
|
+
TRUE_PEAK_OVERSAMPLING = 4
|
|
14
|
+
TRUE_PEAK_RADIUS = 8
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Measures integrated loudness. Inputs shorter than the BS.1770 400 ms
|
|
18
|
+
# block are treated as a single partial block.
|
|
19
|
+
def integrated(samples, sample_rate: nil, channels: nil, format: nil, channel_layout: nil)
|
|
20
|
+
samples, sample_rate, channels, channel_layout = measurement_input(
|
|
21
|
+
samples,
|
|
22
|
+
sample_rate: sample_rate,
|
|
23
|
+
channels: channels,
|
|
24
|
+
format: format,
|
|
25
|
+
channel_layout: channel_layout
|
|
26
|
+
)
|
|
27
|
+
return -Float::INFINITY if samples.empty?
|
|
28
|
+
|
|
29
|
+
weighted = k_weight(samples, sample_rate: sample_rate, channels: channels)
|
|
30
|
+
energies = block_energies(
|
|
31
|
+
weighted,
|
|
32
|
+
sample_rate: sample_rate,
|
|
33
|
+
channels: channels,
|
|
34
|
+
channel_layout: channel_layout
|
|
35
|
+
)
|
|
36
|
+
absolute_gated = energies.select { |energy| loudness_for_energy(energy) >= ABSOLUTE_GATE_LUFS }
|
|
37
|
+
return -Float::INFINITY if absolute_gated.empty?
|
|
38
|
+
|
|
39
|
+
relative_gate = loudness_for_energy(mean(absolute_gated)) + RELATIVE_GATE_LU
|
|
40
|
+
relative_gated = absolute_gated.select { |energy| loudness_for_energy(energy) >= relative_gate }
|
|
41
|
+
loudness_for_energy(mean(relative_gated))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Measures the most recent 400 ms loudness window without gating.
|
|
45
|
+
def momentary(samples, sample_rate: nil, channels: nil, format: nil, channel_layout: nil)
|
|
46
|
+
window_loudness(
|
|
47
|
+
samples,
|
|
48
|
+
seconds: BLOCK_SECONDS,
|
|
49
|
+
sample_rate: sample_rate,
|
|
50
|
+
channels: channels,
|
|
51
|
+
format: format,
|
|
52
|
+
channel_layout: channel_layout
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Measures the most recent 3 second loudness window without gating.
|
|
57
|
+
def short_term(samples, sample_rate: nil, channels: nil, format: nil, channel_layout: nil)
|
|
58
|
+
window_loudness(
|
|
59
|
+
samples,
|
|
60
|
+
seconds: SHORT_TERM_SECONDS,
|
|
61
|
+
sample_rate: sample_rate,
|
|
62
|
+
channels: channels,
|
|
63
|
+
format: format,
|
|
64
|
+
channel_layout: channel_layout
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Estimates inter-sample true peak with windowed-sinc oversampling.
|
|
69
|
+
def true_peak(samples, sample_rate: nil, channels: nil, format: nil, oversampling: TRUE_PEAK_OVERSAMPLING)
|
|
70
|
+
samples, _sample_rate, channels, = measurement_input(
|
|
71
|
+
samples,
|
|
72
|
+
sample_rate: sample_rate,
|
|
73
|
+
channels: channels,
|
|
74
|
+
format: format,
|
|
75
|
+
channel_layout: nil
|
|
76
|
+
)
|
|
77
|
+
unless [2, 4, 8].include?(oversampling)
|
|
78
|
+
raise InvalidParameterError, "oversampling must be 2, 4, or 8"
|
|
79
|
+
end
|
|
80
|
+
return 0.0 if samples.empty?
|
|
81
|
+
|
|
82
|
+
channel_samples = Array.new(channels) { [] }
|
|
83
|
+
samples.each_with_index { |sample, index| channel_samples.fetch(index % channels) << sample.to_f }
|
|
84
|
+
channel_samples.map { |values| oversampled_peak(values, oversampling) }.max || 0.0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def measurement_input(samples, sample_rate:, channels:, format:, channel_layout:)
|
|
90
|
+
if samples.is_a?(Core::SampleBuffer)
|
|
91
|
+
format ||= samples.format
|
|
92
|
+
float_format = samples.format.with(sample_format: :float, bit_depth: 32)
|
|
93
|
+
samples = (samples.format == float_format ? samples : samples.convert(float_format)).samples
|
|
94
|
+
end
|
|
95
|
+
if format
|
|
96
|
+
raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
|
|
97
|
+
|
|
98
|
+
sample_rate ||= format.sample_rate
|
|
99
|
+
channels ||= format.channels
|
|
100
|
+
channel_layout ||= format.channel_layout
|
|
101
|
+
end
|
|
102
|
+
unless samples.is_a?(Array) && samples.all? { |sample| sample.is_a?(Numeric) && sample.respond_to?(:finite?) && sample.finite? }
|
|
103
|
+
raise InvalidParameterError, "samples must be an Array of finite Numeric values"
|
|
104
|
+
end
|
|
105
|
+
unless sample_rate.is_a?(Integer) && sample_rate.positive?
|
|
106
|
+
raise InvalidParameterError, "sample_rate must be a positive Integer"
|
|
107
|
+
end
|
|
108
|
+
unless channels.is_a?(Integer) && channels.positive? && (samples.length % channels).zero?
|
|
109
|
+
raise InvalidParameterError, "channels must be a positive Integer that divides the sample count"
|
|
110
|
+
end
|
|
111
|
+
if channel_layout && (!channel_layout.is_a?(Array) || channel_layout.length != channels)
|
|
112
|
+
raise InvalidParameterError, "channel_layout must contain one position per channel"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
[samples, sample_rate, channels, channel_layout]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def window_loudness(samples, seconds:, sample_rate:, channels:, format:, channel_layout:)
|
|
119
|
+
samples, sample_rate, channels, channel_layout = measurement_input(
|
|
120
|
+
samples,
|
|
121
|
+
sample_rate: sample_rate,
|
|
122
|
+
channels: channels,
|
|
123
|
+
format: format,
|
|
124
|
+
channel_layout: channel_layout
|
|
125
|
+
)
|
|
126
|
+
return -Float::INFINITY if samples.empty?
|
|
127
|
+
|
|
128
|
+
frames = [samples.length / channels, (seconds * sample_rate).round].min
|
|
129
|
+
window = samples.last(frames * channels)
|
|
130
|
+
weighted = k_weight(window, sample_rate: sample_rate, channels: channels)
|
|
131
|
+
energy = block_energy(weighted, channels: channels, channel_layout: channel_layout)
|
|
132
|
+
loudness_for_energy(energy)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def k_weight(samples, sample_rate:, channels:)
|
|
136
|
+
shelf = high_shelf_coefficients(sample_rate)
|
|
137
|
+
highpass = highpass_coefficients(sample_rate)
|
|
138
|
+
apply_biquad(apply_biquad(samples, shelf, channels), highpass, channels)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def high_shelf_coefficients(sample_rate)
|
|
142
|
+
frequency = 1_681.974_450_955_533
|
|
143
|
+
gain_db = 3.999_843_853_973_347
|
|
144
|
+
q = 0.707_175_236_955_419_6
|
|
145
|
+
k = Math.tan(Math::PI * frequency / sample_rate)
|
|
146
|
+
high_gain = 10.0**(gain_db / 20.0)
|
|
147
|
+
band_gain = high_gain**0.499_666_774_154_541_6
|
|
148
|
+
a0 = 1.0 + (k / q) + (k * k)
|
|
149
|
+
|
|
150
|
+
[
|
|
151
|
+
(high_gain + (band_gain * k / q) + (k * k)) / a0,
|
|
152
|
+
2.0 * ((k * k) - high_gain) / a0,
|
|
153
|
+
(high_gain - (band_gain * k / q) + (k * k)) / a0,
|
|
154
|
+
2.0 * ((k * k) - 1.0) / a0,
|
|
155
|
+
(1.0 - (k / q) + (k * k)) / a0
|
|
156
|
+
]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def highpass_coefficients(sample_rate)
|
|
160
|
+
frequency = 38.135_470_876_024_44
|
|
161
|
+
q = 0.500_327_037_323_877_3
|
|
162
|
+
k = Math.tan(Math::PI * frequency / sample_rate)
|
|
163
|
+
a0 = 1.0 + (k / q) + (k * k)
|
|
164
|
+
|
|
165
|
+
[
|
|
166
|
+
1.0,
|
|
167
|
+
-2.0,
|
|
168
|
+
1.0,
|
|
169
|
+
2.0 * ((k * k) - 1.0) / a0,
|
|
170
|
+
(1.0 - (k / q) + (k * k)) / a0
|
|
171
|
+
]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def apply_biquad(samples, coefficients, channels)
|
|
175
|
+
b0, b1, b2, a1, a2 = coefficients
|
|
176
|
+
x1 = Array.new(channels, 0.0)
|
|
177
|
+
x2 = Array.new(channels, 0.0)
|
|
178
|
+
y1 = Array.new(channels, 0.0)
|
|
179
|
+
y2 = Array.new(channels, 0.0)
|
|
180
|
+
output = Array.new(samples.length)
|
|
181
|
+
samples.each_with_index do |sample, index|
|
|
182
|
+
channel = index % channels
|
|
183
|
+
value = (b0 * sample) + (b1 * x1.fetch(channel)) + (b2 * x2.fetch(channel)) -
|
|
184
|
+
(a1 * y1.fetch(channel)) - (a2 * y2.fetch(channel))
|
|
185
|
+
x2[channel] = x1.fetch(channel)
|
|
186
|
+
x1[channel] = sample
|
|
187
|
+
y2[channel] = y1.fetch(channel)
|
|
188
|
+
y1[channel] = value
|
|
189
|
+
output[index] = value
|
|
190
|
+
end
|
|
191
|
+
output
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def block_energies(samples, sample_rate:, channels:, channel_layout:)
|
|
195
|
+
frame_count = samples.length / channels
|
|
196
|
+
block_frames = [(BLOCK_SECONDS * sample_rate).round, frame_count].min
|
|
197
|
+
step_frames = [(STEP_SECONDS * sample_rate).round, 1].max
|
|
198
|
+
starts = frame_count <= block_frames ? [0] : (0..(frame_count - block_frames)).step(step_frames)
|
|
199
|
+
gains = channel_gains(channels, channel_layout)
|
|
200
|
+
|
|
201
|
+
starts.map do |start_frame|
|
|
202
|
+
channel_squares = Array.new(channels, 0.0)
|
|
203
|
+
start_index = start_frame * channels
|
|
204
|
+
end_index = start_index + (block_frames * channels)
|
|
205
|
+
samples[start_index...end_index].each_with_index do |sample, index|
|
|
206
|
+
channel_squares[index % channels] += sample * sample
|
|
207
|
+
end
|
|
208
|
+
channel_squares.each_with_index.sum do |square_sum, channel|
|
|
209
|
+
gains.fetch(channel) * square_sum / block_frames
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def block_energy(samples, channels:, channel_layout:)
|
|
215
|
+
frames = samples.length / channels
|
|
216
|
+
return 0.0 if frames.zero?
|
|
217
|
+
|
|
218
|
+
squares = Array.new(channels, 0.0)
|
|
219
|
+
samples.each_with_index { |sample, index| squares[index % channels] += sample * sample }
|
|
220
|
+
gains = channel_gains(channels, channel_layout)
|
|
221
|
+
squares.each_with_index.sum { |sum, channel| gains.fetch(channel) * sum / frames }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def channel_gains(channels, channel_layout)
|
|
225
|
+
layout = channel_layout || Core::Format::DEFAULT_CHANNEL_LAYOUTS[channels]
|
|
226
|
+
return Array.new(channels, 1.0) unless layout
|
|
227
|
+
|
|
228
|
+
layout.map do |position|
|
|
229
|
+
case position.to_sym
|
|
230
|
+
when :low_frequency then 0.0
|
|
231
|
+
when :back_left, :back_right, :back_center, :side_left, :side_right then 1.41
|
|
232
|
+
else 1.0
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def oversampled_peak(samples, factor)
|
|
238
|
+
peak = samples.map(&:abs).max || 0.0
|
|
239
|
+
return peak if samples.length < 2
|
|
240
|
+
|
|
241
|
+
(0...(samples.length - 1)).each do |index|
|
|
242
|
+
1.upto(factor - 1) do |step|
|
|
243
|
+
time = index + (step.to_f / factor)
|
|
244
|
+
peak = [peak, sinc_interpolate(samples, time).abs].max
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
peak
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def sinc_interpolate(samples, time)
|
|
251
|
+
center = time.floor
|
|
252
|
+
start_index = [center - TRUE_PEAK_RADIUS + 1, 0].max
|
|
253
|
+
end_index = [center + TRUE_PEAK_RADIUS, samples.length - 1].min
|
|
254
|
+
weighted_sum = 0.0
|
|
255
|
+
weight_sum = 0.0
|
|
256
|
+
start_index.upto(end_index) do |index|
|
|
257
|
+
distance = time - index
|
|
258
|
+
next if distance.abs >= TRUE_PEAK_RADIUS
|
|
259
|
+
|
|
260
|
+
sinc = distance.zero? ? 1.0 : Math.sin(Math::PI * distance) / (Math::PI * distance)
|
|
261
|
+
window = 0.5 * (1.0 + Math.cos(Math::PI * distance / TRUE_PEAK_RADIUS))
|
|
262
|
+
weight = sinc * window
|
|
263
|
+
weighted_sum += samples.fetch(index) * weight
|
|
264
|
+
weight_sum += weight
|
|
265
|
+
end
|
|
266
|
+
weight_sum.zero? ? 0.0 : weighted_sum / weight_sum
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def loudness_for_energy(energy)
|
|
270
|
+
return -Float::INFINITY unless energy.positive?
|
|
271
|
+
|
|
272
|
+
LOUDNESS_OFFSET + (10.0 * Math.log10(energy))
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def mean(values)
|
|
276
|
+
values.sum / values.length.to_f
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|