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,26 +5,322 @@ module Wavify
5
5
  # Immutable interleaved sample container with format metadata.
6
6
  #
7
7
  # Samples are stored in interleaved order (`L, R, L, R, ...`) for
8
- # multi-channel audio.
8
+ # multi-channel audio. Packed buffers remain packed when enumerated,
9
+ # compared, hashed, or converted to the same format without dither.
10
+ # Random-access operations (`samples`, `frame_view`, `view`, `slice`,
11
+ # `concat`, `reverse`, and format-changing `convert`) materialize them.
12
+ # Hashing samples a fixed number of positions, so it is O(1); collisions
13
+ # are resolved by full value equality, whose worst case is O(n).
9
14
  class SampleBuffer
10
15
  include Enumerable
11
16
 
12
- attr_reader :samples, :format, :duration
17
+ # Number of packed samples unpacked per sequential enumeration batch.
18
+ PACKED_ENUMERATION_CHUNK_SAMPLES = 4_096
19
+
20
+ # Builds integer PCM samples without normalized-float ambiguity.
21
+ def self.from_pcm_samples(samples, format, storage: :array)
22
+ unless format.is_a?(Format) && format.sample_format == :pcm
23
+ raise InvalidParameterError, "format must be a PCM Core::Format"
24
+ end
25
+ unless samples.is_a?(Array) && samples.all? { |sample| sample.is_a?(Integer) }
26
+ raise InvalidParameterError, "PCM samples must be Integers"
27
+ end
28
+
29
+ new(samples, format, storage: storage)
30
+ end
31
+
32
+ # Builds a buffer from normalized floating-point samples.
33
+ def self.from_normalized_samples(samples, format, storage: :array)
34
+ unless samples.is_a?(Array) && samples.all? do |sample|
35
+ sample.is_a?(Numeric) && sample.real? && sample.respond_to?(:finite?) && sample.finite?
36
+ end
37
+ raise InvalidParameterError, "normalized samples must be finite real Numeric values"
38
+ end
39
+
40
+ new(samples.map(&:to_f), format, storage: storage)
41
+ end
42
+
43
+ def self.adopt_mutable_float_samples!(samples, format) # :nodoc:
44
+ buffer = allocate
45
+ buffer.send(:initialize_owned_float_samples!, samples, format)
46
+ buffer
47
+ end
48
+ private_class_method :adopt_mutable_float_samples!
49
+
50
+ # Lazy no-copy view over interleaved samples as sample frames.
51
+ class FrameView
52
+ include Enumerable
53
+
54
+ attr_reader :channels, :frame_count
55
+
56
+ def initialize(samples, channels, start_frame: 0, frame_count: nil)
57
+ raise InvalidParameterError, "samples must be an Array" unless samples.is_a?(Array)
58
+ raise InvalidParameterError, "channels must be a positive Integer" unless channels.is_a?(Integer) && channels.positive?
59
+ raise InvalidParameterError, "start_frame must be a non-negative Integer" unless start_frame.is_a?(Integer) && start_frame >= 0
60
+
61
+ max_frames = samples.length / channels
62
+ @samples = samples
63
+ @channels = channels
64
+ @start_frame = [start_frame, max_frames].min
65
+ requested_count = frame_count.nil? ? max_frames - @start_frame : frame_count
66
+ raise InvalidParameterError, "frame_count must be a non-negative Integer" unless requested_count.is_a?(Integer) && requested_count >= 0
67
+
68
+ @frame_count = [requested_count, max_frames - @start_frame].min
69
+ end
70
+
71
+ def each
72
+ return enum_for(:each) unless block_given?
73
+
74
+ @frame_count.times { |index| yield self[index] }
75
+ end
76
+
77
+ def [](index)
78
+ raise InvalidParameterError, "frame index must be an Integer" unless index.is_a?(Integer)
79
+
80
+ normalized = index.negative? ? @frame_count + index : index
81
+ return nil unless normalized.between?(0, @frame_count - 1)
82
+
83
+ sample_index = (@start_frame + normalized) * @channels
84
+ @samples.slice(sample_index, @channels).dup
85
+ end
86
+
87
+ def slice(start_frame, frame_length)
88
+ unless start_frame.is_a?(Integer) && start_frame >= 0
89
+ raise InvalidParameterError, "start_frame must be a non-negative Integer: #{start_frame.inspect}"
90
+ end
91
+ unless frame_length.is_a?(Integer) && frame_length >= 0
92
+ raise InvalidParameterError, "frame_length must be a non-negative Integer: #{frame_length.inspect}"
93
+ end
94
+
95
+ self.class.new(@samples, @channels, start_frame: @start_frame + start_frame, frame_count: frame_length)
96
+ end
97
+
98
+ def length
99
+ @frame_count
100
+ end
101
+
102
+ alias size length
103
+ end
104
+
105
+ # Lazy no-copy view over a contiguous frame range of a sample buffer.
106
+ class View
107
+ include Enumerable
108
+
109
+ attr_reader :format, :duration
110
+
111
+ def initialize(samples, format, start_frame: 0, frame_count: nil)
112
+ raise InvalidParameterError, "samples must be an Array" unless samples.is_a?(Array)
113
+ raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Format)
114
+ raise InvalidParameterError, "start_frame must be a non-negative Integer" unless start_frame.is_a?(Integer) && start_frame >= 0
115
+
116
+ max_frames = samples.length / format.channels
117
+ @samples = samples
118
+ @format = format
119
+ @start_frame = [start_frame, max_frames].min
120
+ requested_count = frame_count.nil? ? max_frames - @start_frame : frame_count
121
+ raise InvalidParameterError, "frame_count must be a non-negative Integer" unless requested_count.is_a?(Integer) && requested_count >= 0
122
+
123
+ @frame_count = [requested_count, max_frames - @start_frame].min
124
+ @duration = Duration.from_samples(@frame_count, format.sample_rate)
125
+ end
126
+
127
+ # Enumerates interleaved sample values in the view range.
128
+ #
129
+ # @yieldparam sample [Numeric]
130
+ # @return [Enumerator, Array<Numeric>]
131
+ def each
132
+ return enum_for(:each) unless block_given?
133
+
134
+ start_index = @start_frame * @format.channels
135
+ length.times { |offset| yield @samples.fetch(start_index + offset) }
136
+ end
137
+
138
+ # Materializes the view's interleaved samples.
139
+ #
140
+ # @return [Array<Numeric>]
141
+ def samples
142
+ start_index = @start_frame * @format.channels
143
+ @samples.slice(start_index, length).freeze
144
+ end
145
+
146
+ # @return [Integer] number of interleaved samples in the view
147
+ def length
148
+ @frame_count * @format.channels
149
+ end
150
+
151
+ alias size length
152
+
153
+ # @return [Integer] number of sample frames in the view
154
+ def sample_frame_count
155
+ @frame_count
156
+ end
157
+
158
+ # @return [FrameView]
159
+ def frame_view
160
+ FrameView.new(@samples, @format.channels, start_frame: @start_frame, frame_count: @frame_count)
161
+ end
162
+
163
+ # Returns another lazy view relative to this view.
164
+ #
165
+ # @param start_frame [Integer]
166
+ # @param frame_length [Integer]
167
+ # @return [View]
168
+ def slice(start_frame, frame_length)
169
+ unless start_frame.is_a?(Integer) && start_frame >= 0
170
+ raise InvalidParameterError, "start_frame must be a non-negative Integer: #{start_frame.inspect}"
171
+ end
172
+ unless frame_length.is_a?(Integer) && frame_length >= 0
173
+ raise InvalidParameterError, "frame_length must be a non-negative Integer: #{frame_length.inspect}"
174
+ end
175
+
176
+ self.class.new(@samples, @format, start_frame: @start_frame + start_frame, frame_count: frame_length)
177
+ end
178
+
179
+ # Materializes the view as an immutable sample buffer.
180
+ #
181
+ # @return [SampleBuffer]
182
+ def to_sample_buffer
183
+ SampleBuffer.new(samples, @format)
184
+ end
185
+
186
+ # Converts the materialized view to another format.
187
+ #
188
+ # @param new_format [Format]
189
+ # @return [SampleBuffer]
190
+ def convert(new_format, **options)
191
+ to_sample_buffer.convert(new_format, **options)
192
+ end
193
+ end
194
+
195
+ # Supported internal storage backends.
196
+ STORAGE_TYPES = %i[array packed].freeze
197
+ # Number of representative samples included in the constant-time hash.
198
+ HASH_PROBE_COUNT = 8
199
+ StorageState = Struct.new(:samples, :packed_samples, :type, keyword_init: true) # :nodoc:
200
+
201
+ # Internal mutable processing workspace whose final Array ownership is
202
+ # transferred to an immutable SampleBuffer without another Array copy.
203
+ class MutableFloatWorkspace # :nodoc:
204
+ attr_reader :samples, :format
205
+
206
+ def self.from_buffer(buffer, format:)
207
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(SampleBuffer)
208
+
209
+ converted = buffer.format == format ? buffer : buffer.convert(format)
210
+ new(converted.samples.dup, format: format)
211
+ end
212
+
213
+ def initialize(samples, format:)
214
+ unless format.is_a?(Format) && format.sample_format == :float
215
+ raise InvalidParameterError, "workspace format must be a floating-point Core::Format"
216
+ end
217
+ raise InvalidParameterError, "workspace samples must be an Array" unless samples.is_a?(Array)
218
+
219
+ @samples = samples
220
+ @format = format
221
+ @consumed = false
222
+ end
223
+
224
+ def replace!(samples)
225
+ raise InvalidParameterError, "workspace samples must be an Array" unless samples.is_a?(Array)
226
+ raise InvalidParameterError, "workspace has already been consumed" if @consumed
227
+
228
+ @samples = samples
229
+ self
230
+ end
231
+
232
+ def to_sample_buffer
233
+ raise InvalidParameterError, "workspace has already been consumed" if @consumed
234
+
235
+ @consumed = true
236
+ SampleBuffer.send(:adopt_mutable_float_samples!, @samples, @format)
237
+ end
238
+ end
239
+
240
+ attr_reader :format, :duration
13
241
 
