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
@@ -9,10 +9,47 @@ module Wavify
9
9
  WAV_FORMAT_PCM = 0x0001 # :nodoc:
10
10
  WAV_FORMAT_FLOAT = 0x0003 # :nodoc:
11
11
  WAV_FORMAT_EXTENSIBLE = 0xFFFE # :nodoc:
12
+ RIFF_MAX_SIZE = 0xFFFF_FFFF # :nodoc:
12
13
 
13
14
  GUID_TAIL = [0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71].pack("C*").freeze # :nodoc:
14
15
  PCM_SUBFORMAT_GUID = ([WAV_FORMAT_PCM, 0x0000, 0x0010].pack("V v v") + GUID_TAIL).freeze # :nodoc:
15
16
  FLOAT_SUBFORMAT_GUID = ([WAV_FORMAT_FLOAT, 0x0000, 0x0010].pack("V v v") + GUID_TAIL).freeze # :nodoc:
17
+ SPEAKER_BITS = {
18
+ front_left: 0x0001,
19
+ front_right: 0x0002,
20
+ front_center: 0x0004,
21
+ low_frequency: 0x0008,
22
+ back_left: 0x0010,
23
+ back_right: 0x0020,
24
+ front_left_of_center: 0x0040,
25
+ front_right_of_center: 0x0080,
26
+ back_center: 0x0100,
27
+ side_left: 0x0200,
28
+ side_right: 0x0400,
29
+ top_center: 0x0800,
30
+ top_front_left: 0x1000,
31
+ top_front_center: 0x2000,
32
+ top_front_right: 0x4000,
33
+ top_back_left: 0x8000,
34
+ top_back_center: 0x1_0000,
35
+ top_back_right: 0x2_0000
36
+ }.freeze # :nodoc:
37
+ INFO_TAGS = {
38
+ "INAM" => :title,
39
+ "IART" => :artist,
40
+ "ICMT" => :comment,
41
+ "ICOP" => :copyright,
42
+ "ICRD" => :date,
43
+ "IGNR" => :genre,
44
+ "ISFT" => :software,
45
+ "ITCH" => :technician
46
+ }.freeze # :nodoc:
47
+ INFO_TAG_CODES = INFO_TAGS.invert.freeze # :nodoc:
48
+ SMPL_LOOP_TYPES = {
49
+ 0 => :forward,
50
+ 1 => :alternating,
51
+ 2 => :backward
52
+ }.freeze # :nodoc:
16
53
 
17
54
  class << self
18
55
  # @param io_or_path [String, IO]
@@ -26,9 +63,10 @@ module Wavify
26
63
  io, close_io = open_input(io_or_path)
27
64
  return false unless io
28
65
 
29
- header = io.read(12)
30
- io.rewind if io.respond_to?(:rewind)
31
- header&.start_with?("RIFF") && header[8, 4] == "WAVE"
66
+ header = probe_bytes(io, 12)
67
+ (header&.start_with?("RIFF") || header&.start_with?("RF64")) && header[8, 4] == "WAVE"
68
+ rescue StreamError
69
+ false
32
70
  ensure
33
71
  io.close if close_io && io
34
72
  end
@@ -38,11 +76,12 @@ module Wavify
38
76
  # @param io_or_path [String, IO]
39
77
  # @param format [Wavify::Core::Format, nil] optional output conversion
40
78
  # @return [Wavify::Core::SampleBuffer]
41
- def read(io_or_path, format: nil)
79
+ def read(io_or_path, format: nil, warning_io: nil)
42
80
  io, close_io = open_input(io_or_path)
43
81
  ensure_seekable!(io)
44
82
 
45
83
  info = parse_chunk_directory(io)
84
+ emit_warnings(info.fetch(:warnings), warning_io)
46
85
  source_format = info.fetch(:format)
47
86
  samples = read_data_chunk(io, info, source_format)
48
87
  buffer = Core::SampleBuffer.new(samples, source_format)
@@ -57,14 +96,15 @@ module Wavify
57
96
  # @param sample_buffer [Wavify::Core::SampleBuffer]
58
97
  # @param format [Wavify::Core::Format, nil]
59
98
  # @return [String, IO]
60
- def write(io_or_path, sample_buffer, format: nil)
99
+ def write(io_or_path, sample_buffer, format: nil, info: nil, **codec_options)
100
+ validate_no_codec_options!(codec_options, operation: "WAV write")
61
101
  raise InvalidParameterError, "sample_buffer must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
62
102
 
63
103
  target_format = format || sample_buffer.format
64
104
  raise InvalidParameterError, "format must be Core::Format" unless target_format.is_a?(Core::Format)
65
105
 
66
106
  buffer = sample_buffer.format == target_format ? sample_buffer : sample_buffer.convert(target_format)
67
- stream_write(io_or_path, format: target_format) do |writer|
107
+ stream_write(io_or_path, format: target_format, info: info) do |writer|
68
108
  writer.call(buffer)
69
109
  end
