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
data/lib/wavify/audio.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tempfile"
4
+
3
5
  module Wavify
4
6
  # High-level immutable audio object backed by a {Core::SampleBuffer}.
5
7
  #
@@ -8,46 +10,173 @@ module Wavify
8
10
  class Audio
9
11
  attr_reader :buffer
10
12
 
13
+ # Supported policies for summing multiple sources.
14
+ MIX_STRATEGIES = %i[none clip normalize headroom soft_limit].freeze
15
+ # Supported timeline alignment modes for sources of different lengths.
16
+ MIX_ALIGNMENTS = %i[start center end].freeze
17
+ # Supported normalization reference measurements.
18
+ NORMALIZE_MODES = %i[peak rms lufs].freeze
19
+ # Supported fade interpolation curves.
20
+ FADE_CURVES = %i[linear exp log].freeze
21
+ # Knee threshold used by the built-in soft-limit mix policy.
22
+ SOFT_LIMIT_THRESHOLD = 0.8
23
+ # Maximum number of frames allowed by eager repeat.
24
+ MAX_REPEAT_FRAMES = 50_000_000
25
+ # Maximum estimated allocation allowed by eager repeat.
26
+ MAX_REPEAT_BYTES = 512 * 1024 * 1024
27
+
11
28
  # Reads audio from a file path using codec auto-detection.
12
29
  #
13
- # @param path [String]
30
+ # @param path_or_io [String, IO]
14
31
  # @param format [Core::Format, nil] optional target format to convert into
15
32
  # @param codec_options [Hash] codec-specific options forwarded to `.read`
33
+ # @param strict [Boolean] verify extension and magic-byte codec agreement
34
+ # @param filename [String, nil] optional filename hint for IO inputs
16
35
  # @return [Audio]
17
- def self.read(path, format: nil, codec_options: nil)
18
- codec = Codecs::Registry.detect(path)
19
- options = codec_options || {}
20
- raise InvalidParameterError, "codec_options must be a Hash" unless options.is_a?(Hash)
36
+ def self.read(path_or_io, format: nil, codec_options: nil, strict: false, filename: nil)
37
+ codec = Codecs::Registry.detect_for_read(path_or_io, strict: strict, filename: filename)
38
+ options = normalize_codec_options!(codec_options)
39
+
40
+ new(codec.read(path_or_io, format: format, **options))
41
+ end
42
+
43
+ # Reads audio metadata without decoding the full payload when the codec supports it.
44
+ #
45
+ # @param path_or_io [String, IO]
46
+ # @param format [Core::Format, nil] required for raw PCM/float input
47
+ # @param codec_options [Hash] codec-specific options forwarded to `.metadata`
48
+ # @param strict [Boolean] verify extension and magic-byte codec agreement
49
+ # @param filename [String, nil] optional filename hint for IO inputs
50
+ # @return [Hash]
51
+ def self.metadata(path_or_io, format: nil, codec_options: nil, strict: false, filename: nil)
52
+ codec = Codecs::Registry.detect_for_read(path_or_io, strict: strict, filename: filename)
53
+ options = normalize_codec_options!(codec_options)
54
+ return codec.metadata(path_or_io, format: format, **options) if codec == Codecs::Raw
55
+
56
+ metadata = codec.metadata(path_or_io, **options)
57
+ return metadata unless format
58
+ raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
59
+
60
+ project_metadata(metadata, format)
61
+ end
62
+
63
+ singleton_class.alias_method :info, :metadata
64
+
65
+ def self.project_metadata(metadata, target_format)
66
+ source_format = metadata.fetch(:format)
67
+ projected_frames = project_frame_count(
68
+ metadata.fetch(:sample_frame_count),
69
+ source_format.sample_rate,
70
+ target_format.sample_rate
71
+ )
72
+ projected = metadata.merge(
73
+ format: target_format,
74
+ sample_frame_count: projected_frames,
75
+ duration: Core::Duration.from_samples(projected_frames, target_format.sample_rate)
76
+ )
77
+ project_sample_coordinates!(projected, source_format.sample_rate, target_format.sample_rate)
78
+ end
79
+ private_class_method :project_metadata
80
+
81
+ def self.project_sample_coordinates!(metadata, source_rate, target_rate)
82
+ projector = ->(frame) { project_frame_count(frame, source_rate, target_rate) }
83
+ metadata[:fact_sample_length] = projector.call(metadata[:fact_sample_length]) if metadata[:fact_sample_length]
84
+ metadata[:loops] = project_loops(metadata[:loops], projector) if metadata[:loops]
85
+ metadata[:cue_points] = project_cue_points(metadata[:cue_points], projector) if metadata[:cue_points]
86
+ metadata[:smpl] = project_smpl(metadata[:smpl], projector, target_rate) if metadata[:smpl]
87
+ metadata[:cue] = metadata[:cue].merge(points: metadata[:cue_points]) if metadata[:cue]
88
+ if metadata[:bext]
89
+ metadata[:bext] = metadata[:bext].merge(time_reference: projector.call(metadata[:bext].fetch(:time_reference)))
90
+ metadata[:broadcast_extension] = metadata[:bext]
91
+ end
92
+ metadata
93
+ end
94
+ private_class_method :project_sample_coordinates!
21
95
 
22
- new(codec.read(path, format: format, **options))
96
+ def self.project_frame_count(frame_count, source_rate, target_rate)
97
+ (Rational(frame_count) * Rational(target_rate, source_rate)).round
98
+ end
99
+ private_class_method :project_frame_count
100
+
101
+ def self.project_loops(loops, projector)
102
+ loops.map do |loop|
103
+ start_frame = projector.call(loop.fetch(:start_frame))
104
+ end_frame = projector.call(loop.fetch(:end_frame) + 1) - 1
105
+ end_frame = start_frame if end_frame < start_frame
106
+ loop.merge(start_frame: start_frame, end_frame: end_frame, length_frames: end_frame - start_frame + 1)
107
+ end
23
108
  end
109
+ private_class_method :project_loops
110
+
111
+ def self.project_cue_points(cue_points, projector)
112
+ cue_points.map do |point|
113
+ point.merge(
114
+ position: projector.call(point.fetch(:position)),
115
+ sample_offset: projector.call(point.fetch(:sample_offset))
116
+ )
117
+ end
118
+ end
119
+ private_class_method :project_cue_points
120
+
121
+ def self.project_smpl(smpl, projector, target_rate)
122
+ loops = smpl.fetch(:loops).map do |loop|
123
+ start_frame = projector.call(loop.fetch(:start_frame))
124
+ end_frame = projector.call(loop.fetch(:end_frame) + 1) - 1
125
+ loop.merge(
126
+ start_frame: start_frame,
127
+ end_frame: [end_frame, start_frame].max
128
+ )
129
+ end
130
+ smpl.merge(sample_period: (1_000_000_000.0 / target_rate).round, loops: loops)
131
+ end
132
+ private_class_method :project_smpl
24
133
 
25
- # Mixes multiple audio objects and clips summed samples into range.
134
+ # Mixes multiple audio objects using a selectable clipping policy.
26
135
  #
27
136
  # @param audios [Array<Audio>]
137
+ # @param strategy [Symbol] `:none`, `:clip`, `:normalize`, `:headroom`, or `:soft_limit`;
138
+ # `:none` preserves above-full-scale sums only when the output format is float
139
+ # @param gains [Array<Numeric>, nil] optional per-source gain in dB
140
+ # @param align [Symbol] `:start`, `:center`, or `:end`
141
+ # @param format [Core::Format, nil] output format, defaulting to the first source format
142
+ # @param work_format [Core::Format, nil] intermediate channel/sample-rate format; samples are processed as float
143
+ # @param headroom_smoothing [Numeric] seconds of gain smoothing around peaks
28
144
  # @return [Audio]
29
- def self.mix(*audios)
145
+ def self.mix(*audios, strategy: :clip, gains: nil, align: :start, format: nil, work_format: nil,
146
+ headroom_smoothing: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS)
30
147
  raise InvalidParameterError, "at least one Audio is required" if audios.empty?
31
148
  raise InvalidParameterError, "all arguments must be Audio instances" unless audios.all? { |audio| audio.is_a?(self) }