14
242
  # @param samples [Array<Numeric>] interleaved sample values
15
243
  # @param format [Format] sample encoding and channel layout
16
- def initialize(samples, format)
244
+ def initialize(samples, format, storage: :array)
17
245
  raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Format)
18
246
  raise InvalidParameterError, "samples must be an Array" unless samples.is_a?(Array)
19
247
 
20
248
  validate_samples!(samples)
21
249
  validate_interleaving!(samples.length, format.channels)
250
+ storage = normalize_storage!(storage)
22
251
 
23
252
  @format = format
24
- @samples = coerce_samples(samples, format).freeze
253
+ coerced = coerce_samples(samples, format)
254
+ @sample_count = coerced.length
255
+ @value_hash = sample_value_hash(coerced, canonical_float: storage == :packed && format.sample_format == :float)
256
+ @storage_mutex = Mutex.new
257
+ @storage_state = if storage == :packed
258
+ storage_state(samples: nil, packed_samples: pack_samples(coerced, format).freeze, type: :packed)
259
+ else
260
+ storage_state(samples: coerced.freeze, packed_samples: nil, type: :array)
261
+ end
25
262
  @duration = Duration.from_samples(sample_frame_count, format.sample_rate)
26
263
  end
27
264
 
265
+ # Materializes packed storage on first random-access use.
266
+ def samples
267
+ state = @storage_state
268
+ return state.samples if state.samples
269
+
270
+ @storage_mutex.synchronize do
271
+ state = @storage_state
272
+ return state.samples if state.samples
273
+
274
+ materialized = unpack_samples(state.packed_samples, @format).freeze
275
+ @storage_state = storage_state(samples: materialized, packed_samples: nil, type: :array)
276
+ end
277
+ @storage_state.samples
278
+ end
279
+
280
+ # Iterates interleaved values without allocating a frame Array.
281
+ def each_frame_sample
282
+ return enum_for(:each_frame_sample) unless block_given?
283
+
284
+ each.with_index do |sample, sample_index|
285
+ yield sample, sample_index / @format.channels, sample_index % @format.channels
286
+ end
287
+ self
288
+ end
289
+
290
+ def storage
291
+ @storage_state.type
292
+ end
293
+
294
+ def packed?
295
+ @storage_state.type == :packed
296
+ end
297
+
298
+ def packed_bytesize
299
+ @storage_state.packed_samples&.bytesize || 0
300
+ end
301
+
302
+ # Value equality for immutable samples and format metadata.
303
+ def ==(other)
304
+ return true if equal?(other)
305
+ return false unless other.is_a?(SampleBuffer) && @format == other.format && @sample_count == other.length
306
+
307
+ array_samples, packed_bytes = storage_snapshot
308
+ other_array_samples, other_packed_bytes = other.send(:storage_snapshot)
309
+ return array_samples == other_array_samples if array_samples && other_array_samples
310
+
311
+ if packed_bytes && other_packed_bytes
312
+ return packed_bytes == other_packed_bytes if @format.sample_format == :pcm || packed_bytes == other_packed_bytes
313
+ end
314
+
315
+ sample_values_equal?(other)
316
+ end
317
+
318
+ alias eql? ==
319
+
320
+ def hash
321
+ @value_hash
322
+ end
323
+
28
324
  # Enumerates sample values in interleaved order.