70
110
  end
@@ -74,14 +114,15 @@ module Wavify
74
114
  # @param io_or_path [String, IO]
75
115
  # @param chunk_size [Integer]
76
116
  # @return [Enumerator]
77
- def stream_read(io_or_path, chunk_size: 4096)
78
- return enum_for(__method__, io_or_path, chunk_size: chunk_size) unless block_given?
117
+ def stream_read(io_or_path, chunk_size: 4096, warning_io: nil)
118
+ return enum_for(__method__, io_or_path, chunk_size: chunk_size, warning_io: warning_io) unless block_given?
79
119
  raise InvalidParameterError, "chunk_size must be a positive Integer" unless chunk_size.is_a?(Integer) && chunk_size.positive?
80
120
 
81
121
  io, close_io = open_input(io_or_path)
82
122
  ensure_seekable!(io)
83
123
 
84
124
  info = parse_chunk_directory(io)
125
+ emit_warnings(info.fetch(:warnings), warning_io)
85
126
  format = info.fetch(:format)
86
127
  bytes_per_frame = format.block_align
87
128
  remaining = info.fetch(:data_size)
@@ -103,16 +144,16 @@ module Wavify
103
144
  # @param io_or_path [String, IO]
104
145
  # @param format [Wavify::Core::Format]
105
146
  # @return [Enumerator, String, IO]
106
- def stream_write(io_or_path, format:)
107
- return enum_for(__method__, io_or_path, format: format) unless block_given?
147
+ def stream_write(io_or_path, format:, info: nil, **codec_options)
148
+ validate_no_codec_options!(codec_options, operation: "WAV stream_write")
149
+ return enum_for(__method__, io_or_path, format: format, info: info, **codec_options) unless block_given?
108
150
  raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
109
151
 
110
152
  io, close_io = open_output(io_or_path)
111
153
  ensure_seekable!(io)
112
- io.rewind if io.respond_to?(:rewind)
113
- io.truncate(0) if io.respond_to?(:truncate)
154
+ prepare_output!(io, owned: close_io)
114
155
 
115
- header = write_stream_header(io, format)
156
+ header = write_stream_header(io, format, info: info)
116
157
  total_data_bytes = 0
117
158
  total_sample_frames = 0
118
159
 
@@ -120,17 +161,25 @@ module Wavify
120
161
  raise InvalidParameterError, "stream chunk must be Core::SampleBuffer" unless chunk.is_a?(Core::SampleBuffer)
121
162
 
122
163
  buffer = chunk.format == format ? chunk : chunk.convert(format)
164
+ encoded_bytes = buffer.sample_frame_count * format.block_align
165
+ next_data_bytes = total_data_bytes + encoded_bytes
166
+ next_sample_frames = total_sample_frames + buffer.sample_frame_count
167
+ projected_container_bytes = (io.pos - header.fetch(:container_start)) + encoded_bytes + (next_data_bytes.odd? ? 1 : 0)
168
+ validate_riff_sizes!(next_data_bytes, next_sample_frames, projected_container_bytes)
123
169
  encoded = encode_samples(buffer.samples, format)
124
- io.write(encoded)
125
- total_data_bytes += encoded.bytesize
126
- total_sample_frames += buffer.sample_frame_count
170
+ unless encoded.bytesize == encoded_bytes
171
+ raise StreamError, "WAV encoder produced an unexpected byte count"
172
+ end
173
+
174
+ write_all(io, encoded)
175
+ total_data_bytes = next_data_bytes
176
+ total_sample_frames = next_sample_frames
127
177
  end
128
178
 
129
179
  yield writer
130
- io.write("\x00") if total_data_bytes.odd?
180
+ write_all(io, "\x00") if total_data_bytes.odd?
131
181
  finalize_stream_header(io, header, total_data_bytes, total_sample_frames)
132
- io.flush if io.respond_to?(:flush)
133
- io.rewind if io.respond_to?(:rewind)
182
+ finalize_output!(io, owned: close_io)
134
183
  io_or_path
135
184
  ensure
136
185
  io.close if close_io && io
@@ -153,7 +202,19 @@ module Wavify
153
202
  sample_frame_count: sample_frame_count,
154
203
  duration: Core::Duration.from_samples(sample_frame_count, format.sample_rate),
155
204
  fact_sample_length: info[:fact_sample_length],
156
- smpl: info[:smpl]
205
+ smpl: info[:smpl],
206
+ loops: normalized_smpl_loops(info[:smpl]),
207
+ cue: info[:cue],
208
+ cue_points: info[:cue]&.fetch(:points) || [],
209
+ info: info[:info],
210
+ bext: info[:bext],
211
+ broadcast_extension: info[:bext],
212
+ rf64: info[:rf64],
213
+ container_bit_depth: info[:container_bit_depth],
214
+ valid_bits_per_sample: info[:valid_bits_per_sample],
215
+ channel_mask: info[:channel_mask],
216
+ channel_layout: format.channel_layout,
217
+ warnings: info[:warnings].freeze
157
218
  }