32
149
 
33
- sample_rates = audios.map { |audio| audio.format.sample_rate }.uniq
34
- raise InvalidParameterError, "all audios must have the same sample_rate to mix" if sample_rates.length > 1
150
+ mix_strategy = normalize_mix_strategy!(strategy)
151
+ mix_gains = normalize_mix_gains!(gains, audios.length)
152
+ mix_alignment = normalize_mix_alignment!(align)
153
+ target_format = format || audios.first.format
154
+ raise InvalidParameterError, "format must be Core::Format" unless target_format.is_a?(Core::Format)
155
+ if work_format && !work_format.is_a?(Core::Format)
156
+ raise InvalidParameterError, "work_format must be Core::Format"
157
+ end
158
+ if !format && !work_format && audios.map { |audio| audio.format.sample_rate }.uniq.length > 1
159
+ raise InvalidParameterError, "different sample rates require an explicit format or work_format"
160
+ end
35
161
 
36
- target_format = audios.first.format
37
- work_format = target_format.with(sample_format: :float, bit_depth: 32)
38
- converted = audios.map { |audio| audio.buffer.convert(work_format) }
162
+ workspace_format = work_format || target_format.with(sample_format: :float, bit_depth: 32)
163
+ workspace_format = workspace_format.with(sample_format: :float, bit_depth: 32)
164
+ converted = audios.map { |audio| audio.buffer.convert(workspace_format) }
39
165
  max_frames = converted.map(&:sample_frame_count).max || 0
40
- channels = work_format.channels
166
+ channels = workspace_format.channels
41
167
  mixed = Array.new(max_frames * channels, 0.0)
42
168
 
43
- converted.each do |buffer|
169
+ converted.each_with_index do |buffer, audio_index|
170
+ gain_factor = db_to_amplitude(mix_gains.fetch(audio_index))
171
+ sample_offset = mix_alignment_offset(mix_alignment, max_frames, buffer.sample_frame_count) * channels
44
172
  buffer.samples.each_with_index do |sample, index|
45
- mixed[index] += sample
173
+ target_index = sample_offset + index
174
+ mixed[target_index] += sample * gain_factor
46
175
  end
47
176
  end
48
177
 
49
- mixed.map! { |sample| clip_value(sample, -1.0, 1.0) }
50
- new(Core::SampleBuffer.new(mixed, work_format).convert(target_format))
178
+ apply_mix_strategy!(mixed, mix_strategy, format: workspace_format, headroom_smoothing: headroom_smoothing)
179
+ new(Core::SampleBuffer.new(mixed, workspace_format).convert(target_format))
51
180
  end
52
181
 
53
182
  # Creates a streaming processing pipeline for an input path/IO.
@@ -56,12 +185,17 @@ module Wavify
56
185
  # @param chunk_size [Integer] chunk size in frames
57
186
  # @param format [Core::Format, nil] optional source format override
58
187
  # @param codec_options [Hash] codec-specific options forwarded to `.stream_read`
188
+ # @param strict [Boolean] verify extension and magic-byte codec agreement
189
+ # @param filename [String, nil] optional filename hint for IO inputs
59
190
  # @return [Core::Stream]
60
- def self.stream(path_or_io, chunk_size: 4096, format: nil, codec_options: nil)
61
- codec = Codecs::Registry.detect(path_or_io)
62
- source_format = format || codec.metadata(path_or_io)[:format]
63
- options = codec_options || {}
64
- raise InvalidParameterError, "codec_options must be a Hash" unless options.is_a?(Hash)
191
+ def self.stream(path_or_io, chunk_size: 4096, format: nil, codec_options: nil, strict: false, filename: nil)
192
+ codec = Codecs::Registry.detect_for_read(path_or_io, strict: strict, filename: filename)
193
+ options = normalize_codec_options!(codec_options)
194
+ source_format = format
195
+ if codec == Codecs::Raw && !source_format.is_a?(Core::Format)
196
+ raise InvalidFormatError, "format is required for raw stream input"
197
+ end
198
+ options = options.merge(format: source_format) if codec == Codecs::Raw
65
199
 