29
325
  #
30
326
  # @yield [sample]
@@ -33,35 +329,72 @@ module Wavify
33
329
  def each(&)
34
330
  return enum_for(:each) unless block_given?
35
331
 
36
- @samples.each(&)
332
+ return each_packed_sample(&) if packed?
333
+
334
+ @storage_state.samples.each(&)
37
335
  end
38
336
 
39
337
  # @return [Integer] number of interleaved samples
40
338
  def length
41
- @samples.length
339
+ @sample_count
42
340
  end
43
341
 
44
342
  alias size length
45
343
 
46
344
  # @return [Integer] number of audio frames
47
345
  def sample_frame_count
48
- @samples.length / @format.channels
346
+ @sample_count / @format.channels
347
+ end
348
+
349
+ # Returns a lazy sample-frame view without copying the full buffer.
350
+ #
351
+ # @return [FrameView]
352
+ def frame_view
353
+ FrameView.new(samples, @format.channels)
354
+ end
355
+
356
+ # Returns a lazy sample-buffer view without copying the selected frames.
357
+ #
358
+ # @param start_frame [Integer]
359
+ # @param frame_length [Integer, nil]
360
+ # @return [View]
361
+ def view(start_frame: 0, frame_length: nil)
362
+ View.new(samples, @format, start_frame: start_frame, frame_count: frame_length)
49
363
  end