158
219
  ensure
159
220
  io.close if close_io && io
@@ -161,28 +222,38 @@ module Wavify
161
222
 
162
223
  private
163
224
 
164
- def write_stream_header(io, format)
165
- io.write("RIFF")
225
+ def emit_warnings(warnings, warning_io)
226
+ return if warning_io.nil?
227
+ raise InvalidParameterError, "warning_io must respond to :puts or be nil" unless warning_io.respond_to?(:puts)
228
+
229
+ warnings.each { |warning| warning_io.puts("WAV warning: #{warning}") }
230
+ end
231
+
232
+ def write_stream_header(io, format, info:)
233
+ container_start = io.pos
234
+ write_all(io, "RIFF")
166
235
  riff_size_offset = io.pos
167
- io.write([0].pack("V"))
168
- io.write("WAVE")
236
+ write_all(io, [0].pack("V"))
237
+ write_all(io, "WAVE")
169
238
 
170
239
  fmt_chunk = build_fmt_chunk(format)
171
240
  write_chunk(io, "fmt ", fmt_chunk)
241
+ write_chunk(io, "LIST", build_info_list_chunk(info)) if info
172
242
 
173
243
  fact_sample_offset = nil
174
244
  if format.sample_format != :pcm
175
- io.write("fact")
176
- io.write([4].pack("V"))
245
+ write_all(io, "fact")
246
+ write_all(io, [4].pack("V"))
177
247
  fact_sample_offset = io.pos
178
- io.write([0].pack("V"))
248
+ write_all(io, [0].pack("V"))
179
249
  end
180
250
 
181
- io.write("data")
251
+ write_all(io, "data")
182
252
  data_size_offset = io.pos
183
- io.write([0].pack("V"))
253
+ write_all(io, [0].pack("V"))
184
254
 
