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
@@ -2,10 +2,11 @@
2
2
 
3
3
  module Wavify
4
4
  module Codecs
5
- # AIFF codec for PCM audio (AIFC is not currently supported).
5
+ # AIFF codec for PCM audio and uncompressed AIFF-C variants.
6
6
  class Aiff < Base
7
7
  # Recognized filename extensions.
8
- EXTENSIONS = %w[.aiff .aif].freeze
8
+ EXTENSIONS = %w[.aiff .aif .aifc].freeze
9
+ AIFF_MAX_SIZE = 0xFFFF_FFFF # :nodoc:
9
10
 
10
11
  class << self
11
12
  # @param io_or_path [String, IO]
@@ -19,9 +20,10 @@ module Wavify
19
20
  io, close_io = open_input(io_or_path)
20
21
  return false unless io
21
22
 
22
- header = io.read(12)
23
- io.rewind if io.respond_to?(:rewind)
24
- header&.start_with?("FORM") && header[8, 4] == "AIFF"
23
+ header = probe_bytes(io, 12)
24
+ header&.start_with?("FORM") && %w[AIFF AIFC].include?(header[8, 4])
25
+ rescue StreamError
26
+ false
25
27
  ensure
26
28
  io.close if close_io && io
27
29
  end
@@ -31,11 +33,12 @@ module Wavify
31
33
  # @param io_or_path [String, IO]
32
34
  # @param format [Wavify::Core::Format, nil]
33
35
  # @return [Wavify::Core::SampleBuffer]
34
- def read(io_or_path, format: nil)
36
+ def read(io_or_path, format: nil, warning_io: nil)
35
37
  io, close_io = open_input(io_or_path)
36
38
  ensure_seekable!(io)
37
39
 
38
40
  info = parse_chunks(io)
41
+ emit_warnings(info.fetch(:warnings), warning_io)
39
42
  source_format = info.fetch(:format)
40
43
  samples = read_sound_data(io, info, source_format)
41
44
  buffer = Core::SampleBuffer.new(samples, source_format)
@@ -50,7 +53,9 @@ module Wavify
50
53
  # @param sample_buffer [Wavify::Core::SampleBuffer]
51
54
  # @param format [Wavify::Core::Format, nil]
52
55
  # @return [String, IO]
53
- def write(io_or_path, sample_buffer, format: nil)
56
+ def write(io_or_path, sample_buffer, format: nil, form_type: nil, compression_type: nil, compression_name: nil,
57
+ **codec_options)
58
+ validate_no_codec_options!(codec_options, operation: "AIFF write")
54
59
  raise InvalidParameterError, "sample_buffer must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
55
60
 
56
61
  target_format = format || sample_buffer.format
@@ -58,27 +63,13 @@ module Wavify
58
63
  raise UnsupportedFormatError, "AIFF writer supports PCM only" unless target_format.sample_format == :pcm
59
64
 
60
65
  buffer = sample_buffer.format == target_format ? sample_buffer : sample_buffer.convert(target_format)
61
-
62
- io, close_io = open_output(io_or_path)
63
- io.rewind if io.respond_to?(:rewind)
64
- io.truncate(0) if io.respond_to?(:truncate)
65
-
66
- sample_frames = buffer.sample_frame_count
67
- comm_chunk = build_comm_chunk(target_format, sample_frames)
68
- ssnd_chunk = build_ssnd_chunk(buffer.samples, target_format)
69
- form_size = 4 + chunk_size(comm_chunk) + chunk_size(ssnd_chunk)
70
-
71
- io.write("FORM")
72
- io.write([form_size].pack("N"))
73
- io.write("AIFF")
74
- write_chunk(io, "COMM", comm_chunk)
75
- write_chunk(io, "SSND", ssnd_chunk)
76
- io.flush if io.respond_to?(:flush)
77
- io.rewind if io.respond_to?(:rewind)
78
-
79
- io_or_path
80
- ensure
81
- io.close if close_io && io
66
+ stream_write(
67
+ io_or_path,
68
+ format: target_format,
69
+ form_type: form_type,
70
+ compression_type: compression_type,
71
+ compression_name: compression_name
72
+ ) { |writer| writer.call(buffer) }
82
73
  end
