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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "stringio"
4
- require "vorbis"
4
+ require "tempfile"
5
5
 
6
6
  module Wavify
7
7
  module Codecs
@@ -15,6 +15,22 @@ module Wavify
15
15
  # resampled to the first logical stream sample rate when rates differ).
16
16
  # Interleaved multi-stream OGG logical streams are mixed with clipping.
17
17
  class OggVorbis < Base
18
+ DEPENDENCY_LOAD_ERRORS = begin
19
+ errors = {}
20
+ begin
21
+ require "ogg"
22
+ rescue LoadError => e
23
+ errors["ogg-ruby"] = e
24
+ end
25
+
26
+ begin
27
+ require "vorbis"
28
+ rescue LoadError => e
29
+ errors["vorbis"] = e
30
+ end
31
+ errors.freeze
32
+ end
33
+
18
34
  # Recognized filename extensions.
19
35
  EXTENSIONS = %w[.ogg .oga].freeze
20
36
 
@@ -24,17 +40,44 @@ module Wavify
24
40
  SETUP_HEADER_TYPE = 0x05 # :nodoc:
25
41
  GRANULE_POSITION_UNKNOWN = 0xFFFF_FFFF_FFFF_FFFF # :nodoc:
26
42
  VORBIS_ENCODE_DEFAULT_QUALITY = 0.4 # :nodoc:
43
+ VORBIS_ENCODE_QUALITY_RANGE = (-0.1..1.0) # :nodoc:
27
44
 
28
45
  class << self
46
+ # @return [Boolean]
47
+ def available?
48
+ DEPENDENCY_LOAD_ERRORS.empty?
49
+ end
50
+
51
+ def runtime_diagnostics
52
+ return { available: false, compatible: false, missing: DEPENDENCY_LOAD_ERRORS.keys.freeze } unless available?
53
+
54
+ required_native_methods = %i[
55
+ vorbis_info_init vorbis_info_clear vorbis_comment_init vorbis_comment_clear
56
+ vorbis_synthesis_headerin vorbis_synthesis_init vorbis_block_init
57
+ vorbis_synthesis vorbis_synthesis_blockin vorbis_synthesis_pcmout vorbis_synthesis_read
58
+ ]
59
+ missing_methods = required_native_methods.reject { |method| Vorbis::Native.respond_to?(method) }
60
+ {
61
+ available: true,
62
+ compatible: missing_methods.empty?,
63
+ missing_native_methods: missing_methods.freeze,
64
+ versions: {
65
+ "ogg-ruby" => Gem.loaded_specs["ogg-ruby"]&.version&.to_s,
66
+ "vorbis" => Gem.loaded_specs["vorbis"]&.version&.to_s
67
+ }.freeze
68
+ }.freeze
69
+ end
70
+
29
71
  # @param io_or_path [String, IO]
30
72
  # @return [Boolean]
31
73
  def can_read?(io_or_path)
32
74
  return true if io_or_path.is_a?(String) && EXTENSIONS.include?(File.extname(io_or_path).downcase)
33
75
  return false unless io_or_path.respond_to?(:read)
34
76
 
35
- magic = io_or_path.read(4)
36
- io_or_path.rewind if io_or_path.respond_to?(:rewind)
77
+ magic = probe_bytes(io_or_path, 4)
37
78
  magic == "OggS"
79
+ rescue StreamError
80
+ false
38
81
  end
39
82
 
40
83
  # Reads OGG Vorbis audio.
@@ -43,13 +86,15 @@ module Wavify
43
86
  # OGG logical streams are concatenated and normalized to the first
44
87
  # logical stream format (including resampling). Interleaved
45
88
  # multi-stream OGG logical streams are mixed.
46
- # `decode_mode:` is accepted for API compatibility but has no
47
- # effect; libvorbis always performs full decode.
48
- def read(io_or_path, format: nil, decode_mode: :strict)
49
- raise InvalidParameterError, "decode_mode must be :strict or :placeholder, got #{decode_mode.inspect}" unless %i[strict
50
- placeholder].include?(decode_mode)
51
-
52
- if (chained_decoded = decode_chained_vorbis_read_if_needed(io_or_path, decode_mode: decode_mode, target_format: format))
89
+ def read(io_or_path, format: nil, mix_strategy: :clip)
90
+ ensure_available!
91
+ mix_strategy = validate_vorbis_mix_strategy!(mix_strategy)
92
+
93
+ if (chained_decoded = decode_chained_vorbis_read_if_needed(
94
+ io_or_path,
95
+ target_format: format,
96
+ mix_strategy: mix_strategy
97
+ ))
53
98
  return chained_decoded
54
99
  end
55
100
 
@@ -61,10 +106,12 @@ module Wavify
61
106
  end
62
107
 
63
108
  # Writes OGG Vorbis audio.
64
- def write(io_or_path, sample_buffer, format:)
109
+ def write(io_or_path, sample_buffer, format:, quality: VORBIS_ENCODE_DEFAULT_QUALITY, **codec_options)
110
+ ensure_available!
111
+ validate_no_codec_options!(codec_options, operation: "OGG Vorbis write")
65
112
  raise InvalidParameterError, "sample_buffer must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
66
113
 
67
- stream_write(io_or_path, format: format) do |writer|
114
+ stream_write(io_or_path, format: format, quality: quality) do |writer|
68
115
  writer.call(sample_buffer)
69
116
  end
70
117
  end
@@ -75,14 +122,22 @@ module Wavify
75
122
  # OGG logical streams are concatenated and normalized to the first
76
123
  # logical stream format during streaming (including resampling).
77
124
  # Interleaved multi-stream OGG logical streams are mixed.
78
- # `decode_mode:` is accepted for API compatibility but has no effect.
79
- def stream_read(io_or_path, chunk_size: 4096, decode_mode: :strict, &block)
80
- return enum_for(__method__, io_or_path, chunk_size: chunk_size, decode_mode: decode_mode) unless block_given?
125
+ def stream_read(io_or_path, chunk_size: 4096, mix_strategy: :clip, &block)
126
+ unless block_given?
127
+ return enum_for(__method__, io_or_path, chunk_size: chunk_size, mix_strategy: mix_strategy)
128
+ end
129
+ ensure_available!
81
130
  raise InvalidParameterError, "chunk_size must be a positive Integer" unless chunk_size.is_a?(Integer) && chunk_size.positive?
82
- raise InvalidParameterError, "decode_mode must be :strict or :placeholder, got #{decode_mode.inspect}" unless %i[strict
83
- placeholder].include?(decode_mode)
131
+ mix_strategy = validate_vorbis_mix_strategy!(mix_strategy)
84
132
 
85
- return nil if stream_chained_vorbis_if_needed(io_or_path, chunk_size: chunk_size, decode_mode: decode_mode, &block)
133
+ if multistream_ogg_prefix?(io_or_path)
134
+ return nil if stream_chained_vorbis_if_needed(
135
+ io_or_path,
136
+ chunk_size: chunk_size,
137
+ mix_strategy: mix_strategy,
138
+ &block
139
+ )
140
+ end
86
141
 