66
200
  stream = Core::Stream.new(
67
201
  path_or_io,
@@ -81,12 +215,13 @@ module Wavify
81
215
  # @param frequency [Numeric] oscillator frequency in Hz
82
216
  # @param duration [Numeric] duration in seconds
83
217
  # @param format [Core::Format] output format
84
- # @param waveform [Symbol] `:sine`, `:square`, `:triangle`, `:sawtooth`, `:white_noise`
218
+ # @param waveform [Symbol] `:sine`, `:square`, `:triangle`, `:sawtooth`, `:pulse`, `:white_noise`
85
219
  # @return [Audio]
86
- def self.tone(frequency:, duration:, format:, waveform: :sine)
220
+ def self.tone(frequency:, duration:, format:, waveform: :sine, **oscillator_options)
87
221
  oscillator = DSP::Oscillator.new(
88
222
  waveform: waveform,
89
- frequency: frequency
223
+ frequency: frequency,
224
+ **oscillator_options
90
225
  )
91
226
  new(oscillator.generate(duration, format: format))
92
227
  end
@@ -97,12 +232,13 @@ module Wavify
97
232
  # @param format [Core::Format]
98
233
  # @return [Audio]
99
234
  def self.silence(duration_seconds, format:)
100
- unless duration_seconds.is_a?(Numeric) && duration_seconds >= 0
101
- raise InvalidParameterError, "duration_seconds must be a non-negative Numeric: #{duration_seconds.inspect}"
235
+ seconds = duration_seconds.is_a?(Core::Duration) ? duration_seconds.total_seconds : duration_seconds
236
+ unless seconds.is_a?(Numeric) && seconds.respond_to?(:finite?) && seconds.finite? && seconds >= 0
237
+ raise InvalidParameterError, "duration_seconds must be a non-negative finite Numeric or Core::Duration: #{duration_seconds.inspect}"
102
238
  end
103
239
  raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
104
240
 
105
- frame_count = (duration_seconds.to_f * format.sample_rate).round
241
+ frame_count = (seconds.to_f * format.sample_rate).round
106
242
  default_sample = format.sample_format == :float ? 0.0 : 0
107
243
  samples = Array.new(frame_count * format.channels, default_sample)
108
244
  new(Core::SampleBuffer.new(samples, format))
@@ -115,14 +251,33 @@ module Wavify
115
251
  @buffer = buffer
116
252
  end
117
253
 
254
+ # Value equality based on the underlying immutable sample buffer.
255
+ def ==(other)
256
+ other.is_a?(Audio) && @buffer == other.buffer
257
+ end
258
+
259
+ alias eql? ==
260
+
261
+ def hash
262
+ @buffer.hash
263
+ end
264
+
118
265
  # Writes the audio to a file path using codec auto-detection.
119
266
  #
120
- # @param path [String]
267
+ # @param path [String, IO]
121
268
  # @param format [Core::Format, nil] optional output format
269
+ # @param codec_options [Hash] codec-specific options forwarded to `.write`
270
+ # @param codec [Symbol, Class, nil] explicit output codec for generic IO targets
271
+ # @param filename [String, nil] filename hint used for codec selection
272
+ # @param overwrite [Boolean] whether existing path output may be replaced
122
273
  # @return [Audio] self
123
- def write(path, format: nil)
124
- codec = Codecs::Registry.detect(path)
125
- codec.write(path, @buffer, format: format || @buffer.format)
274
+ def write(path, format: nil, codec: nil, filename: nil, codec_options: nil, overwrite: true)
275
+ validate_overwrite!(path, overwrite)
276
+ output_codec = codec ? Codecs::Registry.resolve(codec) : Codecs::Registry.detect_for_write(path, filename: filename)
277
+ options = normalize_codec_options!(codec_options)
278
+ with_output_target(path, overwrite: overwrite) do |target|
279
+ output_codec.write(target, @buffer, format: format || @buffer.format, **options)
280
+ end
126
281
  self
127
282
  end
128
283
 
@@ -141,12 +296,76 @@ module Wavify
141
296
  @buffer.sample_frame_count
142
297
  end
143
298
 
299
+ # @return [Integer]
300
+ def channels
301
+ format.channels
302
+ end
303
+
304
+ # @return [Integer]
305
+ def sample_rate
306
+ format.sample_rate
307
+ end
308
+
309
+ # @return [Integer]
310
+ def bit_depth
311
+ format.bit_depth
312
+ end
313
+
314
+ # Converts to another bit depth while keeping the remaining format fields.
315
+ def with_bit_depth(value, dither: false, dither_seed: nil)
316
+ convert(format.with(bit_depth: value), dither: dither, dither_seed: dither_seed)
317
+ end
318
+
319
+ # @return [Array<Array<Numeric>>] sample frames
320
+ def frames
321
+ @buffer.frame_view.to_a
322
+ end
323
+
324
+ # Enumerates sample frames.
325
+ #
326
+ # @yield [frame, frame_index]
327
+ # @yieldparam frame [Array<Numeric>]
328
+ # @yieldparam frame_index [Integer]
329
+ # @return [Enumerator, Audio]
330
+ def each_frame
331
+ return enum_for(:each_frame) unless block_given?
332
+
333
+ @buffer.frame_view.each.with_index do |frame, frame_index|
334
+ yield frame, frame_index
335
+ end
336
+ self
337
+ end
338
+
144
339
  # Converts to a new format/channels.
145
340
  #
146
341
  # @param new_format [Core::Format]
342
+ # @param dither [Boolean] add TPDF dither when converting to PCM
343
+ # @param dither_seed [Integer, nil] deterministic seed for dither noise
344
+ # @return [Audio]
345
+ def convert(new_format, dither: false, dither_seed: nil, resampler: :linear)
346
+ self.class.new(@buffer.convert(new_format, dither: dither, dither_seed: dither_seed, resampler: resampler))
347
+ end
348
+
349
+ # Converts to another sample rate.
350
+ #
351
+ # @param sample_rate [Integer]
352
+ # @return [Audio]
353
+ def resample(sample_rate:, resampler: :linear)
354
+ convert(format.with(sample_rate: sample_rate), resampler: resampler)
355
+ end
356
+
357
+ # Converts to mono by downmixing channels.
358
+ #
147
359
  # @return [Audio]
148
- def convert(new_format)
149
- self.class.new(@buffer.convert(new_format))
360
+ def to_mono
361
+ convert(format.with(channels: 1))
362
+ end
363
+
364
+ # Converts to stereo by upmixing/downmixing channels.
365
+ #
366
+ # @return [Audio]
367
+ def to_stereo
368
+ convert(format.with(channels: 2))
150
369
  end
151
370
 
152
371
  # Splits the audio into two clips at a time offset.
@@ -161,26 +380,178 @@ module Wavify
161
380
  [self.class.new(left), self.class.new(right)]
162
381
  end
163
382
 
383
+ # Slices audio between two time offsets.
384
+ #
385
+ # @param from [Numeric, Core::Duration]
386
+ # @param to [Numeric, Core::Duration]
387
+ # @return [Audio]
388
+ def slice(from:, to:)
389
+ start_frame = coerce_time_to_frame(from, upper_bound: @buffer.sample_frame_count)
390
+ end_frame = coerce_time_to_frame(to, upper_bound: @buffer.sample_frame_count)
391
+ raise InvalidParameterError, "to must be greater than or equal to from" if end_frame < start_frame
392
+
393
+ self.class.new(@buffer.slice(start_frame, end_frame - start_frame))
394
+ end
395
+
396
+ # Crops audio from a start time for a duration.
397
+ #
398
+ # @param start [Numeric, Core::Duration]
399
+ # @param duration [Numeric, Core::Duration]
400
+ # @return [Audio]
401
+ def crop(start:, duration:)
402
+ start_frame = coerce_time_to_frame(start, upper_bound: @buffer.sample_frame_count)
403
+ frame_length = coerce_duration_to_frame(duration)
404
+ self.class.new(@buffer.slice(start_frame, [frame_length, @buffer.sample_frame_count - start_frame].min))
405
+ end
406
+
407
+ # Concatenates another audio clip after this one.
408
+ #
409
+ # @param other [Audio]
410
+ # @return [Audio]
411
+ def concat(other)
412
+ validate_audio!(other, :other)
413
+ self.class.new(@buffer.concat(other.buffer))
414
+ end
415
+
416
+ alias append concat
417
+ alias + concat
418
+
419
+ # Prepends another audio clip before this one.
420
+ #
421
+ # @param other [Audio]
422
+ # @return [Audio]
423
+ def prepend(other)
424
+ validate_audio!(other, :other)
425
+ other.concat(self)
426
+ end
427
+
428
+ # Overlays another audio clip at a time offset.
429
+ #
430
+ # @param other [Audio]
431
+ # @param at [Numeric, Core::Duration]
432
+ # @param strategy [Symbol] mix clipping policy
433
+ # @param headroom_smoothing [Numeric] seconds of gain smoothing around peaks
434
+ # @return [Audio]
435
+ def overlay(other, at:, strategy: :clip, headroom_smoothing: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS)
436
+ validate_audio!(other, :other)
437
+ start_frame = coerce_time_to_frame(at, upper_bound: nil)
438
+ work_format = float_work_format(format)
439
+ base = @buffer.convert(work_format)
440
+ overlay_buffer = other.buffer.convert(work_format)
441
+ channels = work_format.channels
442
+ mixed_length = [base.samples.length, (start_frame * channels) + overlay_buffer.samples.length].max
443
+ mixed = Array.new(mixed_length, 0.0)
444
+ base.samples.each_with_index do |sample, index|
445
+ mixed[index] += sample
446
+ end
447
+ offset = start_frame * channels
448
+ overlay_buffer.samples.each_with_index do |sample, index|
449
+ target_index = offset + index
450
+ mixed[target_index] += sample
451
+ end
452
+
453
+ self.class.send(
454
+ :apply_mix_strategy!,
455
+ mixed,
456
+ self.class.send(:normalize_mix_strategy!, strategy),
457
+ format: work_format,
458
+ headroom_smoothing: headroom_smoothing
459
+ )
460
+ self.class.new(Core::SampleBuffer.new(mixed, work_format).convert(format))
461
+ end
462
+
463
+ # Crossfades this audio into another clip.
464
+ #
465
+ # @param other [Audio]
466
+ # @param duration [Numeric, Core::Duration]
467
+ # @return [Audio]
468
+ def crossfade(other, duration:)
469
+ validate_audio!(other, :other)
470
+ rhs = other.convert(format)
471
+ overlap_frames = coerce_duration_to_frame(duration)
472
+ max_overlap = [sample_frame_count, rhs.sample_frame_count].min
473
+ raise InvalidParameterError, "duration is longer than one of the clips" if overlap_frames > max_overlap
474
+
475
+ seconds = overlap_frames.to_f / sample_rate
476
+ left_head = self.class.new(@buffer.slice(0, sample_frame_count - overlap_frames))
477
+ left_tail = self.class.new(@buffer.slice(sample_frame_count - overlap_frames, overlap_frames)).fade_out(seconds)
478
+ right_head = self.class.new(rhs.buffer.slice(0, overlap_frames)).fade_in(seconds)
479
+ right_tail = self.class.new(rhs.buffer.slice(overlap_frames, rhs.sample_frame_count - overlap_frames))
480
+ middle = self.class.mix(left_tail, right_head, strategy: :clip)
481
+
482
+ left_head.concat(middle).concat(right_tail)
483
+ end
484
+
485
+ # Adds silence before the audio.
486
+ #
487
+ # @param seconds [Numeric, Core::Duration]
488
+ # @return [Audio]
489
+ def pad_start(seconds)
490
+ self.class.silence(coerce_seconds(seconds), format: format).concat(self)
491
+ end
492
+
493
+ # Adds silence after the audio.
494
+ #
495
+ # @param seconds [Numeric, Core::Duration]
496
+ # @return [Audio]
497
+ def pad_end(seconds)
498
+ concat(self.class.silence(coerce_seconds(seconds), format: format))
499
+ end
500
+
501
+ # Inserts silence at a time offset.
502
+ #
503
+ # @param at [Numeric, Core::Duration]
504
+ # @param duration [Numeric, Core::Duration]
505
+ # @return [Audio]
506
+ def insert_silence(at:, duration:)
507
+ left, right = split(at: at)
508
+ left.concat(self.class.silence(coerce_seconds(duration), format: format)).concat(right)
509
+ end
510
+
164
511
  # Repeats the audio content.
165
512
  #
166
513
  # @param times [Integer] repetition count
167
514
  # @return [Audio]
168
- def loop(times:)
515
+ def repeat(times:)
169
516
  raise InvalidParameterError, "times must be a non-negative Integer" unless times.is_a?(Integer) && times >= 0
170
517
 
171
518
  return self.class.new(Core::SampleBuffer.new([], @buffer.format)) if times.zero?
172
519
 
173
- result = @buffer
174
- (times - 1).times { result += @buffer }
175
- self.class.new(result)
520
+ repeated_frames = sample_frame_count * times
521
+ repeated_bytes = repeated_frames * format.block_align
522
+ if repeated_frames > MAX_REPEAT_FRAMES || repeated_bytes > MAX_REPEAT_BYTES
523
+ raise InvalidParameterError,
524
+ "repeat output exceeds limits (#{repeated_frames} frames, #{repeated_bytes} bytes); use #repeat_chunks"
525
+ end
526
+
527
+ self.class.new(Core::SampleBuffer.new(@buffer.samples * times, @buffer.format))
528
+ end
529
+
530
+ # Lazily yields repeated audio in bounded chunks.
531
+ def repeat_chunks(times:, chunk_frames: 4_096)
532
+ raise InvalidParameterError, "times must be a non-negative Integer" unless times.is_a?(Integer) && times >= 0
533
+ unless chunk_frames.is_a?(Integer) && chunk_frames.positive?
534
+ raise InvalidParameterError, "chunk_frames must be a positive Integer"
535
+ end
536
+
537
+ Enumerator.new do |yielder|
538
+ times.times do
539
+ offset = 0
540
+ while offset < sample_frame_count
541
+ length = [chunk_frames, sample_frame_count - offset].min
542
+ yielder << @buffer.slice(offset, length)
543
+ offset += length
544
+ end
545
+ end
546
+ end
176
547
  end
177
548
 
178
- # In-place variant of {#loop}.
549
+ # In-place variant of {#repeat}.
179
550
  #
180
551
  # @param times [Integer]
181
552
  # @return [Audio] self
182
- def loop!(times:)
183
- replace_buffer!(self.loop(times: times).buffer)
553
+ def repeat!(times:)
554
+ replace_buffer!(repeat(times: times).buffer)
184
555
  self
185
556
  end
186
557
 
@@ -204,9 +575,10 @@ module Wavify
204
575
  # @param db [Numeric]
205
576
  # @return [Audio]
206
577
  def gain(db)
207
- factor = 10.0**(db.to_f / 20.0)
578
+ db = validate_finite_numeric!(db, :db)
579
+ factor = 10.0**(db / 20.0)
208
580
  transform_samples do |samples, _format|
209
- samples.map { |sample| (sample * factor).clamp(-1.0, 1.0) }
581
+ samples.map! { |sample| sample * factor }
210
582
  end
211
583
  end
212
584
 
@@ -223,14 +595,30 @@ module Wavify
223
595
  #
224
596
  # @param target_db [Numeric]
225
597
  # @return [Audio]
226
- def normalize(target_db: 0.0)
227
- transform_samples do |samples, _format|
228
- peak = samples.map(&:abs).max || 0.0
229
- next samples if peak.zero?
598
+ def normalize(target_db: 0.0, mode: :peak)
599
+ normalize_mode = self.class.send(:normalize_mode!, mode)
600
+ unless target_db.is_a?(Numeric) && target_db.respond_to?(:finite?) && target_db.finite?
601
+ raise InvalidParameterError, "target_db must be a finite Numeric"
602
+ end
603
+ if normalize_mode == :peak && target_db.positive?
604
+ raise InvalidParameterError, "peak target_db must be <= 0 dBFS"
605
+ end
606
+
607
+ transform_samples do |samples, work_format|
608
+ if normalize_mode == :lufs
609
+ current_lufs = integrated_loudness(samples, work_format)
610
+ next samples unless current_lufs.finite?
611
+
612
+ factor = 10.0**((target_db.to_f - current_lufs) / 20.0)
613
+ next samples.map! { |sample| sample * factor }
614
+ end
615
+
616
+ current = normalize_reference_amplitude(samples, normalize_mode)
617
+ next samples if current.zero?
230
618
 
231
619
  target = 10.0**(target_db.to_f / 20.0)
232
- factor = target / peak
233
- samples.map { |sample| (sample * factor).clamp(-1.0, 1.0) }
620
+ factor = target / current
621
+ samples.map! { |sample| sample * factor }
234
622
  end
235
623
  end
236
624
 
@@ -238,8 +626,8 @@ module Wavify
238
626
  #
239
627
  # @param target_db [Numeric]
240
628
  # @return [Audio] self
241
- def normalize!(target_db: 0.0)
242
- replace_buffer!(normalize(target_db: target_db).buffer)
629
+ def normalize!(target_db: 0.0, mode: :peak)
630
+ replace_buffer!(normalize(target_db: target_db, mode: mode).buffer)
243
631
  self
244
632
  end
245
633
 
@@ -252,13 +640,18 @@ module Wavify
252
640
 
253
641
  float_buffer = @buffer.convert(float_work_format(@buffer.format))
254
642
  channels = float_buffer.format.channels
255
- frames = float_buffer.samples.each_slice(channels).to_a
256
- first = frames.index { |frame| frame.any? { |sample| sample.abs >= threshold } }
643
+ first = nil
644
+ last = nil
645
+ float_buffer.samples.each_slice(channels).with_index do |frame, frame_index|
646
+ next unless frame.any? { |sample| sample.abs >= threshold }
647
+
648
+ first ||= frame_index
649
+ last = frame_index
650
+ end
257
651
  return self.class.new(Core::SampleBuffer.new([], @buffer.format)) unless first
258
652
 
259
- last = frames.rindex { |frame| frame.any? { |sample| sample.abs >= threshold } }
260
- trimmed = frames[first..last].flatten
261
- self.class.new(Core::SampleBuffer.new(trimmed, float_buffer.format).convert(@buffer.format))
653
+ float_trimmed = float_buffer.slice(first, last - first + 1)
654
+ self.class.new(float_trimmed.convert(@buffer.format))
262
655
  end
263
656
 
264
657
  # In-place variant of {#trim}.
@@ -270,67 +663,72 @@ module Wavify
270
663
  self
271
664
  end
272
665
 
273
- # Applies a linear fade-in.
666
+ # Applies a fade-in.
274
667
  #
275
- # @param seconds [Numeric]
668
+ # @param seconds [Numeric, Core::Duration]
669
+ # @param curve [Symbol] `:linear`, `:exp`, or `:log`
276
670
  # @return [Audio]
277
- def fade_in(seconds)
278
- apply_fade(seconds: seconds, mode: :in)
671
+ def fade_in(seconds, curve: :linear)
672
+ apply_fade(seconds: seconds, mode: :in, curve: curve)
279
673
  end
280
674
 
281
675
  # In-place variant of {#fade_in}.
282
676
  #
283
- # @param seconds [Numeric]
677
+ # @param seconds [Numeric, Core::Duration]
678
+ # @param curve [Symbol]
284
679
  # @return [Audio] self
285
- def fade_in!(seconds)
286
- replace_buffer!(fade_in(seconds).buffer)
680
+ def fade_in!(seconds, curve: :linear)
681
+ replace_buffer!(fade_in(seconds, curve: curve).buffer)
287
682
  self
288
683
  end
289
684
 
290
- # Applies a linear fade-out.
685
+ # Applies a fade-out.
291
686
  #
292
- # @param seconds [Numeric]
687
+ # @param seconds [Numeric, Core::Duration]
688
+ # @param curve [Symbol] `:linear`, `:exp`, or `:log`
293
689
  # @return [Audio]
294
- def fade_out(seconds)
295
- apply_fade(seconds: seconds, mode: :out)
690
+ def fade_out(seconds, curve: :linear)
691
+ apply_fade(seconds: seconds, mode: :out, curve: curve)
296
692
  end
297
693
 
298
694
  # In-place variant of {#fade_out}.
299
695
  #
300
- # @param seconds [Numeric]
696
+ # @param seconds [Numeric, Core::Duration]
697
+ # @param curve [Symbol]
301
698
  # @return [Audio] self
302
- def fade_out!(seconds)
303
- replace_buffer!(fade_out(seconds).buffer)
699
+ def fade_out!(seconds, curve: :linear)
700
+ replace_buffer!(fade_out(seconds, curve: curve).buffer)
304
701
  self
305
702
  end
306
703
 
307
- # Constant-power pan for mono/stereo sources.
704
+ # Applies a fade in either direction.
308
705
  #
309
- # Mono inputs are first upmixed to stereo.
706
+ # @param seconds [Numeric, Core::Duration]
707
+ # @param type [Symbol] `:in` or `:out`
708
+ # @param curve [Symbol] `:linear`, `:exp`, or `:log`
709
+ # @return [Audio]
710
+ def fade(seconds, type:, curve: :linear)
711
+ apply_fade(seconds: seconds, mode: type, curve: curve)
712
+ end
713
+
714
+ # Constant-power pan for a mono source.
310
715
  #
311
716
  # @param position [Numeric] `-1.0` (left) to `1.0` (right)
312
717
  # @return [Audio]
313
718
  def pan(position)
314
719
  validate_pan_position!(position)
720
+ raise InvalidParameterError, "pan requires mono input; use #balance for stereo" unless @buffer.format.channels == 1
315
721
 
316
- case @buffer.format.channels
317
- when 1
318
- source_format = @buffer.format.with(channels: 2)
319
- when 2
320
- source_format = @buffer.format
321
- else
322
- raise InvalidParameterError, "pan is only supported for mono/stereo input"
323
- end
722
+ source_format = @buffer.format.with(channels: 2)
324
723
 
325
724
  transform_samples(target_format: source_format) do |samples, _format|
326
725
  left_gain, right_gain = constant_power_pan_gains(position.to_f)
327
- result = samples.dup
328
- result.each_slice(2).with_index do |(left, right), frame_index|
726
+ samples.each_slice(2).with_index do |(left, right), frame_index|
329
727
  base = frame_index * 2
330
- result[base] = (left * left_gain).clamp(-1.0, 1.0)
331
- result[base + 1] = (right * right_gain).clamp(-1.0, 1.0)
728
+ samples[base] = left * left_gain
729
+ samples[base + 1] = right * right_gain
332
730
  end
333
- result
731
+ samples
334
732
  end
335
733
  end
336
734
 
@@ -343,6 +741,41 @@ module Wavify
343
741
  self
344
742
  end
345
743
 
744
+ # Adjusts the relative level of existing stereo channels without moving content between them.
745
+ def balance(position)
746
+ validate_pan_position!(position)
747
+ raise InvalidParameterError, "balance requires stereo input" unless channels == 2
748
+
749
+ transform_samples do |samples, _format|
750
+ left_gain = position.positive? ? Math.cos(position * Math::PI / 2.0) : 1.0
751
+ right_gain = position.negative? ? Math.cos(position.abs * Math::PI / 2.0) : 1.0
752
+ samples.each_slice(2).with_index do |(left, right), frame_index|
753
+ base = frame_index * 2
754
+ samples[base] = left * left_gain
755
+ samples[base + 1] = right * right_gain
756
+ end
757
+ samples
758
+ end
759
+ end
760
+
761
+ # Rotates a stereo image, transferring energy between left and right.
762
+ def stereo_rotate(position)
763
+ validate_pan_position!(position)
764
+ raise InvalidParameterError, "stereo_rotate requires stereo input" unless channels == 2
765
+
766
+ angle = position * Math::PI / 4.0
767
+ cosine = Math.cos(angle)
768
+ sine = Math.sin(angle)
769
+ transform_samples do |samples, _format|
770
+ samples.each_slice(2).with_index do |(left, right), frame_index|
771
+ base = frame_index * 2
772
+ samples[base] = (left * cosine) - (right * sine)
773
+ samples[base + 1] = (left * sine) + (right * cosine)
774
+ end
775
+ samples
776
+ end
777
+ end
778
+
346
779
  # Applies an effect/processor object to the audio buffer.
347
780
  #
348
781
  # Accepted interfaces: `#process`, `#call`, or `#apply`.
@@ -350,17 +783,7 @@ module Wavify
350
783
  # @param effect [Object]
351
784
  # @return [Audio]
352
785
  def apply(effect)
353
- processed = if effect.respond_to?(:process)
354
- effect.process(@buffer)
355
- elsif effect.respond_to?(:call)
356
- effect.call(@buffer)
357
- elsif effect.respond_to?(:apply)
358
- effect.apply(@buffer)
359
- else
360
- raise InvalidParameterError, "effect must respond to :process, :call, or :apply"
361
- end
362
-
363
- raise ProcessingError, "effect must return Core::SampleBuffer" unless processed.is_a?(Core::SampleBuffer)
786
+ processed = DSP::Processor.render(effect, @buffer)
364
787
 
365
788
  self.class.new(processed)
366
789
  end
@@ -374,14 +797,64 @@ module Wavify
374
797
  self
375
798
  end
376
799
 
800
+ # Maps normalized float samples and returns a new audio object in the original format.
801
+ #
802
+ # @yield [sample, sample_index]
803
+ # @yieldparam sample [Float]
804
+ # @yieldparam sample_index [Integer]
805
+ # @return [Enumerator, Audio]
806
+ def map_samples
807
+ return enum_for(:map_samples) unless block_given?
808
+
809
+ transform_samples do |samples, _format|
810
+ samples.map!.with_index do |sample, sample_index|
811
+ validate_mapped_sample!(yield(sample, sample_index), "sample #{sample_index}")
812
+ end
813
+ end
814
+ end
815
+
816
+ # Maps normalized float frames and returns a new audio object in the original format.
817
+ #
818
+ # @yield [frame, frame_index]
819
+ # @yieldparam frame [Array<Float>]
820
+ # @yieldparam frame_index [Integer]
821
+ # @return [Enumerator, Audio]
822
+ def map_frames
823
+ return enum_for(:map_frames) unless block_given?
824
+
825
+ transform_samples do |samples, work_format|
826
+ mapped = []
827
+ samples.each_slice(work_format.channels).with_index do |frame, frame_index|
828
+ output = yield(frame.dup, frame_index)
829
+ unless output.is_a?(Array) && output.length == work_format.channels
830
+ raise InvalidParameterError, "map_frames block must return an Array with #{work_format.channels} samples"
831
+ end
832
+
833
+ mapped.concat(output.map.with_index do |sample, channel_index|
834
+ validate_mapped_sample!(sample, "frame #{frame_index}, channel #{channel_index}")
835
+ end)
836
+ end
837
+ mapped
838
+ end
839
+ end
840
+
377
841
  # Returns the absolute peak amplitude in float working space.
378
842
  #
379
843
  # @return [Float] 0.0..1.0
380
- def peak_amplitude
844
+ def sample_peak_amplitude
381
845
  float_buffer = @buffer.convert(float_work_format(@buffer.format))
382
846
  float_buffer.samples.map(&:abs).max || 0.0
383
847
  end
384
848
 
849
+
850
+ alias peak_amplitude sample_peak_amplitude
851
+
852
+ # Returns oversampled inter-sample peak amplitude.
853
+ def true_peak_amplitude(oversampling: DSP::LoudnessMeter::TRUE_PEAK_OVERSAMPLING)
854
+ float_buffer = @buffer.convert(float_work_format(@buffer.format))
855
+ DSP::LoudnessMeter.true_peak(float_buffer, format: float_buffer.format, oversampling: oversampling)
856
+ end
857
+
385
858
  # Returns RMS amplitude in float working space.
386
859
  #
387
860
  # @return [Float] 0.0..1.0
@@ -393,48 +866,390 @@ module Wavify
393
866
  Math.sqrt(square_sum / float_buffer.samples.length)
394
867
  end
395
868
 
869
+ # @return [Float] peak amplitude in dBFS
870
+ def peak_dbfs
871
+ sample_peak_dbfs
872
+ end
873
+
874
+ def sample_peak_dbfs
875
+ amplitude_to_dbfs(sample_peak_amplitude)
876
+ end
877
+
878
+ def true_peak_dbfs(oversampling: DSP::LoudnessMeter::TRUE_PEAK_OVERSAMPLING)
879
+ amplitude_to_dbfs(true_peak_amplitude(oversampling: oversampling))
880
+ end
881
+
882
+ # @return [Float] RMS amplitude in dBFS
883
+ def rms_dbfs
884
+ amplitude_to_dbfs(rms_amplitude)
885
+ end
886
+
887
+ # @return [Float] BS.1770 integrated loudness in LUFS
888
+ def lufs
889
+ float_buffer = @buffer.convert(float_work_format(@buffer.format))
890
+ integrated_loudness(float_buffer.samples, float_buffer.format)
891
+ end
892
+
893
+ # @return [Hash] basic audio statistics
894
+ def stats
895
+ float_buffer = @buffer.convert(float_work_format(@buffer.format))
896
+ samples = float_buffer.samples
897
+ accumulated = accumulate_statistics(samples, float_buffer.format.channels, consecutive_frames: 2)
898
+ peak = accumulated.fetch(:peak)
899
+ rms = accumulated.fetch(:rms)
900
+ true_peak = DSP::LoudnessMeter.true_peak(float_buffer, format: float_buffer.format)
901
+ {
902
+ format: format,
903
+ duration: duration,
904
+ sample_frame_count: sample_frame_count,
905
+ peak_amplitude: peak,
906
+ sample_peak_amplitude: peak,
907
+ true_peak_amplitude: true_peak,
908
+ rms_amplitude: rms,
909
+ peak_dbfs: amplitude_to_dbfs(peak),
910
+ sample_peak_dbfs: amplitude_to_dbfs(peak),
911
+ true_peak_dbfs: amplitude_to_dbfs(true_peak),
912
+ rms_dbfs: amplitude_to_dbfs(rms),
913
+ lufs: integrated_loudness(samples, float_buffer.format),
914
+ clipped: accumulated.fetch(:clipped),
915
+ silent: peak.zero?,
916
+ dc_offsets: accumulated.fetch(:dc_offsets),
917
+ zero_crossing_rate: accumulated.fetch(:zero_crossing_rate)
918
+ }
919
+ end
920
+
921
+ # @param threshold [Numeric]
922
+ # @return [Boolean]
923
+ def silent?(threshold: 0.0)
924
+ raise InvalidParameterError, "threshold must be Numeric in 0.0..1.0" unless threshold.is_a?(Numeric) && threshold.between?(0.0, 1.0)
925
+
926
+ peak_amplitude <= threshold
927
+ end
928
+
929
+ # Detects likely digital clipping by looking for consecutive full-scale
930
+ # samples on the same channel. A single legal full-scale PCM sample is not
931
+ # sufficient evidence that the source was clipped.
932
+ #
933
+ # @param consecutive_frames [Integer] full-scale frames required per channel
934
+ # @return [Boolean]
935
+ def clipped?(consecutive_frames: 2)
936
+ unless consecutive_frames.is_a?(Integer) && consecutive_frames.positive?
937
+ raise InvalidParameterError, "consecutive_frames must be a positive Integer"
938
+ end
939
+
940
+ float_buffer = @buffer.convert(float_work_format(@buffer.format))
941
+ clipped_samples?(float_buffer.samples, float_buffer.format.channels, consecutive_frames: consecutive_frames)
942
+ end
943
+
944
+ # @return [Float] average sample offset in normalized float space
945
+ def dc_offset
946
+ float_buffer = @buffer.convert(float_work_format(@buffer.format))
947
+ return 0.0 if float_buffer.samples.empty?
948
+
949
+ float_buffer.samples.sum / float_buffer.samples.length.to_f
950
+ end
951
+
952
+ # Returns the mean offset for each channel.
953
+ def dc_offsets
954
+ float_buffer = @buffer.convert(float_work_format(@buffer.format))
955
+ channels = float_buffer.format.channels
956
+ return Array.new(channels, 0.0) if float_buffer.sample_frame_count.zero?
957
+
958
+ sums = Array.new(channels, 0.0)
959
+ float_buffer.each_frame_sample { |sample, _frame, channel| sums[channel] += sample }
960
+ sums.map { |sum| sum / float_buffer.sample_frame_count.to_f }
961
+ end
962
+
963
+ # @return [Audio]
964
+ def remove_dc_offset
965
+ offsets = dc_offsets
966
+ map_samples { |sample, index| sample - offsets.fetch(index % channels) }
967
+ end
968
+
969
+ # @return [Float] zero-crossing rate per channel stream
970
+ def zero_crossing_rate
971
+ float_buffer = @buffer.convert(float_work_format(@buffer.format))
972
+ channels = float_buffer.format.channels
973
+ return 0.0 if float_buffer.sample_frame_count <= 1
974
+
975
+ crossings = 0
976
+ comparisons = 0
977
+ channels.times do |channel|
978
+ previous = nil
979
+ float_buffer.samples.each_slice(channels) do |frame|
980
+ current = frame.fetch(channel)
981
+ if previous
982
+ crossings += 1 if (previous.negative? && current >= 0.0) || (previous >= 0.0 && current.negative?)
983
+ comparisons += 1
984
+ end
985
+ previous = current
986
+ end
987
+ end
988
+ return 0.0 if comparisons.zero?
989
+
990
+ crossings.to_f / comparisons
991
+ end
992
+
993
+ # @return [String]
994
+ def inspect
995
+ "#<#{self.class.name} #{human_sample_rate} #{human_channels} #{format.bit_depth}-bit #{format.sample_format} #{Kernel.format('%.3fs', duration.total_seconds)}>"
996
+ end
997
+
396
998
  private
397
999
 
398
- def apply_fade(seconds:, mode:)
399
- raise InvalidParameterError, "seconds must be a non-negative Numeric" unless seconds.is_a?(Numeric) && seconds >= 0
1000
+ def accumulate_statistics(samples, channels, consecutive_frames:)
1001
+ peak = 0.0
1002
+ square_sum = 0.0
1003
+ channel_sums = Array.new(channels, 0.0)
1004
+ previous = Array.new(channels)
1005
+ crossings = 0
1006
+ comparisons = 0
1007
+ clipped_states = Array.new(channels) { { polarity: nil, count: 0 } }
1008
+ clipped = false
1009
+
1010
+ samples.each_with_index do |sample, index|
1011
+ channel = index % channels
1012
+ absolute = sample.abs
1013
+ peak = absolute if absolute > peak
1014
+ square_sum += sample * sample
1015
+ channel_sums[channel] += sample
1016
+ prior = previous[channel]
1017
+ if prior
1018
+ crossings += 1 if (prior.negative? && sample >= 0.0) || (prior >= 0.0 && sample.negative?)
1019
+ comparisons += 1
1020
+ end
1021
+ previous[channel] = sample
1022
+
1023
+ state = clipped_states.fetch(channel)
1024
+ polarity = sample <= -1.0 ? -1 : (1 if sample >= 1.0)
1025
+ if polarity
1026
+ state[:count] = state[:polarity] == polarity ? state[:count] + 1 : 1
1027
+ state[:polarity] = polarity
1028
+ clipped ||= state[:count] >= consecutive_frames
1029
+ else
1030
+ state[:polarity] = nil
1031
+ state[:count] = 0
1032
+ end
1033
+ end
1034
+ frames = samples.length / channels
1035
+ {
1036
+ peak: peak,
1037
+ rms: samples.empty? ? 0.0 : Math.sqrt(square_sum / samples.length),
1038
+ dc_offsets: frames.zero? ? Array.new(channels, 0.0) : channel_sums.map { |sum| sum / frames.to_f },
1039
+ zero_crossing_rate: comparisons.zero? ? 0.0 : crossings.to_f / comparisons,
1040
+ clipped: clipped
1041
+ }
1042
+ end
1043
+
1044
+ def clipped_samples?(samples, channels, consecutive_frames:)
1045
+ clipped_channels = Array.new(channels) { { polarity: nil, count: 0 } }
1046
+
1047
+ samples.each_slice(channels) do |frame|
1048
+ frame.each_with_index do |sample, channel|
1049
+ state = clipped_channels.fetch(channel)
1050
+ polarity = sample <= -1.0 ? -1 : (1 if sample >= 1.0)
1051
+ unless polarity
1052
+ state[:polarity] = nil
1053
+ state[:count] = 0
1054
+ next
1055
+ end
1056
+
1057
+ state[:count] = state[:polarity] == polarity ? state[:count] + 1 : 1
1058
+ state[:polarity] = polarity
1059
+ return true if state[:count] >= consecutive_frames
1060
+ end
1061
+ end
1062
+
1063
+ false
1064
+ end
1065
+
1066
+ def self.normalize_codec_options!(codec_options)
1067
+ return {} if codec_options.nil?
1068
+ raise InvalidParameterError, "codec_options must be a Hash" unless codec_options.is_a?(Hash)
1069
+
1070
+ invalid_keys = codec_options.keys.reject { |key| key.is_a?(Symbol) }
1071
+ unless invalid_keys.empty?
1072
+ raise InvalidParameterError, "codec_options keys must be Symbols: #{invalid_keys.map(&:inspect).join(', ')}"
1073
+ end
1074
+
1075
+ codec_options.dup
1076
+ end
1077
+ private_class_method :normalize_codec_options!
1078
+
1079
+ def normalize_codec_options!(codec_options)
1080
+ self.class.send(:normalize_codec_options!, codec_options)
1081
+ end
1082
+
1083
+ def validate_overwrite!(path, overwrite)
1084
+ raise InvalidParameterError, "overwrite must be true or false" unless overwrite == true || overwrite == false
1085
+ end
1086
+
1087
+ def with_output_target(path, overwrite:)
1088
+ return yield(path) unless path.is_a?(String)
1089
+
1090
+ expanded_path = File.expand_path(path)
1091
+ temporary = Tempfile.new([".wavify-", File.extname(path)], File.dirname(expanded_path), binmode: true)
1092
+ yield temporary
1093
+ temporary.flush
1094
+ temporary.fsync
1095
+ temporary.close
1096
+ if overwrite
1097
+ File.rename(temporary.path, expanded_path)
1098
+ else
1099
+ File.link(temporary.path, expanded_path)
1100
+ File.unlink(temporary.path)
1101
+ end
1102
+ rescue Errno::EEXIST
1103
+ raise InvalidParameterError, "output file already exists: #{path}"
1104
+ ensure
1105
+ temporary&.close!
1106
+ end
1107
+
1108
+ def validate_audio!(audio, name)
1109
+ raise InvalidParameterError, "#{name} must be Audio" unless audio.is_a?(self.class)
1110
+ end
1111
+
1112
+ def coerce_seconds(value)
1113
+ seconds = case value
1114
+ when Core::Duration
1115
+ value.total_seconds
1116
+ when Numeric
1117
+ value.to_f
1118
+ else
1119
+ raise InvalidParameterError, "time value must be Numeric or Core::Duration"
1120
+ end
1121
+ unless seconds.respond_to?(:finite?) && seconds.finite? && seconds >= 0.0
1122
+ raise InvalidParameterError, "time value must be a non-negative finite Numeric"
1123
+ end
1124
+
1125
+ seconds
1126
+ end
1127
+
1128
+ def coerce_time_to_frame(value, upper_bound:)
1129
+ frame = (coerce_seconds(value) * @buffer.format.sample_rate).round
1130
+ if upper_bound && frame > upper_bound
1131
+ raise InvalidParameterError, "time value is out of range: #{frame}"
1132
+ end
1133
+
1134
+ frame
1135
+ end
1136
+
1137
+ def coerce_duration_to_frame(value)
1138
+ (coerce_seconds(value) * @buffer.format.sample_rate).round
1139
+ end
1140
+
1141
+ def amplitude_to_dbfs(amplitude)
1142
+ return -Float::INFINITY if amplitude <= 0.0
1143
+
1144
+ 20.0 * Math.log10(amplitude)
1145
+ end
1146
+
1147
+ def normalize_reference_amplitude(samples, mode)
1148
+ case mode
1149
+ when :peak
1150
+ samples.map(&:abs).max || 0.0
1151
+ when :rms
1152
+ return 0.0 if samples.empty?
1153
+
1154
+ Math.sqrt(samples.sum { |sample| sample * sample } / samples.length)
1155
+ end
1156
+ end
1157
+
1158
+ def integrated_loudness(samples, format)
1159
+ DSP::LoudnessMeter.integrated(
1160
+ samples,
1161
+ format: format
1162
+ )
1163
+ end
1164
+
1165
+ def human_sample_rate
1166
+ if sample_rate >= 1000
1167
+ "#{(sample_rate / 1000.0).round(1)}kHz"
1168
+ else
1169
+ "#{sample_rate}Hz"
1170
+ end
1171
+ end
1172
+
1173
+ def human_channels
1174
+ case channels
1175
+ when 1 then "mono"
1176
+ when 2 then "stereo"
1177
+ else "#{channels}ch"
1178
+ end
1179
+ end
1180
+
1181
+ def apply_fade(seconds:, mode:, curve:)
1182
+ fade_seconds = coerce_seconds(seconds)
1183
+ fade_mode = self.class.send(:normalize_fade_mode!, mode)
1184
+ fade_curve = self.class.send(:normalize_fade_curve!, curve)
400
1185
 
401
1186
  transform_samples do |samples, format|
402
1187
  channels = format.channels
403
1188
  sample_frames = samples.length / channels
404
- fade_frames = [(seconds.to_f * format.sample_rate).round, sample_frames].min
405
- return samples if fade_frames.zero?
1189
+ fade_frames = [(fade_seconds * format.sample_rate).round, sample_frames].min
1190
+ next samples if fade_frames.zero?
406
1191
 
407
- result = samples.dup
408
1192
  start_frame = sample_frames - fade_frames
409
1193
 
410
- result.each_slice(channels).with_index do |frame, frame_index|
411
- factor = case mode
412
- when :in
413
- frame_index < fade_frames ? frame_index.to_f / fade_frames : 1.0
414
- when :out
415
- frame_index >= start_frame ? (sample_frames - frame_index - 1).to_f / fade_frames : 1.0
416
- else
417
- 1.0
418
- end
419
-
420
- factor = factor.clamp(0.0, 1.0)
1194
+ samples.each_slice(channels).with_index do |frame, frame_index|
1195
+ factor = fade_factor_for(
1196
+ frame_index,
1197
+ fade_frames: fade_frames,
1198
+ sample_frames: sample_frames,
1199
+ start_frame: start_frame,
1200
+ mode: fade_mode,
1201
+ curve: fade_curve
1202
+ )
421
1203
  base = frame_index * channels
422
1204
  frame.each_index do |channel_index|
423
- result[base + channel_index] = (frame[channel_index] * factor).clamp(-1.0, 1.0)
1205
+ samples[base + channel_index] = frame[channel_index] * factor
424
1206
  end
425
1207
  end
426
1208
 
427
- result
1209
+ samples
1210
+ end
1211
+ end
1212
+
1213
+ def fade_factor_for(frame_index, fade_frames:, sample_frames:, start_frame:, mode:, curve:)
1214
+ linear_factor = case mode
1215
+ when :in
1216
+ frame_index < fade_frames ? fade_endpoint_ratio(frame_index, fade_frames, single_frame: 1.0) : 1.0
1217
+ when :out
1218
+ if frame_index >= start_frame
1219
+ fade_endpoint_ratio(sample_frames - frame_index - 1, fade_frames, single_frame: 0.0)
1220
+ else
1221
+ 1.0
1222
+ end
1223
+ end
1224
+
1225
+ fade_curve_factor(linear_factor.clamp(0.0, 1.0), curve)
1226
+ end
1227
+
1228
+ def fade_curve_factor(value, curve)
1229
+ case curve
1230
+ when :linear
1231
+ value
1232
+ when :exp
1233
+ value * value
1234
+ when :log
1235
+ Math.log10(1.0 + (9.0 * value))
428
1236
  end
429
1237
  end
430
1238
 
1239
+ def fade_endpoint_ratio(numerator, fade_frames, single_frame:)
1240
+ return single_frame if fade_frames == 1
1241
+
1242
+ numerator.to_f / (fade_frames - 1)
1243
+ end
1244
+
431
1245
  def transform_samples(target_format: @buffer.format)
432
1246
  raise InvalidParameterError, "target_format must be Core::Format" unless target_format.is_a?(Core::Format)
433
1247
 
434
1248
  work_format = float_work_format(target_format)
435
- working_buffer = @buffer.convert(work_format)
436
- transformed_samples = yield(working_buffer.samples.dup, work_format)
437
- processed = Core::SampleBuffer.new(transformed_samples, work_format).convert(target_format)
1249
+ workspace = Core::SampleBuffer::MutableFloatWorkspace.from_buffer(@buffer, format: work_format)
1250
+ transformed_samples = yield(workspace.samples, work_format)
1251
+ workspace.replace!(transformed_samples) unless transformed_samples.equal?(workspace.samples)
1252
+ processed = workspace.to_sample_buffer.convert(target_format)
438
1253
  self.class.new(processed)
439
1254
  end
440
1255
 
@@ -448,7 +1263,25 @@ module Wavify
448
1263
  end
449
1264
 
450
1265
  def validate_pan_position!(position)
451
- raise InvalidParameterError, "position must be Numeric in -1.0..1.0" unless position.is_a?(Numeric) && position.between?(-1.0, 1.0)
1266
+ unless position.is_a?(Numeric) && position.respond_to?(:finite?) && position.finite? && position.between?(-1.0, 1.0)
1267
+ raise InvalidParameterError, "position must be a finite Numeric in -1.0..1.0"
1268
+ end
1269
+ end
1270
+
1271
+ def validate_finite_numeric!(value, name)
1272
+ unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite?
1273
+ raise InvalidParameterError, "#{name} must be a finite Numeric"
1274
+ end
1275
+
1276
+ value.to_f
1277
+ end
1278
+
1279
+ def validate_mapped_sample!(value, location)
1280
+ unless value.is_a?(Numeric) && value.real? && value.respond_to?(:finite?) && value.finite?
1281
+ raise InvalidParameterError, "mapped #{location} must be a finite real Numeric"
1282
+ end
1283
+
1284
+ value.to_f
452
1285
  end
453
1286
 
454
1287
  def replace_buffer!(new_buffer)
@@ -479,5 +1312,118 @@ module Wavify
479
1312
  value
480
1313
  end
481
1314
  private_class_method :clip_value
1315
+
1316
+ def self.normalize_mix_strategy!(strategy)
1317
+ normalized = strategy.to_sym if strategy.respond_to?(:to_sym)
1318
+ return normalized if MIX_STRATEGIES.include?(normalized)
1319
+
1320
+ raise InvalidParameterError, "strategy must be one of: #{MIX_STRATEGIES.join(', ')}"
1321
+ end
1322
+ private_class_method :normalize_mix_strategy!
1323
+
1324
+ def self.normalize_mix_alignment!(align)
1325
+ normalized = align.to_sym if align.respond_to?(:to_sym)
1326
+ return normalized if MIX_ALIGNMENTS.include?(normalized)
1327
+
1328
+ raise InvalidParameterError, "align must be one of: #{MIX_ALIGNMENTS.join(', ')}"
1329
+ end
1330
+ private_class_method :normalize_mix_alignment!
1331
+
1332
+ def self.normalize_mix_gains!(gains, source_count)
1333
+ return Array.new(source_count, 0.0) if gains.nil?
1334
+ raise InvalidParameterError, "gains must be an Array" unless gains.is_a?(Array)
1335
+ raise InvalidParameterError, "gains must have one value per Audio" unless gains.length == source_count
1336
+
1337
+ gains.map do |gain|
1338
+ unless gain.is_a?(Numeric) && gain.respond_to?(:finite?) && gain.finite?
1339
+ raise InvalidParameterError, "gains must contain finite Numeric dB values"
1340
+ end
1341
+
1342
+ gain.to_f
1343
+ end
1344
+ end
1345
+ private_class_method :normalize_mix_gains!
1346
+
1347
+ def self.mix_alignment_offset(align, max_frames, frame_count)
1348
+ case align
1349
+ when :start
1350
+ 0
1351
+ when :center
1352
+ ((max_frames - frame_count) / 2.0).round
1353
+ when :end
1354
+ max_frames - frame_count
1355
+ end
1356
+ end
1357
+ private_class_method :mix_alignment_offset
1358
+
1359
+ def self.db_to_amplitude(db)
1360
+ 10.0**(db / 20.0)
1361
+ end
1362
+ private_class_method :db_to_amplitude
1363
+
1364
+ def self.normalize_mode!(mode)
1365
+ normalized = mode.to_sym if mode.respond_to?(:to_sym)
1366
+ return normalized if NORMALIZE_MODES.include?(normalized)
1367
+
1368
+ raise InvalidParameterError, "mode must be one of: #{NORMALIZE_MODES.join(', ')}"
1369
+ end
1370
+ private_class_method :normalize_mode!
1371
+
1372
+ def self.normalize_fade_mode!(mode)
1373
+ normalized = mode.to_sym if mode.respond_to?(:to_sym)
1374
+ return normalized if %i[in out].include?(normalized)
1375
+
1376
+ raise InvalidParameterError, "fade type must be :in or :out"
1377
+ end
1378
+ private_class_method :normalize_fade_mode!
1379
+
1380
+ def self.normalize_fade_curve!(curve)
1381
+ normalized = curve.to_sym if curve.respond_to?(:to_sym)
1382
+ return normalized if FADE_CURVES.include?(normalized)
1383
+
1384
+ raise InvalidParameterError, "fade curve must be one of: #{FADE_CURVES.join(', ')}"
1385
+ end
1386
+ private_class_method :normalize_fade_curve!
1387
+
1388
+ def self.apply_mix_strategy!(samples, strategy, format: nil, headroom_smoothing: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS)
1389
+ case strategy
1390
+ when :none
1391
+ samples
1392
+ when :clip
1393
+ samples.map! { |sample| clip_value(sample, -1.0, 1.0) }
1394
+ when :normalize
1395
+ normalize_mix_samples!(samples)
1396
+ when :headroom
1397
+ DSP::Headroom.apply!(
1398
+ samples,
1399
+ channels: format&.channels || 1,
1400
+ sample_rate: format&.sample_rate || 1,
1401
+ smoothing_seconds: headroom_smoothing
1402
+ )
1403
+ when :soft_limit
1404
+ samples.map! { |sample| soft_limit_value(sample) }
1405
+ end
1406
+ end
1407
+ private_class_method :apply_mix_strategy!
1408
+
1409
+ def self.normalize_mix_samples!(samples)
1410
+ peak = samples.map(&:abs).max || 0.0
1411
+ return samples if peak <= 1.0
1412
+
1413
+ samples.map! { |sample| sample / peak }
1414
+ end
1415
+ private_class_method :normalize_mix_samples!
1416
+
1417
+ def self.soft_limit_value(sample)
1418
+ value = sample.to_f
1419
+ magnitude = value.abs
1420
+ return value if magnitude <= SOFT_LIMIT_THRESHOLD
1421
+
1422
+ range = 1.0 - SOFT_LIMIT_THRESHOLD
1423
+ sign = value.negative? ? -1.0 : 1.0
1424
+ limited = SOFT_LIMIT_THRESHOLD + (range * (1.0 - Math.exp(-(magnitude - SOFT_LIMIT_THRESHOLD) / range)))
1425
+ sign * clip_value(limited, 0.0, 1.0)
1426
+ end
1427
+ private_class_method :soft_limit_value
482
1428
  end
483
1429
  end