50
364
 
51
365
  # Converts the buffer to another audio format/channels.
52
366
  #
53
367
  # @param new_format [Format]
54
368
  # @return [SampleBuffer]
55
- def convert(new_format)
369
+ def convert(new_format, dither: false, dither_seed: nil, resampler: :linear)
56
370
  raise InvalidParameterError, "new_format must be Core::Format" unless new_format.is_a?(Format)
57
-
58
- frames = frame_view.map do |frame|
59
- frame.map { |sample| to_normalized_float(sample, @format) }
371
+ return self if new_format == @format && !dither
372
+
373
+ resampler = normalize_resampler!(resampler)
374
+ dither_rng = dither_applicable?(new_format, dither) ? Random.new(dither_seed) : nil
375
+ if @format.channels == new_format.channels && @format.sample_rate == new_format.sample_rate
376
+ converted_samples = samples.map do |sample|
377
+ normalized = to_normalized_float(sample, @format)
378
+ from_normalized_float(normalized, new_format, dither_rng: dither_rng)
379
+ end
380
+ return self.class.new(converted_samples, new_format)
60
381
  end
61
382
 
62
- converted_frames = convert_channels(frames, new_format.channels)
63
- converted_samples = converted_frames.flatten.map do |sample|
64
- from_normalized_float(sample, new_format)
383
+ normalized_samples = samples.map { |sample| to_normalized_float(sample, @format) }
384
+ converted_samples = convert_channels_interleaved(
385
+ normalized_samples,
386
+ source_channels: @format.channels,
387
+ target_channels: new_format.channels,
388
+ source_layout: @format.channel_layout
389
+ )
390
+ converted_samples = resample_interleaved(
391
+ converted_samples,
392
+ channels: new_format.channels,
393
+ target_sample_rate: new_format.sample_rate,
394
+ resampler: resampler
395
+ )
396
+ converted_samples.map! do |sample|
397
+ from_normalized_float(sample, new_format, dither_rng: dither_rng)
65
398
  end
66
399
 
67
400
  self.class.new(converted_samples, new_format)
@@ -75,7 +408,12 @@ module Wavify
75
408
  #
76
409
  # @return [SampleBuffer]
77
410
  def reverse
78
- reversed = frame_view.reverse.flatten
411
+ reversed = []
412
+ channels = @format.channels
413
+ source_samples = samples
414
+ (source_samples.length - channels).step(0, -channels) do |sample_index|
415
+ reversed.concat(source_samples.slice(sample_index, channels))
416
+ end
79
417
  self.class.new(reversed, @format)
80
418
  end
81
419
 
@@ -92,26 +430,158 @@ module Wavify
92
430
  raise InvalidParameterError, "frame_length must be a non-negative Integer: #{frame_length.inspect}"
93
431
  end
94
432
 
95
- sliced = frame_view.slice(start_frame, frame_length) || []
96
- self.class.new(sliced.flatten, @format)
433
+ view(start_frame: start_frame, frame_length: frame_length).to_sample_buffer
97
434
  end
98
435
 
99
436
  def concat(other)
100
437
  raise InvalidParameterError, "other must be Core::SampleBuffer" unless other.is_a?(SampleBuffer)
101
438
 
102
439
  rhs = other.format == @format ? other : other.convert(@format)
103
- self.class.new(@samples + rhs.samples, @format)
440
+ self.class.new(samples + rhs.samples, @format)
104
441
  end