83
74
 
84
75
  # Streams AIFF decoding in frame chunks.
@@ -86,14 +77,15 @@ module Wavify
86
77
  # @param io_or_path [String, IO]
87
78
  # @param chunk_size [Integer]
88
79
  # @return [Enumerator]
89
- def stream_read(io_or_path, chunk_size: 4096)
90
- return enum_for(__method__, io_or_path, chunk_size: chunk_size) unless block_given?
80
+ def stream_read(io_or_path, chunk_size: 4096, warning_io: nil)
81
+ return enum_for(__method__, io_or_path, chunk_size: chunk_size, warning_io: warning_io) unless block_given?
91
82
  raise InvalidParameterError, "chunk_size must be a positive Integer" unless chunk_size.is_a?(Integer) && chunk_size.positive?
92
83
 
93
84
  io, close_io = open_input(io_or_path)
94
85
  ensure_seekable!(io)
95
86
 
96
87
  info = parse_chunks(io)
88
+ emit_warnings(info.fetch(:warnings), warning_io)
97
89
  format = info.fetch(:format)
98
90
  bytes_per_frame = format.block_align
99
91
  remaining = info.fetch(:sound_data_size)
@@ -102,7 +94,7 @@ module Wavify
102
94
  while remaining.positive?
103
95
  bytes = [remaining, bytes_per_frame * chunk_size].min
104
96
  chunk_data = read_exact(io, bytes, "truncated SSND data")
105
- yield Core::SampleBuffer.new(decode_samples(chunk_data, format), format)
97
+ yield Core::SampleBuffer.new(decode_samples(chunk_data, format, byte_order: info.fetch(:byte_order)), format)
106
98
  remaining -= bytes
107
99
  end
108
100
  ensure
@@ -114,25 +106,62 @@ module Wavify
114
106
  # @param io_or_path [String, IO]
115
107
  # @param format [Wavify::Core::Format]
116
108
  # @return [Enumerator, String, IO]
117
- def stream_write(io_or_path, format:)
118
- return enum_for(__method__, io_or_path, format: format) unless block_given?
109
+ def stream_write(io_or_path, format:, form_type: nil, compression_type: nil, compression_name: nil,
110
+ **codec_options)
111
+ validate_no_codec_options!(codec_options, operation: "AIFF stream_write")
112
+ unless block_given?
113
+ return enum_for(
114
+ __method__,
115
+ io_or_path,
116
+ format: format,
117
+ form_type: form_type,
118
+ compression_type: compression_type,
119
+ compression_name: compression_name,
120
+ **codec_options
121
+ )
122
+ end
119
123
  raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
120
124
  raise UnsupportedFormatError, "AIFF stream writer supports PCM only" unless format.sample_format == :pcm
121
125
 
122
- chunks = []
123
- sample_frames = 0
126
+ write_options = normalize_write_options(
127
+ io_or_path,
128
+ form_type: form_type,
129
+ compression_type: compression_type,
130
+ compression_name: compression_name
131
+ )
132
+ io, close_io = open_output(io_or_path)
133
+ ensure_seekable!(io)
134
+ prepare_output!(io, owned: close_io)
135
+
136
+ header = write_stream_header(io, format, write_options)
137
+ total_data_bytes = 0
138
+ total_sample_frames = 0
124
139
  writer = lambda do |buffer|
125
140
  raise InvalidParameterError, "stream chunk must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
126
141
 
127
142
  converted = buffer.format == format ? buffer : buffer.convert(format)