87
142
  decode_context = build_vorbis_decode_context(io_or_path)
88
143
  run_vorbis_decode_pipeline(decode_context, streaming: true, chunk_size: chunk_size, &block)
@@ -92,8 +147,13 @@ module Wavify
92
147
  #
93
148
  # @note Encodes using libvorbis at the default VBR quality level.
94
149
  # Accepts any channel count and sample rate supported by libvorbis.
95
- def stream_write(io_or_path, format:)
96
- return enum_for(__method__, io_or_path, format: format) unless block_given?
150
+ def stream_write(io_or_path, format:, quality: VORBIS_ENCODE_DEFAULT_QUALITY, **codec_options)
151
+ ensure_available!
152
+ validate_no_codec_options!(codec_options, operation: "OGG Vorbis stream_write")
153
+ encode_quality = normalize_vorbis_quality(quality)
154
+ unless block_given?
155
+ return enum_for(__method__, io_or_path, format: format, quality: quality, **codec_options)
156
+ end
97
157
  raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
98
158
  raise InvalidParameterError, "Vorbis encode requires positive channel count" unless format.channels.to_i.positive?
99
159
  raise InvalidParameterError, "Vorbis encode requires positive sample_rate" unless format.sample_rate.to_i.positive?
@@ -106,16 +166,16 @@ module Wavify
106
166
  )
107
167
 
108
168
  io, close_io = open_output(io_or_path)
109
- io.rewind if io.respond_to?(:rewind)
110
- io.truncate(0) if io.respond_to?(:truncate)
169
+ prepare_output!(io, owned: close_io)
111
170
 
112
171
  encoder = Vorbis::Encoder.new(
113
172
  channels: target_format.channels,
114
173
  rate: target_format.sample_rate,
115
- quality: VORBIS_ENCODE_DEFAULT_QUALITY
174
+ quality: encode_quality
116
175
  )
117
176
 
118
- encoder.write_headers { |page_bytes| io.write(page_bytes) }
177
+ encoder.write_headers { |page_bytes| write_all(io, page_bytes) }
178
+ channels_data = Array.new(target_format.channels) { [] }
119
179
 
120
180
  writer = lambda do |chunk|
121
181
  raise InvalidParameterError, "stream chunk must be Core::SampleBuffer" unless chunk.is_a?(Core::SampleBuffer)
@@ -123,19 +183,18 @@ module Wavify
123
183
  buffer = chunk.format == target_format ? chunk : chunk.convert(target_format)
124
184
  next nil if buffer.sample_frame_count.zero?
125
185
 
126
- channels_data = Array.new(target_format.channels) { [] }
127
- buffer.samples.each_slice(target_format.channels) do |frame|
128
- frame.each_with_index { |sample, ch| channels_data[ch] << sample.to_f }
186
+ channels_data.each(&:clear)
187
+ buffer.samples.each_with_index do |sample, index|
188
+ channels_data[index % target_format.channels] << sample.to_f
129
189
  end
130
- encoder.encode(channels_data) { |page_bytes| io.write(page_bytes) }
190
+ encoder.encode(channels_data) { |page_bytes| write_all(io, page_bytes) }
131
191
  end
132
192
 
133
193
  yield writer
134
194
 
135
- encoder.finish { |page_bytes| io.write(page_bytes) }
195
+ encoder.finish { |page_bytes| write_all(io, page_bytes) }
136
196
  encoder.close
137
- io.flush if io.respond_to?(:flush)
138
- io.rewind if io.respond_to?(:rewind)
197
+ finalize_output!(io, owned: close_io)
139
198
  io_or_path
140
199
  rescue StandardError
141
200
  begin
@@ -153,13 +212,13 @@ module Wavify
153
212
  # @param io_or_path [String, IO]
154
213
  # @return [Hash]
155
214
  def metadata(io_or_path)
215
+ ensure_available!
156
216
  io, close_io = open_input(io_or_path)
157
- ensure_seekable!(io)
158
- chained_streams, physical_ogg_info = read_ogg_logical_stream_chains(io)
217
+ chained_streams, physical_ogg_info = read_ogg_logical_stream_chains(io, storage: :tempfile)
159
218
 
160
219
  if chained_streams.length > 1
161
220
  chain_metadatas = chained_streams.map do |stream|
162
- parse_single_logical_stream_metadata(StringIO.new(stream.fetch(:bytes)))
221
+ parse_single_logical_stream_metadata(logical_stream_io(stream))
163
222
  end
164
223
  if physical_ogg_info[:interleaved_multistream]
165
224
  return merge_interleaved_vorbis_metadata(chain_metadatas, chained_streams, physical_ogg_info)
@@ -168,29 +227,48 @@ module Wavify
168
227
  return merge_chained_vorbis_metadata(chain_metadatas, chained_streams)
169
228
  end
170
229
 
171
- parse_single_logical_stream_metadata(StringIO.new(chained_streams.fetch(0).fetch(:bytes)))
230
+ parse_single_logical_stream_metadata(logical_stream_io(chained_streams.fetch(0)))
172
231
  ensure
232
+ close_logical_streams(chained_streams)
173
233
  io.close if close_io && io
174
234
  end
175
235
 
176
236
  private
177
237
 
238
+ def ensure_available!
239
+ return if available?
240
+
241
+ missing = DEPENDENCY_LOAD_ERRORS.keys.join(", ")
242
+ raise UnsupportedFormatError,
243
+ "OGG Vorbis support requires optional gems: #{missing}. Add them to your Gemfile to read or write .ogg/.oga files."
244
+ end
245
+
246
+ def normalize_vorbis_quality(quality)
247
+ unless quality.is_a?(Numeric) && VORBIS_ENCODE_QUALITY_RANGE.cover?(quality.to_f)
248
+ raise InvalidParameterError, "quality must be Numeric in -0.1..1.0"
249
+ end
250
+
251
+ quality.to_f
252
+ end
253
+
178
254
  # ---------------------------------------------------------------------------
179
255
  # OGG container reading (using ogg-ruby)
180
256
  # ---------------------------------------------------------------------------
181
257
 
182
- def read_ogg_logical_stream_chains_from_input(io_or_path, with_info: false)
258
+ def read_ogg_logical_stream_chains_from_input(io_or_path, with_info: false, storage: :memory)
183
259
  io, close_io = open_input(io_or_path)
184
- ensure_seekable!(io)
185
260
 
186
- chains, physical_info = read_ogg_logical_stream_chains(io)
261
+ chains, physical_info = read_ogg_logical_stream_chains(io, storage: storage)
187
262
  with_info ? [chains, physical_info] : chains
188
263
  ensure
189
264
  io.close if close_io && io
190
265
  end
191
266
 