105
442
 
106
443
  alias + concat
107
444
 
108
445
  private
109
446
 
447
+ def normalize_storage!(storage)
448
+ value = storage.to_sym
449
+ return value if STORAGE_TYPES.include?(value)
450
+
451
+ raise InvalidParameterError, "storage must be :array or :packed"
452
+ rescue NoMethodError
453
+ raise InvalidParameterError, "storage must be Symbol/String"
454
+ end
455
+
456
+ def pack_samples(samples, format)
457
+ return samples.pack(format.bit_depth == 32 ? "e*" : "E*") if format.sample_format == :float
458
+
459
+ case format.bit_depth
460
+ when 8 then samples.pack("c*")
461
+ when 16 then samples.pack("s<*")
462
+ when 24 then pack_pcm24(samples)
463
+ when 32 then samples.pack("l<*")
464
+ end
465
+ end
466
+
467
+ def unpack_samples(bytes, format)
468
+ return bytes.unpack(format.bit_depth == 32 ? "e*" : "E*") if format.sample_format == :float
469
+
470
+ case format.bit_depth
471
+ when 8 then bytes.unpack("c*")
472
+ when 16 then bytes.unpack("s<*")
473
+ when 24 then unpack_pcm24(bytes)
474
+ when 32 then bytes.unpack("l<*")
475
+ end
476
+ end
477
+
478
+ def pack_pcm24(samples)
479
+ packed = String.new(capacity: samples.length * 3, encoding: Encoding::BINARY)
480
+ samples.each do |sample|
481
+ value = sample.negative? ? sample + 0x1000000 : sample
482
+ packed << (value & 0xFF) << ((value >> 8) & 0xFF) << ((value >> 16) & 0xFF)
483
+ end
484
+ packed
485
+ end
486
+
487
+ def unpack_pcm24(bytes)
488
+ samples = Array.new(bytes.bytesize / 3)
489
+ samples.length.times do |index|
490
+ offset = index * 3
491
+ low = bytes.getbyte(offset)
492
+ middle = bytes.getbyte(offset + 1)
493
+ high = bytes.getbyte(offset + 2)
494
+ value = low | (middle << 8) | (high << 16)
495
+ samples[index] = value.anybits?(0x800000) ? value - 0x1000000 : value
496
+ end
497
+ samples
498
+ end
499
+
500
+ def each_packed_sample
501
+ array_samples, packed_samples = storage_snapshot
502
+ return array_samples.each { |sample| yield sample } unless packed_samples
503
+
504
+ if @format.sample_format == :pcm && @format.bit_depth == 24
505
+ chunk_bytes = PACKED_ENUMERATION_CHUNK_SAMPLES * 3
506
+ 0.step(packed_samples.bytesize - 1, chunk_bytes) do |offset|
507
+ unpack_pcm24(packed_samples.byteslice(offset, chunk_bytes)).each { |sample| yield sample }
508
+ end
509
+ return self
510
+ end
511
+
512
+ directive, byte_width = packed_directive_and_width(@format)
513
+ chunk_bytes = PACKED_ENUMERATION_CHUNK_SAMPLES * byte_width
514
+ 0.step(packed_samples.bytesize - 1, chunk_bytes) do |offset|
515
+ packed_samples.byteslice(offset, chunk_bytes).unpack("#{directive}*").each { |sample| yield sample }
516
+ end
517
+ self
518
+ end
519
+
520
+ def storage_snapshot
521
+ state = @storage_mutex.synchronize { @storage_state }
522
+ [state.samples, state.packed_samples]
523
+ end
524
+
525
+ def storage_state(samples:, packed_samples:, type:)
526
+ StorageState.new(samples: samples, packed_samples: packed_samples, type: type).freeze
527
+ end
528
+
529
+ def sample_values_equal?(other)
530
+ left = each
531
+ right = other.each
532
+ @sample_count.times.all? { left.next == right.next }
533
+ end
534
+
535
+ def sample_value_hash(values, canonical_float: false)
536
+ probe_count = [values.length, HASH_PROBE_COUNT].min
537
+ return [@format, values.length].hash if probe_count.zero?
538
+
539
+ indexes = if probe_count == 1
540
+ [0]
541
+ else
542
+ Array.new(probe_count) { |index| (index * (values.length - 1)) / (probe_count - 1) }
543
+ end
544
+ probes = indexes.map { |index| values.fetch(index) }
545
+ probes.map! { |value| canonical_float_value(value) } if canonical_float
546
+ [@format, values.length, *probes].hash
547
+ end
548
+
549
+ def canonical_float_value(value)
550
+ directive = @format.bit_depth == 32 ? "e" : "E"
551
+ [value].pack(directive).unpack1(directive)
552
+ end
553
+
554
+ def packed_directive_and_width(format)
555
+ return format.bit_depth == 32 ? ["e", 4] : ["E", 8] if format.sample_format == :float
556
+
557
+ { 8 => ["c", 1], 16 => ["s<", 2], 32 => ["l<", 4] }.fetch(format.bit_depth)
558
+ end
559
+
110
560
  def validate_samples!(samples)