128
- chunks << encode_samples(converted.samples, format)
129
- sample_frames += converted.sample_frame_count
143
+ encoded_bytes = converted.sample_frame_count * format.block_align
144
+ validate_aiff_sizes!(
145
+ data_bytes: total_data_bytes + encoded_bytes,
146
+ sample_frames: total_sample_frames + converted.sample_frame_count
147
+ )
148
+ encoded = encode_samples(converted.samples, format, byte_order: write_options.fetch(:byte_order))
149
+ unless encoded.bytesize == encoded_bytes
150
+ raise StreamError, "AIFF encoder produced an unexpected byte count"
151
+ end
152
+
153
+ write_all(io, encoded)
154
+ total_data_bytes += encoded_bytes
155
+ total_sample_frames += converted.sample_frame_count
130
156
  end
131
157
  yield writer
132
158
 
133
- data = chunks.join
134
- temp_buffer = Core::SampleBuffer.new(decode_samples(data, format), format)
135
- write(io_or_path, temp_buffer, format: format)
159
+ write_all(io, "\x00") if total_data_bytes.odd?
160
+ finalize_stream_header(io, header, total_data_bytes, total_sample_frames)
161
+ finalize_output!(io, owned: close_io)
162
+ io_or_path
163
+ ensure
164
+ io.close if close_io && io
136
165
  end
137
166
 
138
167
  # Reads AIFF metadata without decoding the full audio payload.
@@ -150,7 +179,16 @@ module Wavify
150
179
  {
151
180
  format: format,
152
181
  sample_frame_count: sample_frame_count,
153
- duration: Core::Duration.from_samples(sample_frame_count, format.sample_rate)
182
+ duration: Core::Duration.from_samples(sample_frame_count, format.sample_rate),
183
+ form_type: info[:form_type],
184
+ compression_type: info[:compression_type],
185
+ compression_name: info[:compression_name],
186
+ encoded_sample_rate: info[:encoded_sample_rate],
187
+ container_bit_depth: info[:container_bit_depth],
188
+ valid_bits_per_sample: info[:valid_bits_per_sample],
189
+ warnings: info[:warnings].freeze,
190
+ markers: info[:markers],
191
+ instrument: info[:instrument]
154
192
  }
155
193
  ensure
156
194
  io.close if close_io && io
@@ -158,48 +196,138 @@ module Wavify
158
196
 
159
197
  private
160
198
 
199
+ def emit_warnings(warnings, warning_io)
200
+ return if warning_io.nil?
201
+ raise InvalidParameterError, "warning_io must respond to :puts or be nil" unless warning_io.respond_to?(:puts)
202
+
203
+ warnings.each { |warning| warning_io.puts("AIFF warning: #{warning}") }
204
+ end
205
+
206
+ def write_stream_header(io, format, write_options)
207
+ write_all(io, "FORM")
208
+ form_size_offset = io.pos
209
+ write_all(io, [0].pack("N"))
210
+ write_all(io, write_options.fetch(:form_type))
211
+
212
+ comm_chunk = build_comm_chunk(
213
+ format,
214
+ 0,
215
+ compression_type: write_options.fetch(:compression_type),
216
+ compression_name: write_options.fetch(:compression_name)
217
+ )
218
+ comm_frame_count_offset = io.pos + 10
219
+ write_chunk(io, "COMM", comm_chunk)
220
+
221
+ write_all(io, "SSND")
222
+ ssnd_size_offset = io.pos
223
+ write_all(io, [0].pack("N"))
224
+ write_all(io, [0, 0].pack("N2"))
225
+
226
+ {
227
+ container_start: form_size_offset - 4,
228
+ form_size_offset: form_size_offset,
229
+ comm_frame_count_offset: comm_frame_count_offset,
230
+ ssnd_size_offset: ssnd_size_offset
231
+ }
232
+ end
233
+
234
+ def finalize_stream_header(io, header, total_data_bytes, total_sample_frames)
235
+ file_end = io.pos
236
+ form_size = file_end - header.fetch(:container_start) - 8
237
+ validate_aiff_sizes!(
238
+ data_bytes: total_data_bytes,
239
+ sample_frames: total_sample_frames,
240
+ form_size: form_size
241
+ )
242
+
243
+ io.seek(header.fetch(:form_size_offset), IO::SEEK_SET)
244
+ write_all(io, [form_size].pack("N"))
245
+ io.seek(header.fetch(:comm_frame_count_offset), IO::SEEK_SET)
246
+ write_all(io, [total_sample_frames].pack("N"))
247
+ io.seek(header.fetch(:ssnd_size_offset), IO::SEEK_SET)
248
+ write_all(io, [total_data_bytes + 8].pack("N"))
249
+ io.seek(file_end, IO::SEEK_SET)
250
+ end
251
+
252
+ def validate_aiff_sizes!(data_bytes:, sample_frames:, form_size: nil)
253
+ if data_bytes > (AIFF_MAX_SIZE - 8) || sample_frames > AIFF_MAX_SIZE || (form_size && form_size > AIFF_MAX_SIZE)
254
+ raise UnsupportedFormatError, "AIFF output exceeds 32-bit container limits"
255
+ end
256
+ end
257
+
161
258
  def parse_chunks(io)