185
255
  {
256
+ container_start: container_start,
186
257
  riff_size_offset: riff_size_offset,
187
258
  data_size_offset: data_size_offset,
188
259
  fact_sample_offset: fact_sample_offset
@@ -191,72 +262,143 @@ module Wavify
191
262
 
192
263
  def finalize_stream_header(io, header, total_data_bytes, total_sample_frames)
193
264
  file_end = io.pos
265
+ container_bytes = file_end - header.fetch(:container_start)
266
+ validate_riff_sizes!(total_data_bytes, total_sample_frames, container_bytes)
194
267
 
195
268
  io.seek(header.fetch(:data_size_offset), IO::SEEK_SET)
196
- io.write([total_data_bytes].pack("V"))
269
+ write_all(io, [total_data_bytes].pack("V"))
197
270
 
198
271
  fact_sample_offset = header[:fact_sample_offset]
199
272
  if fact_sample_offset
200
273
  io.seek(fact_sample_offset, IO::SEEK_SET)
201
- io.write([total_sample_frames].pack("V"))
274
+ write_all(io, [total_sample_frames].pack("V"))
202
275
  end
203
276
 
204
277
  io.seek(header.fetch(:riff_size_offset), IO::SEEK_SET)
205
- io.write([file_end - 8].pack("V"))
278
+ write_all(io, [container_bytes - 8].pack("V"))
206
279
  io.seek(file_end, IO::SEEK_SET)
207
280
  end
208
281
 
209
282
  def parse_chunk_directory(io)
210
- io.rewind
283
+ container_start = io.pos
211
284
  header = read_exact(io, 12, "missing RIFF/WAVE header")
212
- raise InvalidFormatError, "invalid WAV header" unless header.start_with?("RIFF") && header[8, 4] == "WAVE"
285
+ container_id = header[0, 4]
286
+ unless %w[RIFF RF64].include?(container_id) && header[8, 4] == "WAVE"
287
+ raise InvalidFormatError, "invalid WAV header"
288
+ end
289
+ riff_size = header[4, 4].unpack1("V")
290
+ container_end = container_id == "RIFF" ? container_start + 8 + riff_size : nil
291
+ raise InvalidFormatError, "RIFF size is smaller than WAVE header" if container_end && container_end < container_start + 12
292
+ if container_end && io.respond_to?(:size) && container_end > io.size
293
+ raise InvalidFormatError, "RIFF size exceeds available input"
294
+ end
213
295
 
214
296
  info = {
215
297
  format: nil,
298
+ container_bit_depth: nil,
299
+ valid_bits_per_sample: nil,
300
+ channel_mask: nil,
301
+ warnings: [],
216
302
  data_offset: nil,
217
303
  data_size: nil,
218
304
  sample_frame_count: nil,
219
305
  fact_sample_length: nil,
220
- smpl: nil
306
+ smpl: nil,
307
+ cue: nil,
308
+ info: nil,
309
+ bext: nil,
310
+ rf64: container_id == "RF64" ? {} : nil
221
311
  }
312
+ seen_chunks = {}
313
+
314
+ loop do
315
+ break if container_end && io.pos >= container_end
316
+ break if !container_end && io.eof?
317
+ if container_end && (container_end - io.pos) < 8
318
+ raise InvalidFormatError, "truncated chunk header inside RIFF container"
319
+ end
222
320
 
223
- until io.eof?
224
- chunk_header = io.read(8)
225
- break if chunk_header.nil?
226
- raise InvalidFormatError, "truncated chunk header" unless chunk_header.bytesize == 8
321
+ chunk_header = read_exact(io, 8, "truncated chunk header")
227
322
 
228
323
  chunk_id = chunk_header[0, 4]
229
324
  chunk_size = chunk_header[4, 4].unpack1("V")
325
+ consumed_size = chunk_size
326
+ if ["fmt ", "data", "ds64"].include?(chunk_id) && seen_chunks[chunk_id]
327
+ raise InvalidFormatError, "duplicate #{chunk_id.inspect} chunk"
328
+ end
329
+ seen_chunks[chunk_id] = true
230
330
 
231
331
  case chunk_id
232
332
  when "fmt "
233
333
  chunk_data = read_exact(io, chunk_size, "truncated fmt chunk")
234
- info[:format] = parse_fmt_chunk(chunk_data)
334
+ fmt_info = parse_fmt_chunk(chunk_data)
335
+ info[:format] = fmt_info.fetch(:format)
336
+ info[:container_bit_depth] = fmt_info.fetch(:container_bit_depth)
337
+ info[:valid_bits_per_sample] = fmt_info.fetch(:valid_bits_per_sample)
338
+ info[:channel_mask] = fmt_info.fetch(:channel_mask)
339
+ info[:warnings].concat(fmt_info.fetch(:warnings))
235
340
  when "data"
236
341
  info[:data_offset] = io.pos
237
- info[:data_size] = chunk_size
238
- skip_bytes(io, chunk_size)
342
+ data_size = rf64_chunk_size(info, :data_size, chunk_size)
343
+ consumed_size = data_size
344
+ info[:data_size] = data_size
345
+ skip_bytes(io, data_size)
346
+ when "ds64"
347
+ chunk_data = read_exact(io, chunk_size, "truncated ds64 chunk")
348
+ info[:rf64] = parse_ds64_chunk(chunk_data)
349
+ container_end = container_start + 8 + info.fetch(:rf64).fetch(:riff_size)
350
+ if io.respond_to?(:size) && container_end > io.size
351
+ raise InvalidFormatError, "RF64 size exceeds available input"
352
+ end
239
353
  when "fact"
240
354
  chunk_data = read_exact(io, chunk_size, "truncated fact chunk")
241
355
  info[:fact_sample_length] = chunk_data.unpack1("V") if chunk_data.bytesize >= 4
242
356
  when "smpl"
243
357
  chunk_data = read_exact(io, chunk_size, "truncated smpl chunk")
244
358
  info[:smpl] = parse_smpl_chunk(chunk_data)
359
+ when "cue "
360
+ chunk_data = read_exact(io, chunk_size, "truncated cue chunk")
361
+ info[:cue] = parse_cue_chunk(chunk_data)
362
+ when "bext"
363
+ chunk_data = read_exact(io, chunk_size, "truncated bext chunk")
364
+ info[:bext] = parse_bext_chunk(chunk_data)
365
+ when "LIST"
366
+ chunk_data = read_exact(io, chunk_size, "truncated LIST chunk")
367
+ list_info = parse_list_chunk(chunk_data)
368
+ info[:info] = list_info if list_info
245
369
  else
246
370
  skip_bytes(io, chunk_size)
247
371
  end
248
372
 
249
- skip_padding_byte(io, chunk_size)
373
+ skip_padding_byte(io, consumed_size)
374
+ if container_end && io.pos > container_end
375
+ raise InvalidFormatError, "chunk exceeds RIFF container boundary"
376
+ end
250
377
  end
251
378
 
252
379
  raise InvalidFormatError, "fmt chunk missing" unless info[:format]
253
380
  raise InvalidFormatError, "data chunk missing" unless info[:data_offset] && info[:data_size]
254
381
 
255
382
  validate_data_chunk!(io, info)
256
- info[:sample_frame_count] = info[:data_size] / info[:format].block_align
383
+ rf64_frame_count = info.dig(:rf64, :sample_frame_count)
384
+ info[:sample_frame_count] = if rf64_frame_count&.positive?
385
+ rf64_frame_count
386
+ else
387
+ info[:data_size] / info[:format].block_align
388
+ end
389
+ validate_smpl_loops!(info[:smpl], info[:sample_frame_count])
257
390
  info
258
391
  end
259
392
 
393
+ def rf64_chunk_size(info, key, chunk_size)
394
+ return chunk_size unless chunk_size == 0xFFFF_FFFF
395
+
396
+ rf64_size = info.dig(:rf64, key)
397
+ raise InvalidFormatError, "RF64 #{key} requires ds64 chunk" unless rf64_size
398
+
399
+ rf64_size
400
+ end
401
+
260
402
  def validate_data_chunk!(io, info)
261
403
  format = info.fetch(:format)
262
404
  data_size = info.fetch(:data_size)
@@ -280,7 +422,9 @@ module Wavify
280
422
  def parse_fmt_chunk(chunk)
281
423
  raise InvalidFormatError, "fmt chunk is too small" if chunk.bytesize < 16
282
424
 
283
- audio_format, channels, sample_rate, byte_rate, block_align, bit_depth = chunk.unpack("v v V V v v")
425
+ audio_format, channels, sample_rate, byte_rate, block_align, container_bit_depth = chunk.unpack("v v V V v v")
426
+ valid_bits = container_bit_depth
427
+ channel_mask = nil
284
428
 
285
429
  if audio_format == WAV_FORMAT_EXTENSIBLE
286
430
  raise InvalidFormatError, "fmt extensible chunk is too small" if chunk.bytesize < 40
@@ -289,9 +433,19 @@ module Wavify
289
433
  raise InvalidFormatError, "invalid extensible fmt chunk size" if extension_size < 22 || chunk.bytesize < (18 + extension_size)
290
434
 
291
435
  valid_bits = chunk[18, 2].unpack1("v")
436
+ valid_bits = container_bit_depth if valid_bits.zero?
437
+ if valid_bits > container_bit_depth
438
+ raise InvalidFormatError, "valid bits per sample exceeds container bit depth"
439
+ end
440
+ channel_mask = chunk[20, 4].unpack1("V")
292
441
  sub_format_guid = chunk[24, 16]
293
- audio_format = sub_format_guid.unpack1("v")
294
- bit_depth = valid_bits if valid_bits.positive?
442
+ audio_format = case sub_format_guid
443
+ when PCM_SUBFORMAT_GUID then WAV_FORMAT_PCM
444
+ when FLOAT_SUBFORMAT_GUID then WAV_FORMAT_FLOAT
445
+ else
446
+ raise UnsupportedFormatError,
447
+ "unsupported WAV extensible subformat GUID: #{sub_format_guid.unpack1('H*')}"
448
+ end
295
449
  end
296
450
 
297
451
  sample_format = case audio_format
@@ -304,21 +458,32 @@ module Wavify
304
458
  format = Core::Format.new(
305
459
  channels: channels,
306
460
  sample_rate: sample_rate,
307
- bit_depth: bit_depth,
308
- sample_format: sample_format
461
+ bit_depth: container_bit_depth,
462
+ sample_format: sample_format,
463
+ valid_bits: valid_bits,
464
+ channel_layout: channel_layout_for_mask(channel_mask, channels)
309
465
  )
310
466
 
311
467
  expected_block_align = format.block_align
312
468
  expected_byte_rate = format.byte_rate
313
- unless block_align == expected_block_align && byte_rate == expected_byte_rate
314
- raise InvalidFormatError, "fmt chunk has inconsistent byte_rate/block_align"
469
+ raise InvalidFormatError, "fmt chunk has inconsistent block_align" unless block_align == expected_block_align
470
+
471
+ warnings = []
472
+ if byte_rate != expected_byte_rate
473
+ warnings << "fmt chunk byte_rate #{byte_rate} does not match expected #{expected_byte_rate}"
315
474
  end
316
475
 
317
- format
476
+ {
477
+ format: format,
478
+ container_bit_depth: container_bit_depth,
479
+ valid_bits_per_sample: valid_bits,
480
+ channel_mask: channel_mask,
481
+ warnings: warnings
482
+ }
318
483
  end
319
484
 
320
485
  def parse_smpl_chunk(chunk)
321
- return nil if chunk.bytesize < 36
486
+ raise InvalidFormatError, "smpl chunk too small" if chunk.bytesize < 36
322
487
 
323
488
  manufacturer, product, sample_period, midi_unity_note, midi_pitch_fraction,
324
489
  smpte_format, smpte_offset, loop_count, sampler_data = chunk.unpack("V9")
@@ -326,7 +491,7 @@ module Wavify
326
491
  loops = []
327
492
  offset = 36
328
493
  loop_count.times do
329
- break if offset + 24 > chunk.bytesize
494
+ raise InvalidFormatError, "truncated smpl loop table" if offset + 24 > chunk.bytesize
330
495
 
331
496
  identifier, loop_type, start_frame, end_frame, fraction, play_count = chunk.byteslice(offset, 24).unpack("V6")
332
497
  loops << {
@@ -354,27 +519,164 @@ module Wavify
354
519
  }
355
520
  end
356
521
 
522
+ def normalized_smpl_loops(smpl)
523
+ return [] unless smpl
524
+
525
+ smpl.fetch(:loops).map do |loop|
526
+ {
527
+ identifier: loop.fetch(:identifier),
528
+ type: SMPL_LOOP_TYPES.fetch(loop.fetch(:type), loop.fetch(:type)),
529
+ start_frame: loop.fetch(:start_frame),
530
+ end_frame: loop.fetch(:end_frame),
531
+ length_frames: loop.fetch(:end_frame) - loop.fetch(:start_frame) + 1,
532
+ play_count: loop.fetch(:play_count)
533
+ }
534
+ end
535
+ end
536
+
537
+ def parse_cue_chunk(chunk)
538
+ raise InvalidFormatError, "cue chunk too small" if chunk.bytesize < 4
539
+
540
+ cue_count = chunk.unpack1("V")
541
+ points = []
542
+ offset = 4
543
+ cue_count.times do
544
+ raise InvalidFormatError, "truncated cue point table" if offset + 24 > chunk.bytesize
545
+
546
+ identifier, position, data_chunk_id, chunk_start, block_start, sample_offset =
547
+ chunk.byteslice(offset, 24).unpack("V V A4 V V V")
548
+ points << {
549
+ identifier: identifier,
550
+ position: position,
551
+ data_chunk_id: data_chunk_id,
552
+ chunk_start: chunk_start,
553
+ block_start: block_start,
554
+ sample_offset: sample_offset
555
+ }
556
+ offset += 24
557
+ end
558
+
559
+ { cue_count: cue_count, points: points }
560
+ end
561
+
562
+ def parse_ds64_chunk(chunk)
563
+ raise InvalidFormatError, "ds64 chunk too small" if chunk.bytesize < 28
564
+
565
+ riff_size, data_size, sample_frame_count = chunk.byteslice(0, 24).unpack("Q< Q< Q<")
566
+ table_count = chunk.byteslice(24, 4).unpack1("V")
567
+ table = {}
568
+ offset = 28
569
+ table_count.times do
570
+ raise InvalidFormatError, "truncated ds64 table" if offset + 12 > chunk.bytesize
571
+
572
+ chunk_id = chunk.byteslice(offset, 4)
573
+ size = chunk.byteslice(offset + 4, 8).unpack1("Q<")
574
+ table[chunk_id] = size
575
+ offset += 12
576
+ end
577
+
578
+ {
579
+ riff_size: riff_size,
580
+ data_size: data_size,
581
+ sample_frame_count: sample_frame_count,
582
+ table: table
583
+ }
584
+ end
585
+
586
+ def parse_bext_chunk(chunk)
587
+ return nil if chunk.bytesize < 602
588
+
589
+ coding_history = chunk.bytesize > 602 ? strip_trailing_nuls(chunk.byteslice(602, chunk.bytesize - 602)) : ""
590
+ {
591
+ description: wav_string(chunk.byteslice(0, 256)),
592
+ originator: wav_string(chunk.byteslice(256, 32)),
593
+ originator_reference: wav_string(chunk.byteslice(288, 32)),
594
+ origination_date: wav_string(chunk.byteslice(320, 10)),
595
+ origination_time: wav_string(chunk.byteslice(330, 8)),
596
+ time_reference: chunk.byteslice(338, 8).unpack1("Q<"),
597
+ version: chunk.byteslice(346, 2).unpack1("v"),
598
+ umid: chunk.byteslice(348, 64).unpack1("H*"),
599
+ loudness_value: bext_loudness_value(chunk, 412),
600
+ loudness_range: bext_loudness_value(chunk, 414),
601
+ max_true_peak_level: bext_loudness_value(chunk, 416),
602
+ max_momentary_loudness: bext_loudness_value(chunk, 418),
603
+ max_short_term_loudness: bext_loudness_value(chunk, 420),
604
+ coding_history: coding_history
605
+ }
606
+ end
607
+
608
+ def wav_string(bytes)
609
+ strip_trailing_nuls(bytes).split("\x00", 2).first.to_s.force_encoding(Encoding::UTF_8).scrub
610
+ end
611
+
612
+ def bext_loudness_value(chunk, offset)
613
+ return nil if chunk.bytesize < offset + 2
614
+
615
+ chunk.byteslice(offset, 2).unpack1("s<")
616
+ end
617
+
618
+ def parse_list_chunk(chunk)
619
+ return nil if chunk.bytesize < 4
620
+ return nil unless chunk[0, 4] == "INFO"
621
+
622
+ result = { raw: {}, raw_bytes: {} }
623
+ offset = 4
624
+ while offset + 8 <= chunk.bytesize
625
+ tag = chunk[offset, 4]
626
+ size = chunk[offset + 4, 4].unpack1("V")
627
+ offset += 8
628
+ break if offset + size > chunk.bytesize
629
+
630
+ raw_value = strip_trailing_nuls(chunk.byteslice(offset, size))
631
+ value = raw_value.dup.force_encoding(Encoding::UTF_8).scrub
632
+ result[:raw][tag] = value
633
+ result[:raw_bytes][tag] = raw_value.freeze
634
+ semantic_key = INFO_TAGS[tag]
635
+ result[semantic_key] = value if semantic_key
636
+ offset += size
637
+ offset += 1 if size.odd?
638
+ end
639
+
640
+ result
641
+ end
642
+
643
+ def validate_smpl_loops!(smpl, sample_frame_count)
644
+ return unless smpl
645
+
646
+ smpl.fetch(:loops).each do |loop|
647
+ start_frame = loop.fetch(:start_frame)
648
+ end_frame = loop.fetch(:end_frame)
649
+ if end_frame < start_frame || end_frame >= sample_frame_count
650
+ raise InvalidFormatError,
651
+ "smpl loop range #{start_frame}..#{end_frame} is outside #{sample_frame_count} sample frames"
652
+ end
653
+ end
654
+ end
655
+
357
656
  def decode_samples(data_chunk, format)
358
657
  if format.sample_format == :float
359
658
  return data_chunk.unpack("e*") if format.bit_depth == 32
360
659
  return data_chunk.unpack("E*") if format.bit_depth == 64
361
660
  elsif format.sample_format == :pcm
362
- return data_chunk.unpack("C*").map { |byte| byte - 128 } if format.bit_depth == 8
363
- return data_chunk.unpack("s<*") if format.bit_depth == 16
364
- return decode_pcm24(data_chunk) if format.bit_depth == 24
365
- return data_chunk.unpack("l<*") if format.bit_depth == 32
661
+ samples = data_chunk.unpack("C*").map { |byte| byte - 128 } if format.bit_depth == 8
662
+ samples = data_chunk.unpack("s<*") if format.bit_depth == 16
663
+ samples = decode_pcm24(data_chunk) if format.bit_depth == 24
664
+ samples = data_chunk.unpack("l<*") if format.bit_depth == 32
665
+ return canonicalize_valid_bits(samples, format) if samples
366
666
  end
367
667
 
368
668
  raise UnsupportedFormatError, "unsupported WAV bit depth: #{format.bit_depth}"
369
669
  end
370
670
 
371
671
  def decode_pcm24(data)
372
- bytes = data.unpack("C*")
373
- bytes.each_slice(3).map do |b0, b1, b2|
374
- value = b0 | (b1 << 8) | (b2 << 16)
672
+ samples = Array.new(data.bytesize / 3)
673
+ samples.length.times do |index|
674
+ offset = index * 3
675
+ value = data.getbyte(offset) | (data.getbyte(offset + 1) << 8) | (data.getbyte(offset + 2) << 16)
375
676
  value -= 0x1000000 if value.anybits?(0x800000)
376
- value
677
+ samples[index] = value
377
678
  end
679
+ samples
378
680
  end
379
681
 
380
682
  def encode_samples(samples, format)
@@ -385,7 +687,7 @@ module Wavify
385
687
  elsif format.sample_format == :pcm
386
688
  min = -(2**(format.bit_depth - 1))
387
689
  max = (2**(format.bit_depth - 1)) - 1
388
- ints = samples.map { |sample| sample.to_i.clamp(min, max) }
690
+ ints = canonicalize_valid_bits(samples.map { |sample| sample.to_i.clamp(min, max) }, format)
389
691
  return ints.map { |sample| sample + 128 }.pack("C*") if format.bit_depth == 8
390
692
  return ints.pack("s<*") if format.bit_depth == 16
391
693
  return encode_pcm24(ints) if format.bit_depth == 24
@@ -396,23 +698,24 @@ module Wavify
396
698
  end
397
699
 
398
700
  def encode_pcm24(samples)
399
- bytes = samples.flat_map do |sample|
701
+ bytes = String.new(capacity: samples.length * 3, encoding: Encoding::BINARY)
702
+ samples.each do |sample|
400
703
  value = sample
401
704
  value += 0x1000000 if value.negative?
402
- [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF]
705
+ bytes << (value & 0xFF) << ((value >> 8) & 0xFF) << ((value >> 16) & 0xFF)
403
706
  end
404
- bytes.pack("C*")
707
+ bytes
405
708
  end
406
709
 
407
710
  def build_fmt_chunk(format)
408
711
  return build_standard_fmt_chunk(format) unless use_extensible_format?(format)
409
712
 
410
713
  base_format_code = format.sample_format == :pcm ? WAV_FORMAT_PCM : WAV_FORMAT_FLOAT
411
- channel_mask = channel_mask_for(format.channels)
714
+ channel_mask = channel_mask_for(format)
412
715
  sub_format_guid = base_format_code == WAV_FORMAT_PCM ? PCM_SUBFORMAT_GUID : FLOAT_SUBFORMAT_GUID
413
716
 
414
717
  [WAV_FORMAT_EXTENSIBLE, format.channels, format.sample_rate, format.byte_rate, format.block_align, format.bit_depth,
415
- 22, format.bit_depth, channel_mask].pack("v v V V v v v v V") + sub_format_guid
718
+ 22, format.valid_bits, channel_mask].pack("v v V V v v v v V") + sub_format_guid
416
719
  end