192
- def read_ogg_logical_stream_chains(io)
193
- io.rewind
267
+ def read_ogg_logical_stream_chains(io, storage: :memory)
268
+ unless %i[memory tempfile].include?(storage)
269
+ raise InvalidParameterError, "OGG logical stream storage must be :memory or :tempfile"
270
+ end
271
+
194
272
  sync = Ogg::SyncState.new
195
273
  streams_by_serial = {}
196
274
  total_page_count = 0
@@ -217,7 +295,7 @@ module Wavify
217
295
 
218
296
  stream = {
219
297
  serial_number: sn,
220
- bytes: +"",
298
+ storage: build_logical_stream_storage(storage),
221
299
  page_count: 0,
222
300
  bos_page_count: 0,
223
301
  eos_page_count: 0,
@@ -225,7 +303,7 @@ module Wavify
225
303
  eos_seen: false,
226
304
  first_physical_page_index: physical_page_index,
227
305
  last_physical_page_index: physical_page_index,
228
- physical_page_indices: []
306
+ physical_page_indices: storage == :memory ? [] : nil
229
307
  }
230
308
  streams_by_serial[sn] = stream
231
309
  elsif stream[:eos_seen]
@@ -234,14 +312,14 @@ module Wavify
234
312
 
235
313
  raise InvalidFormatError, "unexpected BOS page in OGG logical stream" if stream[:page_count].positive? && bos
236
314
 
237
- stream[:bytes] << page.to_s
315
+ append_logical_stream_page(stream.fetch(:storage), page.to_s)
238
316
  stream[:page_count] += 1
239
317
  stream[:bos_page_count] += 1 if bos
240
318
  stream[:eos_page_count] += 1 if eos
241
319
  stream[:continued_page_count] += 1 if continued
242
320
  stream[:eos_seen] = true if eos
243
321
  stream[:last_physical_page_index] = physical_page_index
244
- stream[:physical_page_indices] << physical_page_index
322
+ stream[:physical_page_indices]&.push(physical_page_index)
245
323
 
246
324
  total_page_count += 1
247
325
  total_bos_page_count += 1 if bos
@@ -275,12 +353,20 @@ module Wavify
275
353
  end
276
354
 
277
355
  result_streams = streams.map do |stream|
278
- stream.merge(
279
- bytes: stream[:bytes].dup.freeze,
280
- interleaved_pages: per_stream_overlap.fetch(stream[:serial_number], false)
281
- ).freeze
356
+ finalize_logical_stream_storage!(stream.fetch(:storage))
357
+ result = stream.reject { |key, _value| key == :storage }.merge(
358
+ interleaved_pages: per_stream_overlap.fetch(stream[:serial_number], false),
359
+ physical_page_indices: (stream[:physical_page_indices] || []).freeze
360
+ )
361
+ if storage == :memory
362
+ result[:bytes] = stream.fetch(:storage).dup.freeze
363
+ else
364
+ result[:io] = stream.fetch(:storage)
365
+ end
366
+ result.freeze
282
367
  end
283
368
 
