wavify 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/README.md +204 -22
  4. data/exe/wavify +6 -0
  5. data/lib/wavify/adapters.rb +81 -0
  6. data/lib/wavify/audio.rb +1066 -120
  7. data/lib/wavify/cli.rb +237 -0
  8. data/lib/wavify/codecs/aiff.rb +388 -87
  9. data/lib/wavify/codecs/base.rb +98 -5
  10. data/lib/wavify/codecs/flac.rb +660 -170
  11. data/lib/wavify/codecs/ogg_vorbis.rb +515 -155
  12. data/lib/wavify/codecs/raw.rb +235 -45
  13. data/lib/wavify/codecs/registry.rb +272 -10
  14. data/lib/wavify/codecs/wav.rb +453 -74
  15. data/lib/wavify/core/duration.rb +54 -2
  16. data/lib/wavify/core/format.rb +96 -11
  17. data/lib/wavify/core/sample_buffer.rb +648 -60
  18. data/lib/wavify/core/stream.rb +590 -34
  19. data/lib/wavify/dsl.rb +712 -100
  20. data/lib/wavify/dsp/automation.rb +109 -0
  21. data/lib/wavify/dsp/effects/auto_pan.rb +71 -0
  22. data/lib/wavify/dsp/effects/bitcrusher.rb +74 -0
  23. data/lib/wavify/dsp/effects/chorus.rb +15 -4
  24. data/lib/wavify/dsp/effects/compressor.rb +133 -23
  25. data/lib/wavify/dsp/effects/delay.rb +46 -1
  26. data/lib/wavify/dsp/effects/distortion.rb +3 -2
  27. data/lib/wavify/dsp/effects/effect_base.rb +69 -2
  28. data/lib/wavify/dsp/effects/effect_chain.rb +94 -0
  29. data/lib/wavify/dsp/effects/envelope_controlled_effect.rb +93 -0
  30. data/lib/wavify/dsp/effects/eq.rb +102 -0
  31. data/lib/wavify/dsp/effects/expander.rb +90 -0
  32. data/lib/wavify/dsp/effects/flanger.rb +112 -0
  33. data/lib/wavify/dsp/effects/limiter.rb +259 -0
  34. data/lib/wavify/dsp/effects/mastering_chain.rb +31 -0
  35. data/lib/wavify/dsp/effects/noise_gate.rb +72 -0
  36. data/lib/wavify/dsp/effects/phaser.rb +112 -0
  37. data/lib/wavify/dsp/effects/podcast_chain.rb +32 -0
  38. data/lib/wavify/dsp/effects/reverb.rb +100 -14
  39. data/lib/wavify/dsp/effects/soft_limiter.rb +55 -0
  40. data/lib/wavify/dsp/effects/stereo_widener.rb +73 -0
  41. data/lib/wavify/dsp/effects/tremolo.rb +66 -0
  42. data/lib/wavify/dsp/effects/vibrato.rb +102 -0
  43. data/lib/wavify/dsp/effects.rb +106 -0
  44. data/lib/wavify/dsp/envelope.rb +90 -19
  45. data/lib/wavify/dsp/filter.rb +149 -8
  46. data/lib/wavify/dsp/headroom.rb +57 -0
  47. data/lib/wavify/dsp/lfo.rb +65 -0
  48. data/lib/wavify/dsp/loudness_meter.rb +281 -0
  49. data/lib/wavify/dsp/oscillator.rb +261 -22
  50. data/lib/wavify/dsp/processor.rb +90 -0
  51. data/lib/wavify/errors.rb +2 -2
  52. data/lib/wavify/sequencer/engine.rb +405 -89
  53. data/lib/wavify/sequencer/note_sequence.rb +45 -7
  54. data/lib/wavify/sequencer/pattern.rb +121 -12
  55. data/lib/wavify/sequencer/track.rb +149 -10
  56. data/lib/wavify/version.rb +1 -1
  57. data/lib/wavify.rb +18 -0
  58. data/sig/audio.rbs +86 -0
  59. data/sig/codecs.rbs +77 -0
  60. data/sig/core.rbs +108 -0
  61. data/sig/dsl.rbs +32 -0
  62. data/sig/dsp.rbs +87 -0
  63. data/sig/effects.rbs +129 -0
  64. data/sig/public_api.yml +111 -0
  65. data/sig/sequencer.rbs +126 -0
  66. data/sig/stream.rbs +30 -0
  67. data/sig/wavify.rbs +24 -0
  68. metadata +44 -58
  69. data/.serena/.gitignore +0 -1
  70. data/.serena/memories/project_overview.md +0 -5
  71. data/.serena/memories/style_and_completion.md +0 -5
  72. data/.serena/memories/suggested_commands.md +0 -11
  73. data/.serena/project.yml +0 -126
  74. data/.simplecov +0 -18
  75. data/.yardopts +0 -4
  76. data/Rakefile +0 -190
  77. data/benchmarks/README.md +0 -46
  78. data/benchmarks/benchmark_helper.rb +0 -112
  79. data/benchmarks/dsp_effects_benchmark.rb +0 -46
  80. data/benchmarks/flac_benchmark.rb +0 -74
  81. data/benchmarks/streaming_memory_benchmark.rb +0 -94
  82. data/benchmarks/wav_io_benchmark.rb +0 -110
  83. data/examples/audio_processing.rb +0 -73
  84. data/examples/cinematic_transition.rb +0 -118
  85. data/examples/drum_machine.rb +0 -74
  86. data/examples/format_convert.rb +0 -81
  87. data/examples/hybrid_arrangement.rb +0 -165
  88. data/examples/streaming_master_chain.rb +0 -129
  89. data/examples/synth_pad.rb +0 -42
  90. data/tools/fixture_writer.rb +0 -85
@@ -5,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:, bandwidth:)
25
- raise InvalidParameterError, "bandwidth must be positive" unless bandwidth.is_a?(Numeric) && bandwidth.positive?
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
- raise InvalidParameterError, "sample must be Numeric" unless sample.is_a?(Numeric)
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
- ensure_channel_states!(channel + 1)
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
- raise InvalidParameterError, "cutoff must be a positive Numeric" unless cutoff.is_a?(Numeric) && cutoff.positive?
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
- raise InvalidParameterError, "q must be a positive Numeric" unless q.is_a?(Numeric) && q.positive?
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
- raise InvalidParameterError, "gain_db must be Numeric" unless gain_db.is_a?(Numeric)
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