417
720
 
418
721
  def build_standard_fmt_chunk(format)
@@ -421,11 +724,55 @@ module Wavify
421
724
  .pack("v v V V v v")
422
725
  end
423
726
 
727
+ def build_info_list_chunk(info)
728
+ normalized = normalize_info_tags!(info)
729
+ chunks = normalized.map do |tag, value|
730
+ data = "#{value}\x00".b
731
+ tag + [data.bytesize].pack("V") + data + (data.bytesize.odd? ? "\x00" : "")
732
+ end
733
+
734
+ "INFO" + chunks.join
735
+ end
736
+
737
+ def normalize_info_tags!(info)
738
+ raise InvalidParameterError, "WAV info must be a Hash" unless info.is_a?(Hash)
739
+
740
+ info.each_with_object({}) do |(key, value), normalized|
741
+ tag = info_tag_for!(key)
742
+ raise InvalidParameterError, "WAV info values must be String-compatible" unless value.respond_to?(:to_s)
743
+
744
+ normalized[tag] = value.to_s
745
+ end
746
+ end
747
+
748
+ def info_tag_for!(key)
749
+ return key if key.is_a?(String) && key.match?(/\A[A-Z0-9 ]{4}\z/)
750
+
751
+ semantic = key.to_sym if key.respond_to?(:to_sym)
752
+ tag = INFO_TAG_CODES[semantic]
753
+ return tag if tag
754
+
755
+ raise InvalidParameterError, "unsupported WAV info tag: #{key.inspect}"
756
+ end
757
+
424
758
  def use_extensible_format?(format)