111
- invalid_index = samples.index { |sample| !sample.is_a?(Numeric) }
561
+ invalid_index = samples.index do |sample|
562
+ !sample.is_a?(Numeric) || !sample.real? || !sample.respond_to?(:finite?) || !sample.finite?
563
+ end
112
564
  return unless invalid_index
113
565
 
114
- raise InvalidParameterError, "sample at index #{invalid_index} must be Numeric"
566
+ raise InvalidParameterError, "sample at index #{invalid_index} must be a finite real Numeric"
567
+ end
568
+
569
+ def initialize_owned_float_samples!(samples, format)
570
+ unless format.is_a?(Format) && format.sample_format == :float
571
+ raise InvalidParameterError, "format must be a floating-point Core::Format"
572
+ end
573
+ raise InvalidParameterError, "samples must be an Array" unless samples.is_a?(Array)
574
+
575
+ validate_samples!(samples)
576
+ validate_interleaving!(samples.length, format.channels)
577
+ samples.map!(&:to_f)
578
+
579
+ @format = format
580
+ @sample_count = samples.length
581
+ @value_hash = sample_value_hash(samples)
582
+ @storage_mutex = Mutex.new
583
+ @storage_state = storage_state(samples: samples.freeze, packed_samples: nil, type: :array)
584
+ @duration = Duration.from_samples(sample_frame_count, format.sample_rate)
115
585
  end
116
586
 
117
587
  def validate_interleaving!(sample_count, channels)
@@ -124,76 +594,181 @@ module Wavify
124
594
  def coerce_samples(samples, format)
125
595
  samples.map do |sample|
126
596
  if format.sample_format == :float
127
- sample.to_f.clamp(-1.0, 1.0)
597
+ sample.to_f
128
598
  else
129
- coerce_pcm_sample(sample, format.bit_depth)
599
+ coerce_pcm_sample(sample, format)
130
600
  end
131
601
  end
132
602
  end
133
603
 
134
- def coerce_pcm_sample(sample, bit_depth)
604
+ def coerce_pcm_sample(sample, format)
135
605
  if sample.is_a?(Float) && sample.between?(-1.0, 1.0)
136
- float_to_pcm(sample, bit_depth)
606
+ float_to_pcm(sample, format)
137
607
  else
138
- min = -(2**(bit_depth - 1))
139
- max = (2**(bit_depth - 1)) - 1
140
- sample.to_i.clamp(min, max)
608
+ shift = format.bit_depth - format.valid_bits
609
+ min = -(2**(format.valid_bits - 1)) << shift
610
+ max = ((2**(format.valid_bits - 1)) - 1) << shift
611
+ (sample.to_i.clamp(min, max) >> shift) << shift
141
612
  end
142
613
  end
143
614
 
144
- def frame_view
145
- @samples.each_slice(@format.channels).map(&:dup)
146
- end
147
-
148
615
  def to_normalized_float(sample, format)
149
- return sample.to_f.clamp(-1.0, 1.0) if format.sample_format == :float
616
+ return sample.to_f if format.sample_format == :float
150
617
 
151
- max = ((2**(format.bit_depth - 1)) - 1).to_f
152
- (sample.to_f / max).clamp(-1.0, 1.0)
618
+ shift = format.bit_depth - format.valid_bits
619
+ positive_scale = (((2**(format.valid_bits - 1)) - 1) << shift).to_f
620
+ negative_scale = ((2**(format.valid_bits - 1)) << shift).to_f
621
+ scale = sample.negative? ? negative_scale : positive_scale
622
+ (sample.to_f / scale).clamp(-1.0, 1.0)
153
623
  end
154
624
 
155
- def from_normalized_float(sample, format)
156
- value = sample.to_f.clamp(-1.0, 1.0)
625
+ def from_normalized_float(sample, format, dither_rng: nil)
626
+ value = sample.to_f
157
627
  return value if format.sample_format == :float
158
628
 