162
- io.rewind
259
+ container_start = io.pos
163
260
  header = read_exact(io, 12, "missing FORM header")
164
261
  raise InvalidFormatError, "invalid AIFF header" unless header.start_with?("FORM")
165
262
 
166
263
  form_type = header[8, 4]
167
- raise UnsupportedFormatError, "AIFC is not supported yet" if form_type == "AIFC"
168
- raise InvalidFormatError, "invalid AIFF form type" unless form_type == "AIFF"
264
+ raise InvalidFormatError, "invalid AIFF form type" unless %w[AIFF AIFC].include?(form_type)
265
+ container_end = container_start + 8 + header[4, 4].unpack1("N")
266
+ raise InvalidFormatError, "FORM size is smaller than AIFF header" if container_end < container_start + 12
267
+ if io.respond_to?(:size) && container_end > io.size
268
+ raise InvalidFormatError, "FORM size exceeds available input"
269
+ end
169
270
 
170
271
  info = {
272
+ form_type: form_type,
171
273
  format: nil,
172
274
  sample_frame_count: nil,
173
275
  sound_data_offset: nil,
174
- sound_data_size: nil
276
+ sound_data_size: nil,
277
+ byte_order: :big,
278
+ compression_type: form_type == "AIFF" ? "NONE" : nil,
279
+ compression_name: nil,
280
+ encoded_sample_rate: nil,
281
+ container_bit_depth: nil,
282
+ valid_bits_per_sample: nil,
283
+ warnings: [],
284
+ markers: [],
285
+ instrument: nil
175
286
  }
287
+ seen_chunks = {}
176
288
 
177
- until io.eof?
178
- chunk_header = io.read(8)
179
- break if chunk_header.nil?
180
- raise InvalidFormatError, "truncated AIFF chunk header" unless chunk_header.bytesize == 8
289
+ while io.pos < container_end
290
+ raise InvalidFormatError, "truncated AIFF chunk header" if (container_end - io.pos) < 8
291
+
292
+ chunk_header = read_exact(io, 8, "truncated AIFF chunk header")
181
293
 
182
294
  chunk_id = chunk_header[0, 4]
183
295
  chunk_size = chunk_header[4, 4].unpack1("N")
296
+ if %w[COMM SSND].include?(chunk_id) && seen_chunks[chunk_id]
297
+ raise InvalidFormatError, "duplicate #{chunk_id.inspect} chunk"
298
+ end
299
+ seen_chunks[chunk_id] = true
184
300
 
185
301
  case chunk_id
186
302
  when "COMM"
187
303
  chunk_data = read_exact(io, chunk_size, "truncated COMM chunk")
188
304
  parse_comm_chunk(chunk_data, info)
189
305
  when "SSND"