425
- format.channels > 2 || format.bit_depth > 16
759
+ default_layout = Core::Format::DEFAULT_CHANNEL_LAYOUTS[format.channels]
760
+ nonstandard_layout = format.channel_layout && format.channel_layout != default_layout
761
+ format.channels > 2 || format.bit_depth > 16 || format.valid_bits != format.bit_depth || nonstandard_layout
426
762
  end
427
763
 
428
- def channel_mask_for(channels)
764
+ def channel_mask_for(format)
765
+ if format.channel_layout
766
+ return format.channel_layout.sum do |position|
767
+ SPEAKER_BITS.fetch(position) do
768
+ raise UnsupportedFormatError, "WAV cannot encode channel position: #{position.inspect}"
769
+ end
770
+ end
771
+ end
772
+
773
+ return 0 if format.channel_layout.nil?
774
+
775
+ channels = format.channels
429
776
  return 0 if channels <= 0
430
777
  return 0x4 if channels == 1
431
778
  return 0x3 if channels == 2
@@ -439,11 +786,43 @@ module Wavify
439
786
  ((1 << [channels, 32].min) - 1) & 0xFFFF_FFFF
440
787
  end
441
788
 
789
+ def channel_layout_for_mask(channel_mask, channels)
790
+ return Core::Format::DEFAULT_CHANNEL_LAYOUTS[channels] if channel_mask.nil?
791
+ return Core::Format::UNKNOWN_LAYOUT if channel_mask.zero?
792
+
793
+ unknown_bits = channel_mask & ~SPEAKER_BITS.values.reduce(0, :|)
794
+ raise UnsupportedFormatError, "unsupported WAV channel mask bits: 0x#{unknown_bits.to_s(16)}" unless unknown_bits.zero?
795
+
796
+ layout = SPEAKER_BITS.filter_map { |position, bit| position if channel_mask.anybits?(bit) }
797
+ unless layout.length == channels
798
+ raise InvalidFormatError, "WAV channel mask has #{layout.length} positions for #{channels} channels"
799
+ end
800
+
801
+ layout
802
+ end
803
+
804
+ def canonicalize_valid_bits(samples, format)
805
+ shift = format.bit_depth - format.valid_bits
806
+ return samples if shift.zero?
807
+
808
+ samples.map { |sample| (sample >> shift) << shift }
809
+ end
810
+
442
811
  def write_chunk(io, chunk_id, chunk_data)
443
- io.write(chunk_id)
444
- io.write([chunk_data.bytesize].pack("V"))
445
- io.write(chunk_data)
446
- io.write("\x00") if chunk_data.bytesize.odd?
812
+ write_all(io, chunk_id)
813
+ write_all(io, [chunk_data.bytesize].pack("V"))
814
+ write_all(io, chunk_data)
815
+ write_all(io, "\x00") if chunk_data.bytesize.odd?
816
+ end
817
+
818
+ def validate_riff_sizes!(data_bytes, sample_frames, container_bytes)
819
+ return if data_bytes <= RIFF_MAX_SIZE && sample_frames <= RIFF_MAX_SIZE && (container_bytes - 8) <= RIFF_MAX_SIZE
820
+
821
+ raise UnsupportedFormatError, "WAV output exceeds RIFF 32-bit size limits; RF64 writing is not supported"
822
+ end
823
+
824
+ def strip_trailing_nuls(bytes)
825
+ bytes.to_s.sub(/\x00+\z/, "")
447
826
  end
448
827
 
449
828
  def skip_padding_byte(io, chunk_size)