159
- float_to_pcm(value, format.bit_depth)
629
+ value = value.clamp(-1.0, 1.0)
630
+ value = apply_tpdf_dither(value, format, dither_rng) if dither_rng
631
+ float_to_pcm(value, format)
160
632
  end
161
633
 
162
- def float_to_pcm(sample, bit_depth)
163
- max = (2**(bit_depth - 1)) - 1
164
- min = -(2**(bit_depth - 1))
165
- (sample * max).round.clamp(min, max)
634
+ def apply_tpdf_dither(value, format, rng)
635
+ max = ((2**(format.valid_bits - 1)) - 1).to_f
636
+ (value + ((rng.rand - rng.rand) / max)).clamp(-1.0, 1.0)
166
637
  end
167
638
 
168
- def convert_channels(frames, target_channels)
169
- return frames if frames.empty?
639
+ def dither_applicable?(new_format, requested)
640
+ return false unless requested && new_format.sample_format == :pcm
641
+ return true if @format.sample_format == :float
170
642
 
171
- source_channels = frames.first.length
172
- return frames if source_channels == target_channels
643
+ @format.valid_bits > new_format.valid_bits
644
+ end
173
645
 
174
- return frames.map { |frame| [frame.sum / frame.length.to_f] } if target_channels == 1
646
+ def float_to_pcm(sample, format)
647
+ max = (2**(format.valid_bits - 1)) - 1
648
+ min = -(2**(format.valid_bits - 1))
649
+ scale = sample.negative? ? -min : max
650
+ ((sample * scale).round.clamp(min, max)) << (format.bit_depth - format.valid_bits)
651
+ end
652
+
653
+ def convert_channels_interleaved(samples, source_channels:, target_channels:, source_layout: nil)
654
+ return samples if samples.empty? || source_channels == target_channels
655
+
656
+ output = []
657
+ samples.each_slice(source_channels) do |frame|
658
+ converted = if target_channels == 1
659
+ [frame.sum / frame.length.to_f]
660
+ elsif source_channels == 1
661
+ Array.new(target_channels, frame.first)
662
+ elsif source_channels > 2 && target_channels == 2
663
+ downmix_to_stereo(frame, source_layout)
664
+ elsif source_channels > target_channels
665
+ truncate_and_fold(frame, target_channels)
666
+ else
667
+ upmix_with_duplication(frame, target_channels)
668
+ end
669
+ output.concat(converted)
670
+ end
671
+ output
672
+ end
673
+
674
+ def normalize_resampler!(resampler)
675
+ value = resampler.to_sym
676
+ return value if %i[linear windowed_sinc].include?(value)
175
677
 
176
- return frames.map { |frame| Array.new(target_channels, frame.first) } if source_channels == 1
678
+ raise InvalidParameterError, "resampler must be :linear or :windowed_sinc"
679
+ rescue NoMethodError
680
+ raise InvalidParameterError, "resampler must be Symbol/String"
681
+ end
177
682
 
178
- return frames.map { |frame| downmix_to_stereo(frame) } if source_channels > 2 && target_channels == 2
683
+ def resample_interleaved(samples, channels:, target_sample_rate:, resampler:)
684
+ return samples if samples.empty? || @format.sample_rate == target_sample_rate
685
+
686
+ source_frame_count = samples.length / channels
687
+ target_frame_count = resampled_frame_count(source_frame_count, target_sample_rate)
688
+ return [] if target_frame_count.zero?
689
+
690
+ output = Array.new(target_frame_count * channels)
691
+ target_frame_count.times do |target_index|
692
+ source_position = (target_index * @format.sample_rate.to_f) / target_sample_rate
693
+ if resampler == :windowed_sinc
694
+ channels.times do |channel|
695
+ output[(target_index * channels) + channel] = windowed_sinc_sample(
696
+ samples,
697
+ source_position,
698
+ channel,
699
+ channels,
700
+ source_frame_count,
701
+ target_sample_rate
702
+ )
703
+ end
704
+ next
705
+ end
179
706
 
180
- return frames.map { |frame| truncate_and_fold(frame, target_channels) } if source_channels > target_channels
707
+ lower_index = source_position.floor
708
+ upper_index = [lower_index + 1, source_frame_count - 1].min
709
+ fraction = source_position - lower_index
181
710
 
182
- frames.map { |frame| upmix_with_duplication(frame, target_channels) }
711
+ channels.times do |channel|
712
+ lower = samples.fetch((lower_index * channels) + channel)
713
+ upper = samples.fetch((upper_index * channels) + channel)
714
+ output[(target_index * channels) + channel] = lower + ((upper - lower) * fraction)
715
+ end
716
+ end
717
+ output
183
718
  end