306
+ raise InvalidFormatError, "invalid SSND chunk size" if chunk_size < 8
307
+
190
308
  offset, = read_exact(io, 8, "truncated SSND header").unpack("N2")
191
- skip_bytes(io, offset)
192
- sound_data_size = chunk_size - 8 - offset
193
- raise InvalidFormatError, "invalid SSND chunk size" if sound_data_size.negative?
309
+ available_sound_bytes = chunk_size - 8
310
+ if offset > available_sound_bytes
311
+ raise InvalidFormatError, "SSND offset exceeds chunk body"
312
+ end
194
313
 
314
+ skip_bytes(io, offset)
315
+ sound_data_size = available_sound_bytes - offset
195
316
  info[:sound_data_offset] = io.pos
196
317
  info[:sound_data_size] = sound_data_size
197
318
  skip_bytes(io, sound_data_size)
319
+ when "MARK"
320
+ chunk_data = read_exact(io, chunk_size, "truncated MARK chunk")
321
+ info[:markers] = parse_mark_chunk(chunk_data)
322
+ when "INST"
323
+ chunk_data = read_exact(io, chunk_size, "truncated INST chunk")
324
+ info[:instrument] = parse_inst_chunk(chunk_data)
198
325
  else
199
326
  skip_bytes(io, chunk_size)
200
327
  end
201
328
 
202
- io.read(1) if chunk_size.odd?
329
+ read_exact(io, 1, "missing padding byte after odd-sized AIFF chunk") if chunk_size.odd?
330
+ raise InvalidFormatError, "chunk exceeds FORM container boundary" if io.pos > container_end
203
331
  end
204
332
 
205
333
  raise InvalidFormatError, "COMM chunk missing" unless info[:format]
@@ -210,6 +338,11 @@ module Wavify
210
338
  if (info[:sound_data_size] % info[:format].block_align) != 0
211
339
  raise InvalidFormatError, "SSND data size is not aligned to frame size"
212
340
  end
341
+ decoded_frame_count = info[:sound_data_size] / info[:format].block_align
342
+ if info[:sample_frame_count] != decoded_frame_count
343
+ raise InvalidFormatError,
344
+ "COMM declares #{info[:sample_frame_count]} frames but SSND contains #{decoded_frame_count}"
345
+ end
213
346
 
214
347
  info
215
348
  end
@@ -217,88 +350,256 @@ module Wavify
217
350
  def parse_comm_chunk(chunk, info)
218
351
  raise InvalidFormatError, "COMM chunk too small" if chunk.bytesize < 18
219
352
 
220
- channels, sample_frames, bit_depth = chunk.unpack("n N n")
353
+ channels, sample_frames, valid_bits = chunk.unpack("n N n")
221
354
  sample_rate = decode_extended80(chunk[8, 10])
222
355
  rounded_rate = sample_rate.round
356
+ container_bit_depth = ((valid_bits + 7) / 8) * 8
357
+ unless Core::Format::PCM_BIT_DEPTHS.include?(container_bit_depth)
358
+ raise UnsupportedFormatError, "unsupported AIFF sample size: #{valid_bits} bits"
359
+ end
360
+ if sample_rate != rounded_rate
361
+ info[:warnings] << "COMM sample rate #{sample_rate} rounded to #{rounded_rate} Hz"
362
+ end
363
+ if info.fetch(:form_type) == "AIFC"
364
+ raise InvalidFormatError, "AIFC COMM chunk too small" if chunk.bytesize < 22
365
+
366
+ compression_type = chunk.byteslice(18, 4)
367
+ info[:compression_type] = compression_type
368
+ info[:compression_name] = parse_pascal_string(chunk.byteslice(22, chunk.bytesize - 22).to_s)
369
+ info[:byte_order] = byte_order_for_aifc!(compression_type)
370
+ end
223
371
 
224
372
  format = Core::Format.new(
225
373
  channels: channels,
226
374
  sample_rate: rounded_rate,
227
- bit_depth: bit_depth,
228
- sample_format: :pcm
375
+ bit_depth: container_bit_depth,
376
+ sample_format: :pcm,
377
+ valid_bits: valid_bits
229
378
  )