369
+ completed = true
284
370
  [
285
371
  result_streams,
286
372
  {
@@ -299,6 +385,49 @@ module Wavify
299
385
  raise InvalidFormatError, "OGG stream sequence error: #{e.message}"
300
386
  ensure
301
387
  sync&.clear
388
+ unless completed
389
+ close_logical_streams(streams_by_serial&.values&.map { |stream| { io: stream[:storage] } })
390
+ end
391
+ end
392
+
393
+ def build_logical_stream_storage(storage)
394
+ return +"".b if storage == :memory
395
+
396
+ Tempfile.new(["wavify-ogg-logical-stream", ".ogg"]).tap(&:binmode)
397
+ end
398
+
399
+ def append_logical_stream_page(storage, bytes)
400
+ if storage.is_a?(String)
401
+ storage << bytes
402
+ else
403
+ write_all(storage, bytes)
404
+ end
405
+ end
406
+
407
+ def finalize_logical_stream_storage!(storage)
408
+ return if storage.is_a?(String)
409
+
410
+ storage.flush
411
+ storage.rewind
412
+ end
413
+
414
+ def logical_stream_io(stream)
415
+ return StringIO.new(stream.fetch(:bytes)) if stream.key?(:bytes)
416
+
417
+ io = stream.fetch(:io)
418
+ io.rewind
419
+ io
420
+ end
421
+
422
+ def close_logical_streams(streams)
423
+ Array(streams).each do |stream|
424
+ io = stream[:io]
425
+ next unless io.respond_to?(:close!)
426
+
427
+ io.close!
428
+ rescue IOError, SystemCallError
429
+ nil
430
+ end
302
431
  end
303
432
 
304
433
  # Reads OGG packets from a single logical stream IO using ogg-ruby.
@@ -307,7 +436,6 @@ module Wavify
307
436
  # :data, :kind, :granule_position keys, and ogg_info is a Hash with
308
437
  # page-level statistics.
309
438
  def read_ogg_packets(io)
310
- io.rewind
311
439
  sync = Ogg::SyncState.new
312
440
  stream_state = nil
313
441
  serial_number = nil
@@ -378,15 +506,125 @@ module Wavify
378
506
  sync&.clear
379
507
  end
380
508
 
509
+ def build_ogg_packet_reader(io)
510
+ {
511
+ io: io,
512
+ sync: Ogg::SyncState.new,
513
+ stream_state: nil,
514
+ serial_number: nil,
515
+ page_count: 0,
516
+ bos_page_count: 0,
517
+ eos_page_count: 0,
518
+ continued_page_count: 0,
519
+ max_granule_position: nil,
520
+ input_eof: false,
521
+ closed: false
522
+ }
523
+ end
524
+
525
+ def next_ogg_packet!(reader)
526
+ loop do
527
+ if (packet = reader[:stream_state]&.packetout)
528
+ granule_position = packet.granulepos == -1 ? nil : packet.granulepos
529
+ if granule_position
530
+ reader[:max_granule_position] = [reader[:max_granule_position] || 0, granule_position].max
531
+ end
532
+ return {
533
+ data: packet.data,
534
+ bos: packet.bos?,
535
+ eos: packet.eos?,
536
+ packetno: packet.packetno,
537
+ kind: classify_vorbis_packet(packet.data),
538
+ granule_position: granule_position
539
+ }
540
+ end
541
+
542
+ if (page = reader.fetch(:sync).pageout)
543
+ serial_number = page.serialno
544
+ if reader[:serial_number] && serial_number != reader[:serial_number]
545
+ raise UnsupportedFormatError, "multiple OGG logical streams require the multistream decoder"
546
+ end
547
+ if reader[:page_count].zero? && !page.bos?
548
+ raise InvalidFormatError, "first OGG page must have BOS flag"
549
+ end
550
+
551
+ reader[:serial_number] ||= serial_number
552
+ reader[:stream_state] ||= Ogg::StreamState.new(serial_number)
553
+ reader.fetch(:stream_state).pagein(page)
554
+ reader[:page_count] += 1
555
+ reader[:bos_page_count] += 1 if page.bos?
556
+ reader[:eos_page_count] += 1 if page.eos?
557
+ reader[:continued_page_count] += 1 if page.continued?
558
+ next
559
+ end
560
+
561
+ return nil if reader[:input_eof]
562
+
563
+ data = reader.fetch(:io).read(4096)
564
+ if data.nil? || data.empty?
565
+ reader[:input_eof] = true
566
+ else
567
+ reader.fetch(:sync).write(data)
568
+ end
569
+ end
570
+ rescue Ogg::CorruptDataError, Ogg::SyncCorruptDataError => e
571
+ raise InvalidFormatError, "OGG data corrupt or invalid checksum: #{e.message}"
572
+ rescue Ogg::StreamCorruptDataError => e
573
+ raise InvalidFormatError, "OGG stream sequence error: #{e.message}"
574
+ end
575
+
576
+ def close_ogg_packet_reader(reader)
577
+ return unless reader && !reader[:closed]
578
+
579
+ reader[:stream_state]&.clear
580
+ reader[:sync]&.clear
581
+ reader[:closed] = true
582
+ end
583
+
584
+ def multistream_ogg_prefix?(io_or_path, probe_size: 4096, tail_probe_size: 65_536)
585
+ io, close_io = open_input(io_or_path)
586
+ ensure_seekable!(io)
587
+ original_position = io.pos
588
+ bytes = +"".b
589
+ while bytes.bytesize < probe_size
590
+ chunk = io.read(probe_size - bytes.bytesize)
591
+ break if chunk.nil? || chunk.empty?
592
+
593
+ bytes << chunk
594
+ end
595
+ io.seek(0, IO::SEEK_END)
596
+ input_end = io.pos
597
+ tail_start = [original_position + bytes.bytesize, input_end - tail_probe_size].max
598
+ if tail_start < input_end
599
+ io.seek(tail_start, IO::SEEK_SET)
600
+ tail = io.read(tail_probe_size)
601
+ bytes << tail if tail
602
+ end
603
+
604
+ sync = Ogg::SyncState.new
605
+ sync.write(bytes)
606
+ serial_numbers = []
607
+ while (page = sync.pageout)
608
+ serial_numbers << page.serialno
609
+ return true if serial_numbers.uniq.length > 1
610
+ end
611
+ false
612
+ rescue Ogg::CorruptDataError, Ogg::SyncCorruptDataError
613
+ false
614
+ ensure
615
+ sync&.clear
616
+ io.seek(original_position, IO::SEEK_SET) if original_position && io
617
+ io.close if close_io && io
618
+ end
619
+
381
620
  # ---------------------------------------------------------------------------
382
621
  # Vorbis decode (using Vorbis::Native synthesis functions)
383
622
  # ---------------------------------------------------------------------------
384
623
 
385
624
  def build_vorbis_decode_context(io_or_path)
386
625
  io, close_io = open_input(io_or_path)
387
- ensure_seekable!(io)
388
-
389
- packet_entries, ogg_info = read_ogg_packets(io)
626
+ packet_reader = build_ogg_packet_reader(io)
627
+ packet_entries = Array.new(3) { next_ogg_packet!(packet_reader) }
390
628
  raise InvalidFormatError, "missing Vorbis identification header" if packet_entries[0].nil?
391
629
  raise InvalidFormatError, "missing Vorbis comment header" if packet_entries[1].nil?
392
630
  raise InvalidFormatError, "missing Vorbis setup header" if packet_entries[2].nil?
@@ -394,7 +632,9 @@ module Wavify
394
632
  info_ptr = FFI::MemoryPointer.new(Vorbis::Native::VorbisInfo.size)
395
633
  comment_ptr = FFI::MemoryPointer.new(Vorbis::Native::VorbisComment.size)
396
634
  Vorbis::Native.vorbis_info_init(info_ptr)
635
+ info_initialized = true
397
636
  Vorbis::Native.vorbis_comment_init(comment_ptr)
637
+ comment_initialized = true
398
638
 
399
639
  packet_entries.first(3).each_with_index do |entry, idx|
400
640
  pkt = Ogg::Packet.new(
@@ -411,30 +651,34 @@ module Wavify
411
651
  channels = vinfo[:channels]
412
652
  sample_rate = vinfo[:rate]
413
653
 
414
- audio_packets = packet_entries.drop(3).select { |e| e.fetch(:kind) == :audio }
415
- raise InvalidFormatError, "OGG Vorbis stream does not contain audio packets" if audio_packets.empty?
416
-
417
654
  format = Core::Format.new(channels: channels, sample_rate: sample_rate, bit_depth: 32, sample_format: :float)
418
655
 
419
- {
656
+ context = {
420
657
  format: format,
421
658
  channels: channels,
422
659
  sample_rate: sample_rate,
423
- audio_packets: audio_packets,
424
- sample_frame_count: ogg_info[:max_granule_position],
660
+ packet_reader: packet_reader,
661
+ io: io,
662
+ close_io: close_io,
425
663
  info_ptr: info_ptr,
426
664
  comment_ptr: comment_ptr
427
665
  }
666
+ ownership_transferred = true
667
+ context
428
668
  ensure
429
- io.close if close_io && io
669
+ unless ownership_transferred
670
+ Vorbis::Native.vorbis_comment_clear(comment_ptr) if comment_initialized
671
+ Vorbis::Native.vorbis_info_clear(info_ptr) if info_initialized
672
+ close_ogg_packet_reader(packet_reader)
673
+ io.close if close_io && io
674
+ end
430
675
  end
431
676
 
432
677
  def run_vorbis_decode_pipeline(decode_context, streaming: false, chunk_size: nil, &block)
433
678
  info_ptr = decode_context.fetch(:info_ptr)
434
679
  comment_ptr = decode_context.fetch(:comment_ptr)
435
- audio_packets = decode_context.fetch(:audio_packets)
680
+ packet_reader = decode_context.fetch(:packet_reader)
436
681
  channels = decode_context.fetch(:channels)
437
- max_granule = decode_context.fetch(:sample_frame_count)
438
682
  format = decode_context.fetch(:format)
439
683
 
440
684
  dsp_ptr = FFI::MemoryPointer.new(Vorbis::Native::VorbisDspState.size)
@@ -453,10 +697,16 @@ module Wavify
453
697
 
454
698
  block_initialized = true
455
699
 
456
- all_samples = []
700
+ samples = []
701
+ emitted_sample_count = 0
702
+ audio_packet_count = 0
703
+ chunk_sample_count = streaming ? chunk_size * channels : nil
457
704
  ptr_size = FFI::Pointer.size
458
705
 
459
- audio_packets.each do |entry|
706
+ while (entry = next_ogg_packet!(packet_reader))
707
+ next unless entry.fetch(:kind) == :audio
708
+
709
+ audio_packet_count += 1
460
710
  pkt = Ogg::Packet.new(
461
711
  data: entry.fetch(:data),
462
712
  bos: entry.fetch(:bos, false),
@@ -464,7 +714,11 @@ module Wavify
464
714
  granulepos: entry[:granule_position].nil? ? -1 : entry[:granule_position],
465
715
  packetno: entry.fetch(:packetno, 0)
466
716
  )
467
- next unless Vorbis::Native.vorbis_synthesis(block_ptr, pkt.native).zero?
717
+ synthesis_result = Vorbis::Native.vorbis_synthesis(block_ptr, pkt.native)
718
+ unless synthesis_result.zero?
719
+ raise InvalidFormatError,
720
+ "Vorbis packet decode failed (packet #{entry.fetch(:packetno)}, code #{synthesis_result})"
721
+ end
468
722
 
469
723
  Vorbis::Native.vorbis_synthesis_blockin(dsp_ptr, block_ptr)
470
724
 
@@ -473,34 +727,42 @@ module Wavify
473
727
  n.times do |i|
474
728
  channels.times do |ch|
475
729
  ch_ptr = ch_array_ptr.get_pointer(ch * ptr_size)
476
- all_samples << ch_ptr.get_float(i * 4)
730
+ samples << ch_ptr.get_float(i * 4)
477
731
  end
478
732
  end
479
733
  Vorbis::Native.vorbis_synthesis_read(dsp_ptr, n)
734
+
735
+ while streaming && samples.length > chunk_sample_count
736
+ chunk_samples = samples.slice!(0, chunk_sample_count)
737
+ yield Core::SampleBuffer.new(chunk_samples, format)
738
+ emitted_sample_count += chunk_samples.length
739
+ end
480
740
  end
481
741
  end
742
+ raise InvalidFormatError, "OGG Vorbis stream does not contain audio packets" if audio_packet_count.zero?
482
743
 
744
+ max_granule = packet_reader.fetch(:max_granule_position)
483
745
  if max_granule&.positive?
484
- target_sample_count = max_granule * channels
485
- all_samples = all_samples.first(target_sample_count) if all_samples.length > target_sample_count
746
+ remaining_sample_count = [(max_granule * channels) - emitted_sample_count, 0].max
747
+ samples = samples.first(remaining_sample_count) if samples.length > remaining_sample_count
486
748
  end
487
749
 
488
- result_buffer = Core::SampleBuffer.new(all_samples, format)
489
-
490
750
  if streaming && block
491
- each_sample_buffer_frame_slice(result_buffer, chunk_size, &block)
751
+ each_sample_buffer_frame_slice(Core::SampleBuffer.new(samples, format), chunk_size, &block)
492
752
  nil
493
753
  elsif block
494
- yield result_buffer
754
+ yield Core::SampleBuffer.new(samples, format)
495
755
  nil
496
756
  else
497
- result_buffer
757
+ Core::SampleBuffer.new(samples, format)
498
758
  end
499
759
  ensure
500
760
  Vorbis::Native.vorbis_block_clear(block_ptr) if block_initialized
501
761
  Vorbis::Native.vorbis_dsp_clear(dsp_ptr) if dsp_initialized
502
762
  Vorbis::Native.vorbis_comment_clear(comment_ptr) if comment_ptr
503
763
  Vorbis::Native.vorbis_info_clear(info_ptr) if info_ptr
764
+ close_ogg_packet_reader(packet_reader)
765
+ decode_context[:io].close if decode_context[:close_io] && decode_context[:io]
504
766
  end
505
767
 
506
768
  # ---------------------------------------------------------------------------
@@ -508,15 +770,53 @@ module Wavify
508
770
  # ---------------------------------------------------------------------------
509
771
 
510
772
  def parse_single_logical_stream_metadata(io)
511
- packet_entries, ogg_info = read_ogg_packets(io)
512
- raise InvalidFormatError, "missing Vorbis identification header" if packet_entries[0].nil?
513
- raise InvalidFormatError, "missing Vorbis comment header" if packet_entries[1].nil?
514
- raise InvalidFormatError, "missing Vorbis setup header" if packet_entries[2].nil?
773
+ packet_reader = build_ogg_packet_reader(io)
774
+ header_entries = []
775
+ packet_count = 0
776
+ audio_packet_count = 0
777
+ non_audio_packet_count = 0
778
+ audio_packets_with_granule_count = 0
779
+ first_audio_granule_position = nil
780
+ last_audio_granule_position = nil
781
+
782
+ while (entry = next_ogg_packet!(packet_reader))
783
+ packet_count += 1
784
+ if header_entries.length < 3
785
+ header_entries << entry
786
+ next
787
+ end
788
+
789
+ if entry.fetch(:kind) == :audio
790
+ audio_packet_count += 1
791
+ granule_position = entry[:granule_position]
792
+ if granule_position
793
+ audio_packets_with_granule_count += 1
794
+ first_audio_granule_position ||= granule_position
795
+ last_audio_granule_position = granule_position
796
+ end
797
+ else
798
+ non_audio_packet_count += 1
799
+ end
800
+ end
801
+ raise InvalidFormatError, "missing Vorbis identification header" if header_entries[0].nil?
802
+ raise InvalidFormatError, "missing Vorbis comment header" if header_entries[1].nil?
803
+ raise InvalidFormatError, "missing Vorbis setup header" if header_entries[2].nil?
804
+
805
+ ogg_info = {
806
+ serial_number: packet_reader[:serial_number],
807
+ page_count: packet_reader[:page_count],
808
+ max_granule_position: packet_reader[:max_granule_position],
809
+ bos_page_count: packet_reader[:bos_page_count],
810
+ eos_page_count: packet_reader[:eos_page_count],
811
+ continued_page_count: packet_reader[:continued_page_count]
812
+ }
515
813
 
516
814
  info_ptr = FFI::MemoryPointer.new(Vorbis::Native::VorbisInfo.size)
517
815
  comment_ptr = FFI::MemoryPointer.new(Vorbis::Native::VorbisComment.size)
518
816
  Vorbis::Native.vorbis_info_init(info_ptr)
817
+ info_initialized = true
519
818
  Vorbis::Native.vorbis_comment_init(comment_ptr)
819
+ comment_initialized = true
520
820
 
521
821
  setup_parsed = false
522
822
  saved_channels = nil
@@ -524,7 +824,7 @@ module Wavify
524
824
  saved_bitrate_nominal = nil
525
825
  saved_bitrate_lower = nil
526
826
  saved_bitrate_upper = nil
527
- packet_entries.first(3).each_with_index do |entry, index|
827
+ header_entries.each_with_index do |entry, index|
528
828
  pkt = Ogg::Packet.new(
529
829
  data: entry.fetch(:data),
530
830
  bos: entry.fetch(:bos, index.zero?),
@@ -563,8 +863,11 @@ module Wavify
563
863
  end
564
864
 
565
865
  vc = Vorbis::Native::VorbisComment.new(comment_ptr)
566
- vendor = vc[:vendor].null? ? nil : vc[:vendor].read_string
866
+ vendor = vc[:vendor].null? ? nil : vc[:vendor].read_string.force_encoding(Encoding::UTF_8).scrub
567
867
  comments_hash = {}
868
+ comment_values = Hash.new { |hash, key| hash[key] = [] }
869
+ raw_comments = []
870
+ raw_comment_bytes = []
568
871
  n_comments = vc[:comments]
569
872
  if n_comments.positive? && !vc[:user_comments].null?
570
873
  user_comments_ptr = vc[:user_comments]
@@ -577,15 +880,18 @@ module Wavify
577
880
  next unless len.positive?
578
881
 
579
882
  str = str_ptr.read_bytes(len)
580
- key, value = str.split("=", 2)
581
- comments_hash[key.downcase] = value if key && value
883
+ raw_comment_bytes << str.b.dup.freeze
884
+ display = str.force_encoding(Encoding::UTF_8).scrub
885
+ raw_comments << display
886
+ key, value = display.split("=", 2)
887
+ if key && value
888
+ normalized_key = key.downcase
889
+ comments_hash[normalized_key] ||= value
890
+ comment_values[normalized_key] << value
891
+ end
582
892
  end
583
893
  end
584
894
 
585
- audio_packets = packet_entries.drop(3).select { |e| e.fetch(:kind) == :audio }
586
- non_audio_packets = packet_entries.drop(3).reject { |e| e.fetch(:kind) == :audio }
587
- known_granule_positions = audio_packets.filter_map { |e| e[:granule_position] }
588
-
589
895
  format = Core::Format.new(channels: channels, sample_rate: sample_rate, bit_depth: 32, sample_format: :float)
590
896
  sample_frame_count = ogg_info[:max_granule_position]
591
897
  duration = sample_frame_count ? Core::Duration.from_samples(sample_frame_count, format.sample_rate) : nil
@@ -593,9 +899,14 @@ module Wavify
593
899
  {
594
900
  format: format,
595
901
  sample_frame_count: sample_frame_count,
902
+ granule_position_frame_count: sample_frame_count,
903
+ sample_frame_count_estimated: !sample_frame_count.nil?,
596
904
  duration: duration,
597
905
  vendor: vendor,
598
906
  comments: comments_hash,
907
+ comment_values: comment_values.transform_values(&:freeze).freeze,
908
+ raw_comments: raw_comments.freeze,
909
+ raw_comment_bytes: raw_comment_bytes.freeze,
599
910
  nominal_bitrate: nominal_bitrate_raw.positive? ? nominal_bitrate_raw : nil,
600
911
  minimum_bitrate: minimum_bitrate_raw.positive? ? minimum_bitrate_raw : nil,
601
912
  maximum_bitrate: maximum_bitrate_raw.positive? ? maximum_bitrate_raw : nil,
@@ -603,17 +914,15 @@ module Wavify
603
914
  blocksize_large: blocksize_large,
604
915
  ogg_serial_number: ogg_info[:serial_number],
605
916
  ogg_page_count: ogg_info[:page_count],
606
- ogg_packet_count: packet_entries.length,
917
+ ogg_packet_count: packet_count,
607
918
  ogg_bos_page_count: ogg_info[:bos_page_count],
608
919
  ogg_eos_page_count: ogg_info[:eos_page_count],
609
920
  ogg_continued_page_count: ogg_info[:continued_page_count],
610
- vorbis_audio_packet_count: audio_packets.length,
611
- vorbis_non_audio_packet_count: non_audio_packets.length,
612
- vorbis_audio_packets_with_granule_count: known_granule_positions.length,
613
- first_audio_packet_granule_position: audio_packets.find { |e| !e[:granule_position].nil? }&.fetch(:granule_position),
614
- last_audio_packet_granule_position: audio_packets.reverse_each.find do |e|
615
- !e[:granule_position].nil?
616
- end&.fetch(:granule_position),
921
+ vorbis_audio_packet_count: audio_packet_count,
922
+ vorbis_non_audio_packet_count: non_audio_packet_count,
923
+ vorbis_audio_packets_with_granule_count: audio_packets_with_granule_count,
924
+ first_audio_packet_granule_position: first_audio_granule_position,
925
+ last_audio_packet_granule_position: last_audio_granule_position,
617
926
  vorbis_setup_parsed: setup_parsed,
618
927
  vorbis_codebook_count: nil,
619
928
  vorbis_codebook_dimensions: nil,
@@ -656,11 +965,12 @@ module Wavify
656
965
  vorbis_output_assembly_window_curve_preflight_count: nil,
657
966
  vorbis_long_window_packet_count: nil,
658
967
  vorbis_short_window_packet_count: nil,
659
- setup_header_size: packet_entries[2]&.fetch(:data)&.bytesize
968
+ setup_header_size: header_entries[2]&.fetch(:data)&.bytesize
660
969
  }
661
970
  ensure
662
- Vorbis::Native.vorbis_comment_clear(comment_ptr) if comment_ptr
663
- Vorbis::Native.vorbis_info_clear(info_ptr) if info_ptr
971
+ Vorbis::Native.vorbis_comment_clear(comment_ptr) if comment_initialized
972
+ Vorbis::Native.vorbis_info_clear(info_ptr) if info_initialized
973
+ close_ogg_packet_reader(packet_reader)
664
974
  end
665
975
 
666
976
  # ---------------------------------------------------------------------------
@@ -789,68 +1099,120 @@ module Wavify
789
1099
  # Chained / interleaved stream decoding (high-level helpers)
790
1100
  # ---------------------------------------------------------------------------
791
1101
 
792
- def decode_chained_vorbis_read_if_needed(io_or_path, decode_mode:, target_format: nil)
793
- chained_streams, physical_ogg_info = read_ogg_logical_stream_chains_from_input(io_or_path, with_info: true)
794
- return nil unless chained_streams.length > 1
1102
+ def decode_chained_vorbis_read_if_needed(io_or_path, target_format: nil, mix_strategy: :clip)
1103
+ original_position = io_or_path.pos if !io_or_path.is_a?(String) && io_or_path.respond_to?(:pos)
1104
+ chained_streams, physical_ogg_info = read_ogg_logical_stream_chains_from_input(
1105
+ io_or_path,
1106
+ with_info: true,
1107
+ storage: :tempfile
1108
+ )
1109
+ unless chained_streams.length > 1
1110
+ io_or_path.seek(original_position, IO::SEEK_SET) if original_position && io_or_path.respond_to?(:seek)
1111
+ return nil
1112
+ end
795
1113
 
796
1114
  if physical_ogg_info[:interleaved_multistream]
797
1115
  decoded_buffers = chained_streams.map do |stream|
798
- read(StringIO.new(stream.fetch(:bytes)), decode_mode: decode_mode)
1116
+ read(logical_stream_io(stream))
799
1117
  end
800
1118
 
801
- return mix_vorbis_sample_buffers(decoded_buffers, target_format: target_format)
1119
+ return mix_vorbis_sample_buffers(decoded_buffers, target_format: target_format, strategy: mix_strategy)
802
1120
  end
803
1121
 
804
1122
  decoded_buffers = chained_streams.map do |stream|
805
- read(StringIO.new(stream.fetch(:bytes)), format: target_format, decode_mode: decode_mode)
1123
+ read(logical_stream_io(stream), format: target_format)
806
1124
  end
807
1125
 
808
1126
  concatenate_vorbis_sample_buffers(decoded_buffers, target_format: target_format)
1127
+ ensure
1128
+ close_logical_streams(chained_streams)
809
1129
  end
810
1130
 
811
- def stream_chained_vorbis_if_needed(io_or_path, chunk_size:, decode_mode:, &block)
812
- chained_streams, physical_ogg_info = read_ogg_logical_stream_chains_from_input(io_or_path, with_info: true)
1131
+ def stream_chained_vorbis_if_needed(io_or_path, chunk_size:, mix_strategy: :clip, &block)
1132
+ chained_streams, physical_ogg_info = read_ogg_logical_stream_chains_from_input(
1133
+ io_or_path,
1134
+ with_info: true,
1135
+ storage: :tempfile
1136
+ )
813
1137
  return false unless chained_streams.length > 1
814
1138
 
815
1139
  if physical_ogg_info[:interleaved_multistream]
816
1140
  stream_metadatas = chained_streams.map do |stream|
817
- parse_single_logical_stream_metadata(StringIO.new(stream.fetch(:bytes)))
1141
+ parse_single_logical_stream_metadata(logical_stream_io(stream))
818
1142
  end
819
1143
  target_format = stream_metadatas.first.fetch(:format)
820
1144
  return stream_interleaved_vorbis_logical_streams_mixed!(
821
1145
  chained_streams,
822
1146
  chunk_size: chunk_size,
823
- decode_mode: decode_mode,
824
1147
  target_format: target_format,
825
- stream_metadatas: stream_metadatas, &block
1148
+ stream_metadatas: stream_metadatas,
1149
+ mix_strategy: mix_strategy,
1150
+ &block
826
1151
  )
827
1152
  end
828
1153
 
829
1154
  stream_metadatas = chained_streams.map do |stream|
830
- parse_single_logical_stream_metadata(StringIO.new(stream.fetch(:bytes)))
1155
+ parse_single_logical_stream_metadata(logical_stream_io(stream))
831
1156
  end
832
1157
  target_format = stream_metadatas.first.fetch(:format)
833
1158
  same_sample_rate = stream_metadatas.all? { |metadata| metadata.fetch(:format).sample_rate == target_format.sample_rate }
834
1159
 
835
1160
  unless same_sample_rate
836
- chained_streams.each do |stream|
837
- normalized = normalize_vorbis_logical_stream_buffer_for_target(
838
- read(StringIO.new(stream.fetch(:bytes)), decode_mode: decode_mode),
839
- target_format
1161
+ chained_streams.zip(stream_metadatas).each do |stream, metadata|
1162
+ stream_vorbis_logical_stream_resampled!(
1163
+ stream,
1164
+ source_format: metadata.fetch(:format),
1165
+ target_format: target_format,
1166
+ chunk_size: chunk_size,
1167
+ &block
840
1168
  )
841
- each_sample_buffer_frame_slice(normalized, chunk_size, &block)
842
1169
  end
843
1170
  return true
844
1171
  end
845
1172
 
846
1173
  chained_format = nil
847
1174
  chained_streams.each do |stream|
848
- stream_read(StringIO.new(stream.fetch(:bytes)), chunk_size: chunk_size, decode_mode: decode_mode) do |chunk|
1175
+ stream_read(logical_stream_io(stream), chunk_size: chunk_size) do |chunk|
849
1176
  chained_format ||= chunk.format
850
1177
  yield(chunk.format == chained_format ? chunk : chunk.convert(chained_format))
851
1178
  end
852
1179
  end
853
1180
 
1181
+ true
1182
+ ensure
1183
+ close_logical_streams(chained_streams)
1184
+ end
1185
+
1186
+ def stream_vorbis_logical_stream_resampled!(stream, source_format:, target_format:, chunk_size:)
1187
+ return enum_for(
1188
+ __method__,
1189
+ stream,
1190
+ source_format: source_format,
1191
+ target_format: target_format,
1192
+ chunk_size: chunk_size
1193
+ ) unless block_given?
1194
+
1195
+ resampler = build_vorbis_streaming_linear_resampler_state(
1196
+ source_format: source_format,
1197
+ target_sample_rate: target_format.sample_rate
1198
+ )
1199
+ stream_read(logical_stream_io(stream), chunk_size: chunk_size) do |chunk|
1200
+ unless resampler
1201
+ yield(chunk.format == target_format ? chunk : chunk.convert(target_format))
1202
+ next
1203
+ end
1204
+
1205
+ feed_vorbis_streaming_linear_resampler_chunk!(resampler, chunk)
1206
+ while (output = drain_vorbis_streaming_linear_resampler_chunk!(resampler, max_frames: chunk_size))
1207
+ yield(output.format == target_format ? output : output.convert(target_format))
1208
+ end
1209
+ end
1210
+ return true unless resampler
1211
+
1212
+ finish_vorbis_streaming_linear_resampler!(resampler)
1213
+ while (output = drain_vorbis_streaming_linear_resampler_chunk!(resampler, max_frames: chunk_size))
1214
+ yield(output.format == target_format ? output : output.convert(target_format))
1215
+ end
854
1216
  true
855
1217
  end
856
1218
 
@@ -873,7 +1235,7 @@ module Wavify
873
1235
  end
874
1236
  end
875
1237
 
876
- def mix_vorbis_sample_buffers(buffers, target_format: nil)
1238
+ def mix_vorbis_sample_buffers(buffers, target_format: nil, strategy: :clip)
877
1239
  buffers = Array(buffers)
878
1240
  raise InvalidFormatError, "OGG Vorbis multi-stream decode did not produce any logical streams" if buffers.empty?
879
1241
 
@@ -895,7 +1257,7 @@ module Wavify
895
1257
  mixed_samples[index] += sample.to_f
896
1258
  end
897
1259
  end
898
- mixed_samples.map! { |sample| [[sample, -1.0].max, 1.0].min }
1260
+ apply_vorbis_mix_strategy!(mixed_samples, strategy, source_count: converted.length)
899
1261
 
900
1262
  mixed = Core::SampleBuffer.new(mixed_samples, work_format)
901
1263
  return mixed if mixed.format == resolved_target_format
@@ -903,6 +1265,31 @@ module Wavify
903
1265
  mixed.convert(resolved_target_format)
904
1266
  end
905
1267
 
1268
+ def apply_vorbis_mix_strategy!(samples, strategy, source_count:)
1269
+ strategy = validate_vorbis_mix_strategy!(strategy)
1270
+ case strategy
1271
+ when :clip
1272
+ samples.map! { |sample| sample.clamp(-1.0, 1.0) }
1273
+ when :normalize
1274
+ peak = samples.map(&:abs).max.to_f
1275
+ samples.map! { |sample| sample / peak } if peak > 1.0
1276
+ when :headroom
1277
+ samples.map! { |sample| sample / source_count } if source_count > 1
1278
+ when :soft_limit
1279
+ samples.map! { |sample| Math.tanh(sample) }
1280
+ end
1281
+ samples
1282
+ end
1283
+
1284
+ def validate_vorbis_mix_strategy!(strategy)
1285
+ value = strategy.to_sym
1286
+ return value if %i[clip normalize headroom soft_limit].include?(value)
1287
+
1288
+ raise InvalidParameterError, "mix_strategy must be :clip, :normalize, :headroom, or :soft_limit"
1289
+ rescue NoMethodError
1290
+ raise InvalidParameterError, "mix_strategy must be Symbol/String"
1291
+ end
1292
+
906
1293
  def normalize_vorbis_logical_stream_buffer_for_target(buffer, target_format)
907
1294
  raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
908
1295
  raise InvalidParameterError, "target_format must be Core::Format" unless target_format.is_a?(Core::Format)
@@ -989,18 +1376,18 @@ module Wavify
989
1376
  def stream_interleaved_vorbis_logical_streams_mixed!(
990
1377
  chained_streams,
991
1378
  chunk_size:,
992
- decode_mode:,
993
1379
  target_format: nil,
994
- stream_metadatas: nil, &block
1380
+ stream_metadatas: nil,
1381
+ mix_strategy: :clip, &block
995
1382
  )
996
1383
  unless block_given?
997
1384
  return enum_for(
998
1385
  __method__,
999
1386
  chained_streams,
1000
1387
  chunk_size: chunk_size,
1001
- decode_mode: decode_mode,
1002
1388
  target_format: target_format,
1003
- stream_metadatas: stream_metadatas
1389
+ stream_metadatas: stream_metadatas,
1390
+ mix_strategy: mix_strategy
1004
1391
  )
1005
1392
  end
1006
1393
 
@@ -1023,16 +1410,15 @@ module Wavify
1023
1410
  stream_metadatas: metadatas,
1024
1411
  target_format: resolved_target_format,
1025
1412
  chunk_size: chunk_size,
1026
- decode_mode: decode_mode, &block
1413
+ mix_strategy: mix_strategy, &block
1027
1414
  )
1028
1415
  end
1029
1416
  end
1030
1417
 
1031
1418
  enumerators = streams.map do |stream|
1032
1419
  stream_read(
1033
- StringIO.new(stream.fetch(:bytes)),
1034
- chunk_size: chunk_size,
1035
- decode_mode: decode_mode
1420
+ logical_stream_io(stream),
1421
+ chunk_size: chunk_size
1036
1422
  )
1037
1423
  end
1038
1424
  loop do
@@ -1044,7 +1430,7 @@ module Wavify
1044
1430
  active_chunks = chunks.compact
1045
1431
  break if active_chunks.empty?
1046
1432
 
1047
- yield mix_vorbis_sample_buffers(active_chunks)
1433
+ yield mix_vorbis_sample_buffers(active_chunks, strategy: mix_strategy)
1048
1434
  end
1049
1435
 
1050
1436
  true
@@ -1055,7 +1441,7 @@ module Wavify
1055
1441
  stream_metadatas:,
1056
1442
  target_format:,
1057
1443
  chunk_size:,
1058
- decode_mode:
1444
+ mix_strategy: :clip
1059
1445
  )
1060
1446
  unless block_given?
1061
1447
  return enum_for(
@@ -1064,7 +1450,7 @@ module Wavify
1064
1450
  stream_metadatas: stream_metadatas,
1065
1451
  target_format: target_format,
1066
1452
  chunk_size: chunk_size,
1067
- decode_mode: decode_mode
1453
+ mix_strategy: mix_strategy
1068
1454
  )
1069
1455
  end
1070
1456
 
@@ -1077,9 +1463,8 @@ module Wavify
1077
1463
  stream_states = streams.zip(metadatas).map do |stream, _metadata|
1078
1464
  {
1079
1465
  enumerator: stream_read(
1080
- StringIO.new(stream.fetch(:bytes)),
1081
- chunk_size: chunk_size,
1082
- decode_mode: decode_mode
1466
+ logical_stream_io(stream),
1467
+ chunk_size: chunk_size
1083
1468
  ),
1084
1469
  source_eof: false,
1085
1470
  pending_samples: [],
@@ -1116,7 +1501,7 @@ module Wavify
1116
1501
  mixed_inputs = stream_states.map do |stream_state|
1117
1502
  take_vorbis_interleaved_stream_pending_chunk!(stream_state, frame_count: emit_frames)
1118
1503
  end.compact
1119
- yield mix_vorbis_sample_buffers(mixed_inputs, target_format: target_work_format)
1504
+ yield mix_vorbis_sample_buffers(mixed_inputs, target_format: target_work_format, strategy: mix_strategy)
1120
1505
  end
1121
1506
 
1122
1507
  true
@@ -1416,31 +1801,6 @@ module Wavify
1416
1801
  :unknown
1417
1802
  end
1418
1803
 
1419
- # ---------------------------------------------------------------------------
1420
- # IO helpers
1421
- # ---------------------------------------------------------------------------
1422
-
1423
- def open_input(io_or_path)
1424
- return [io_or_path, false] if io_or_path.respond_to?(:read)
1425
- raise InvalidParameterError, "input path must be String or IO: #{io_or_path.inspect}" unless io_or_path.is_a?(String)
1426
-
1427
- [File.open(io_or_path, "rb"), true]
1428
- rescue Errno::ENOENT
1429
- raise InvalidFormatError, "input file not found: #{io_or_path}"
1430
- end
1431
-
1432
- def open_output(io_or_path)
1433
- return [io_or_path, false] if io_or_path.respond_to?(:write)
1434
- raise InvalidParameterError, "output path must be String or IO: #{io_or_path.inspect}" unless io_or_path.is_a?(String)
1435
-
1436
- [File.open(io_or_path, "wb"), true]
1437
- end
1438
-
1439
- def ensure_seekable!(io)
1440
- return if io.respond_to?(:seek) && io.respond_to?(:rewind)
1441
-
1442
- raise StreamError, "OGG Vorbis codec requires seekable IO"
1443
- end
1444
1804
  end
1445
1805
  end
1446
1806
  end