184
719
 
185
- def downmix_to_stereo(frame)
186
- left = frame[0] || 0.0
187
- right = frame[1] || left
188
- center = frame[2] || 0.0
189
- lfe = frame[3] || 0.0
190
- left_surround = frame[4] || 0.0
191
- right_surround = frame[5] || 0.0
192
- extras = (frame[6..] || []).sum
720
+ def windowed_sinc_sample(samples, source_position, channel, channels, source_frame_count, target_sample_rate)
721
+ cutoff = [1.0, target_sample_rate.to_f / @format.sample_rate].min
722
+ radius = (8 / cutoff).ceil.clamp(8, 64)
723
+ center = source_position.floor
724
+ start_index = [center - radius + 1, 0].max
725
+ end_index = [center + radius, source_frame_count - 1].min
726
+ weighted_sum = 0.0
727
+ weight_sum = 0.0
728
+ (start_index..end_index).each do |source_index|
729
+ distance = source_position - source_index
730
+ weight = cutoff * sinc(distance * cutoff) * hann_window(distance, radius)
731
+ weighted_sum += samples.fetch((source_index * channels) + channel) * weight
732
+ weight_sum += weight
733
+ end
734
+ return weighted_sum / weight_sum unless weight_sum.zero?
735
+
736
+ samples.fetch((center.clamp(0, source_frame_count - 1) * channels) + channel)
737
+ end
738
+
739
+ def sinc(value)
740
+ return 1.0 if value.abs < 1e-12
741
+
742
+ x = Math::PI * value
743
+ Math.sin(x) / x
744
+ end
745
+
746
+ def hann_window(distance, radius)
747
+ normalized = distance.abs / radius.to_f
748
+ return 0.0 if normalized > 1.0
749
+
750
+ 0.5 + (0.5 * Math.cos(Math::PI * normalized))
751
+ end
752
+
753
+ def resampled_frame_count(source_frame_count, target_sample_rate)
754
+ ((source_frame_count * target_sample_rate.to_f) / @format.sample_rate).round
755
+ end
756
+
757
+ def downmix_to_stereo(frame, source_layout)
758
+ positions = source_layout || default_channel_layout(frame.length)
759
+ by_position = positions.zip(frame).to_h
760
+ left = by_position.fetch(:front_left, frame[0] || 0.0)
761
+ right = by_position.fetch(:front_right, frame[1] || left)
762
+ center = by_position.fetch(:front_center, 0.0)
763
+ lfe = by_position.fetch(:low_frequency, 0.0)
764
+ left_surround = by_position.fetch(:side_left, by_position.fetch(:back_left, 0.0))
765
+ right_surround = by_position.fetch(:side_right, by_position.fetch(:back_right, 0.0))
766
+ known = %i[front_left front_right front_center low_frequency side_left side_right back_left back_right]
767
+ extras = by_position.reject { |position, _| known.include?(position) }.values.sum
193
768
 
194
769
  left_mix = left + (center * 0.707) + (lfe * 0.5) + (left_surround * 0.707) + (extras * 0.5)
195
770
  right_mix = right + (center * 0.707) + (lfe * 0.5) + (right_surround * 0.707) + (extras * 0.5)
196
- [left_mix.clamp(-1.0, 1.0), right_mix.clamp(-1.0, 1.0)]
771
+ [left_mix, right_mix]
197
772
  end
198
773
 
199
774
  def truncate_and_fold(frame, target_channels)
@@ -202,7 +777,20 @@ module Wavify
202
777
  return reduced if extras.empty?
203
778
 
204
779
  extra_mix = extras.sum / target_channels.to_f
205
- reduced.map { |sample| (sample + extra_mix).clamp(-1.0, 1.0) }
780
+ reduced.map { |sample| sample + extra_mix }
781
+ end
782
+
783
+ def default_channel_layout(channels)
784
+ {
785
+ 1 => %i[front_center],
786
+ 2 => %i[front_left front_right],
787
+ 3 => %i[front_left front_right front_center],
788
+ 4 => %i[front_left front_right back_left back_right],
789
+ 5 => %i[front_left front_right front_center back_left back_right],
790
+ 6 => %i[front_left front_right front_center low_frequency side_left side_right],
791
+ 7 => %i[front_left front_right front_center low_frequency back_center side_left side_right],
792
+ 8 => %i[front_left front_right front_center low_frequency back_left back_right side_left side_right]
793
+ }.fetch(channels) { Array.new(channels) { |index| :"channel_#{index}" } }
206
794
  end
207
795
 
208
796
  def upmix_with_duplication(frame, target_channels)