230
379
 
231
380
  info[:format] = format
232
381
  info[:sample_frame_count] = sample_frames
382
+ info[:encoded_sample_rate] = sample_rate
383
+ info[:container_bit_depth] = container_bit_depth
384
+ info[:valid_bits_per_sample] = valid_bits
233
385
  end
234
386
 
235
387
  def read_sound_data(io, info, format)
236
388
  io.seek(info.fetch(:sound_data_offset), IO::SEEK_SET)
237
389
  data = read_exact(io, info.fetch(:sound_data_size), "truncated SSND data")
238
- decode_samples(data, format)
390
+ decode_samples(data, format, byte_order: info.fetch(:byte_order))
239
391
  end
240
392
 
241
- def decode_samples(data, format)
242
- case format.bit_depth
243
- when 8
244
- data.unpack("c*")
245
- when 16
246
- data.unpack("s>*")
247
- when 24
248
- decode_pcm24_be(data)
249
- when 32
250
- data.unpack("l>*")
251
- else
252
- raise UnsupportedFormatError, "unsupported AIFF bit depth: #{format.bit_depth}"
393
+ def parse_mark_chunk(chunk)
394
+ raise InvalidFormatError, "MARK chunk too small" if chunk.bytesize < 2
395
+
396
+ marker_count = chunk.unpack1("n")
397
+ markers = []
398
+ offset = 2
399
+ marker_count.times do
400
+ raise InvalidFormatError, "truncated MARK marker" if offset + 7 > chunk.bytesize
401
+
402
+ identifier = chunk.byteslice(offset, 2).unpack1("n")
403
+ position = chunk.byteslice(offset + 2, 4).unpack1("N")
404
+ name_length = chunk.getbyte(offset + 6)
405
+ name_start = offset + 7
406
+ raise InvalidFormatError, "truncated MARK marker name" if name_start + name_length > chunk.bytesize
407
+
408
+ raw_name = chunk.byteslice(name_start, name_length).b.freeze
409
+ name = raw_name.dup.force_encoding(Encoding::UTF_8).scrub.freeze
410
+ markers << { identifier: identifier, position: position, name: name, raw_name_bytes: raw_name }.freeze
411
+ offset = name_start + name_length
412
+ offset += 1 if (name_length + 1).odd?
253
413
  end
414
+ markers
415
+ end
416
+
417
+ def parse_inst_chunk(chunk)
418
+ return nil if chunk.bytesize < 20
419
+
420
+ base_note, detune, low_note, high_note, low_velocity, high_velocity, gain =
421
+ chunk.byteslice(0, 8).unpack("C6s>")
422
+ sustain_mode, sustain_begin, sustain_end, release_mode, release_begin, release_end =
423
+ chunk.byteslice(8, 12).unpack("n n n n n n")
424
+
425
+ {
426
+ base_note: base_note,
427
+ detune: detune,
428
+ low_note: low_note,
429
+ high_note: high_note,
430
+ low_velocity: low_velocity,
431
+ high_velocity: high_velocity,
432
+ gain: gain,
433
+ sustain_loop: { mode: sustain_mode, begin_marker: sustain_begin, end_marker: sustain_end },
434
+ release_loop: { mode: release_mode, begin_marker: release_begin, end_marker: release_end }
435
+ }
436
+ end
437
+
438
+ def byte_order_for_aifc!(compression_type)
439
+ return :big if compression_type == "NONE"
440
+ return :little if compression_type == "sowt"
441
+
442
+ raise UnsupportedFormatError, "unsupported AIFF-C compression type: #{compression_type.inspect}"
443
+ end
444
+
445
+ def normalize_write_options(io_or_path, form_type:, compression_type:, compression_name:)
446
+ requested_form = normalize_write_form_type(form_type || inferred_form_type(io_or_path, compression_type))
447
+ requested_compression = compression_type&.to_s
448
+ requested_form = "AIFC" if requested_compression
449
+ if requested_form == "AIFF"
450
+ return { form_type: "AIFF", compression_type: nil, compression_name: nil, byte_order: :big }
451
+ end
452
+
453
+ normalized_compression = requested_compression || "NONE"
454
+ byte_order = byte_order_for_aifc!(normalized_compression)
455
+ {
456
+ form_type: "AIFC",
457
+ compression_type: normalized_compression,
458
+ compression_name: compression_name || default_aifc_compression_name(normalized_compression),
459
+ byte_order: byte_order
460
+ }
461
+ end
462
+
463
+ def inferred_form_type(io_or_path, compression_type)
464
+ return "AIFC" if compression_type
465
+ return "AIFC" if io_or_path.is_a?(String) && File.extname(io_or_path).casecmp(".aifc").zero?
466
+
467
+ "AIFF"
468
+ end
469
+
470
+ def normalize_write_form_type(value)
471
+ normalized = value.to_s.upcase
472
+ return normalized if %w[AIFF AIFC].include?(normalized)
473
+
474
+ raise InvalidParameterError, "form_type must be :aiff or :aifc"
475
+ end
476
+
477
+ def default_aifc_compression_name(compression_type)
478
+ compression_type == "sowt" ? "little-endian PCM" : "not compressed"
479
+ end
480
+
481
+ def parse_pascal_string(data)
482
+ return nil if data.empty?
483
+
484
+ length = data.getbyte(0).to_i
485
+ return "" if length.zero?
486
+ raise InvalidFormatError, "truncated Pascal string" if length > data.bytesize - 1
487
+
488
+ data.byteslice(1, length).to_s.force_encoding(Encoding::UTF_8).scrub
254
489
  end
255
490
 
256
- def encode_samples(samples, format)
491
+ def decode_samples(data, format, byte_order: :big)
492
+ samples = case format.bit_depth
493
+ when 8
494
+ data.unpack("c*")
495
+ when 16
496
+ byte_order == :little ? data.unpack("s<*") : data.unpack("s>*")
497
+ when 24
498
+ byte_order == :little ? decode_pcm24_le(data) : decode_pcm24_be(data)
499
+ when 32
500
+ byte_order == :little ? data.unpack("l<*") : data.unpack("l>*")
501
+ else
502
+ raise UnsupportedFormatError, "unsupported AIFF bit depth: #{format.bit_depth}"
503
+ end
504
+ canonicalize_valid_bits(samples, format)
505
+ end
506
+
507
+ def encode_samples(samples, format, byte_order: :big)
508
+ samples = canonicalize_valid_bits(samples, format)
257
509
  case format.bit_depth
258
510
  when 8
259
511
  samples.pack("c*")
260
512
  when 16
261
- samples.pack("s>*")
513
+ byte_order == :little ? samples.pack("s<*") : samples.pack("s>*")
262
514
  when 24
263
- encode_pcm24_be(samples)
515
+ byte_order == :little ? encode_pcm24_le(samples) : encode_pcm24_be(samples)
264
516
  when 32
265
- samples.pack("l>*")
517
+ byte_order == :little ? samples.pack("l<*") : samples.pack("l>*")
266
518
  else
267
519
  raise UnsupportedFormatError, "unsupported AIFF bit depth: #{format.bit_depth}"
268
520
  end
269
521
  end
270
522
 
271
523
  def decode_pcm24_be(data)
272
- bytes = data.unpack("C*")
273
- bytes.each_slice(3).map do |b0, b1, b2|
524
+ samples = Array.new(data.bytesize / 3)
525
+ samples.length.times do |index|
526
+ offset = index * 3
527
+ b0 = data.getbyte(offset)
528
+ b1 = data.getbyte(offset + 1)
529
+ b2 = data.getbyte(offset + 2)
274
530
  value = (b0 << 16) | (b1 << 8) | b2
275
531
  value -= 0x1000000 if value.anybits?(0x800000)
276
- value
532
+ samples[index] = value
533
+ end
534
+ samples
535
+ end
536
+
537
+ def decode_pcm24_le(data)
538
+ samples = Array.new(data.bytesize / 3)
539
+ samples.length.times do |index|
540
+ offset = index * 3
541
+ b0 = data.getbyte(offset)
542
+ b1 = data.getbyte(offset + 1)
543
+ b2 = data.getbyte(offset + 2)
544
+ value = b0 | (b1 << 8) | (b2 << 16)
545
+ value -= 0x1000000 if value.anybits?(0x800000)
546
+ samples[index] = value
277
547
  end
548
+ samples
278
549
  end
279
550
 
280
551
  def encode_pcm24_be(samples)
281
- bytes = samples.flat_map do |sample|
552
+ bytes = String.new(capacity: samples.length * 3, encoding: Encoding::BINARY)
553
+ samples.each do |sample|
282
554
  value = sample.to_i
283
555
  value += 0x1000000 if value.negative?
284
- [(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]
556
+ bytes << ((value >> 16) & 0xFF) << ((value >> 8) & 0xFF) << (value & 0xFF)
285
557
  end
286
- bytes.pack("C*")
558
+ bytes
287
559
  end
288
560
 
289
- def build_comm_chunk(format, sample_frames)
290
- [format.channels, sample_frames, format.bit_depth].pack("n N n") + encode_extended80(format.sample_rate.to_f)
561
+ def encode_pcm24_le(samples)
562
+ bytes = String.new(capacity: samples.length * 3, encoding: Encoding::BINARY)
563
+ samples.each do |sample|
564
+ value = sample.to_i
565
+ value += 0x1000000 if value.negative?
566
+ bytes << (value & 0xFF) << ((value >> 8) & 0xFF) << ((value >> 16) & 0xFF)
567
+ end
568
+ bytes
569
+ end
570
+
571
+ def canonicalize_valid_bits(samples, format)
572
+ shift = format.bit_depth - format.valid_bits
573
+ return samples if shift.zero?
574
+
575
+ samples.map { |sample| (sample.to_i >> shift) << shift }
576
+ end
577
+
578
+ def build_comm_chunk(format, sample_frames, compression_type: nil, compression_name: nil)
579
+ base = [format.channels, sample_frames, format.bit_depth].pack("n N n") + encode_extended80(format.sample_rate.to_f)
580
+ return base unless compression_type
581
+
582
+ base + compression_type + build_pascal_string(compression_name.to_s)
583
+ end
584
+
585
+ def build_pascal_string(value)
586
+ bytes = value.b
587
+ raise InvalidParameterError, "AIFF Pascal strings cannot exceed 255 bytes" if bytes.bytesize > 255
588
+
589
+ data = [bytes.bytesize].pack("C") + bytes
590
+ data << "\x00" if data.bytesize.odd?
591
+ data
291
592
  end
292
593
 
293
- def build_ssnd_chunk(samples, format)
294
- [0, 0].pack("N2") + encode_samples(samples, format)
594
+ def build_ssnd_chunk(samples, format, byte_order: :big)
595
+ [0, 0].pack("N2") + encode_samples(samples, format, byte_order: byte_order)
295
596
  end
296
597
 
297
598
  def write_chunk(io, chunk_id, chunk_data)
298
- io.write(chunk_id)
299
- io.write([chunk_data.bytesize].pack("N"))
300
- io.write(chunk_data)
301
- io.write("\x00") if chunk_data.bytesize.odd?
599
+ write_all(io, chunk_id)
600
+ write_all(io, [chunk_data.bytesize].pack("N"))
601
+ write_all(io, chunk_data)
602
+ write_all(io, "\x00") if chunk_data.bytesize.odd?
302
603
  end
303
604
 
304
605
  def chunk_size(chunk_data)