wavify 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.serena/.gitignore +1 -0
  3. data/.serena/memories/project_overview.md +5 -0
  4. data/.serena/memories/style_and_completion.md +5 -0
  5. data/.serena/memories/suggested_commands.md +11 -0
  6. data/.serena/project.yml +126 -0
  7. data/.simplecov +18 -0
  8. data/.yardopts +4 -0
  9. data/CHANGELOG.md +11 -0
  10. data/LICENSE +21 -0
  11. data/README.md +196 -0
  12. data/Rakefile +190 -0
  13. data/benchmarks/README.md +46 -0
  14. data/benchmarks/benchmark_helper.rb +112 -0
  15. data/benchmarks/dsp_effects_benchmark.rb +46 -0
  16. data/benchmarks/flac_benchmark.rb +74 -0
  17. data/benchmarks/streaming_memory_benchmark.rb +94 -0
  18. data/benchmarks/wav_io_benchmark.rb +110 -0
  19. data/examples/audio_processing.rb +73 -0
  20. data/examples/cinematic_transition.rb +118 -0
  21. data/examples/drum_machine.rb +74 -0
  22. data/examples/format_convert.rb +81 -0
  23. data/examples/hybrid_arrangement.rb +165 -0
  24. data/examples/streaming_master_chain.rb +129 -0
  25. data/examples/synth_pad.rb +42 -0
  26. data/lib/wavify/audio.rb +483 -0
  27. data/lib/wavify/codecs/aiff.rb +338 -0
  28. data/lib/wavify/codecs/base.rb +108 -0
  29. data/lib/wavify/codecs/flac.rb +1322 -0
  30. data/lib/wavify/codecs/ogg_vorbis.rb +1447 -0
  31. data/lib/wavify/codecs/raw.rb +193 -0
  32. data/lib/wavify/codecs/registry.rb +87 -0
  33. data/lib/wavify/codecs/wav.rb +459 -0
  34. data/lib/wavify/core/duration.rb +99 -0
  35. data/lib/wavify/core/format.rb +133 -0
  36. data/lib/wavify/core/sample_buffer.rb +216 -0
  37. data/lib/wavify/core/stream.rb +129 -0
  38. data/lib/wavify/dsl.rb +537 -0
  39. data/lib/wavify/dsp/effects/chorus.rb +98 -0
  40. data/lib/wavify/dsp/effects/compressor.rb +85 -0
  41. data/lib/wavify/dsp/effects/delay.rb +69 -0
  42. data/lib/wavify/dsp/effects/distortion.rb +64 -0
  43. data/lib/wavify/dsp/effects/effect_base.rb +68 -0
  44. data/lib/wavify/dsp/effects/reverb.rb +112 -0
  45. data/lib/wavify/dsp/effects.rb +21 -0
  46. data/lib/wavify/dsp/envelope.rb +97 -0
  47. data/lib/wavify/dsp/filter.rb +271 -0
  48. data/lib/wavify/dsp/oscillator.rb +123 -0
  49. data/lib/wavify/errors.rb +34 -0
  50. data/lib/wavify/sequencer/engine.rb +278 -0
  51. data/lib/wavify/sequencer/note_sequence.rb +132 -0
  52. data/lib/wavify/sequencer/pattern.rb +102 -0
  53. data/lib/wavify/sequencer/track.rb +298 -0
  54. data/lib/wavify/sequencer.rb +12 -0
  55. data/lib/wavify/version.rb +6 -0
  56. data/lib/wavify.rb +28 -0
  57. data/tools/fixture_writer.rb +85 -0
  58. metadata +129 -0
@@ -0,0 +1,459 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module Codecs
5
+ # WAV codec with PCM, float, and WAVE_FORMAT_EXTENSIBLE support.
6
+ class Wav < Base
7
+ # Recognized filename extensions.
8
+ EXTENSIONS = %w[.wav .wave].freeze
9
+ WAV_FORMAT_PCM = 0x0001 # :nodoc:
10
+ WAV_FORMAT_FLOAT = 0x0003 # :nodoc:
11
+ WAV_FORMAT_EXTENSIBLE = 0xFFFE # :nodoc:
12
+
13
+ GUID_TAIL = [0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71].pack("C*").freeze # :nodoc:
14
+ PCM_SUBFORMAT_GUID = ([WAV_FORMAT_PCM, 0x0000, 0x0010].pack("V v v") + GUID_TAIL).freeze # :nodoc:
15
+ FLOAT_SUBFORMAT_GUID = ([WAV_FORMAT_FLOAT, 0x0000, 0x0010].pack("V v v") + GUID_TAIL).freeze # :nodoc:
16
+
17
+ class << self
18
+ # @param io_or_path [String, IO]
19
+ # @return [Boolean]
20
+ def can_read?(io_or_path)
21
+ if io_or_path.is_a?(String)
22
+ return true if EXTENSIONS.include?(File.extname(io_or_path).downcase)
23
+ return false unless File.file?(io_or_path)
24
+ end
25
+
26
+ io, close_io = open_input(io_or_path)
27
+ return false unless io
28
+
29
+ header = io.read(12)
30
+ io.rewind if io.respond_to?(:rewind)
31
+ header&.start_with?("RIFF") && header[8, 4] == "WAVE"
32
+ ensure
33
+ io.close if close_io && io
34
+ end
35
+
36
+ # Reads a WAV file/IO into a sample buffer.
37
+ #
38
+ # @param io_or_path [String, IO]
39
+ # @param format [Wavify::Core::Format, nil] optional output conversion
40
+ # @return [Wavify::Core::SampleBuffer]
41
+ def read(io_or_path, format: nil)
42
+ io, close_io = open_input(io_or_path)
43
+ ensure_seekable!(io)
44
+
45
+ info = parse_chunk_directory(io)
46
+ source_format = info.fetch(:format)
47
+ samples = read_data_chunk(io, info, source_format)
48
+ buffer = Core::SampleBuffer.new(samples, source_format)
49
+ format ? buffer.convert(format) : buffer
50
+ ensure
51
+ io.close if close_io && io
52
+ end
53
+
54
+ # Writes a sample buffer as WAV.
55
+ #
56
+ # @param io_or_path [String, IO]
57
+ # @param sample_buffer [Wavify::Core::SampleBuffer]
58
+ # @param format [Wavify::Core::Format, nil]
59
+ # @return [String, IO]
60
+ def write(io_or_path, sample_buffer, format: nil)
61
+ raise InvalidParameterError, "sample_buffer must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
62
+
63
+ target_format = format || sample_buffer.format
64
+ raise InvalidParameterError, "format must be Core::Format" unless target_format.is_a?(Core::Format)
65
+
66
+ buffer = sample_buffer.format == target_format ? sample_buffer : sample_buffer.convert(target_format)
67
+ stream_write(io_or_path, format: target_format) do |writer|
68
+ writer.call(buffer)
69
+ end
70
+ end
71
+
72
+ # Streams WAV data chunks as sample buffers.
73
+ #
74
+ # @param io_or_path [String, IO]
75
+ # @param chunk_size [Integer]
76
+ # @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?
79
+ raise InvalidParameterError, "chunk_size must be a positive Integer" unless chunk_size.is_a?(Integer) && chunk_size.positive?
80
+
81
+ io, close_io = open_input(io_or_path)
82
+ ensure_seekable!(io)
83
+
84
+ info = parse_chunk_directory(io)
85
+ format = info.fetch(:format)
86
+ bytes_per_frame = format.block_align
87
+ remaining = info.fetch(:data_size)
88
+
89
+ io.seek(info.fetch(:data_offset), IO::SEEK_SET)
90
+ while remaining.positive?
91
+ to_read = [remaining, chunk_size * bytes_per_frame].min
92
+ chunk_data = read_exact(io, to_read, "truncated data chunk")
93
+ samples = decode_samples(chunk_data, format)
94
+ yield Core::SampleBuffer.new(samples, format)
95
+ remaining -= to_read
96
+ end
97
+ ensure
98
+ io.close if close_io && io
99
+ end
100
+
101
+ # Streams WAV encoding and finalizes the RIFF header on completion.
102
+ #
103
+ # @param io_or_path [String, IO]
104
+ # @param format [Wavify::Core::Format]
105
+ # @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?
108
+ raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
109
+
110
+ io, close_io = open_output(io_or_path)
111
+ ensure_seekable!(io)
112
+ io.rewind if io.respond_to?(:rewind)
113
+ io.truncate(0) if io.respond_to?(:truncate)
114
+
115
+ header = write_stream_header(io, format)
116
+ total_data_bytes = 0
117
+ total_sample_frames = 0
118
+
119
+ writer = lambda do |chunk|
120
+ raise InvalidParameterError, "stream chunk must be Core::SampleBuffer" unless chunk.is_a?(Core::SampleBuffer)
121
+
122
+ buffer = chunk.format == format ? chunk : chunk.convert(format)
123
+ encoded = encode_samples(buffer.samples, format)
124
+ io.write(encoded)
125
+ total_data_bytes += encoded.bytesize
126
+ total_sample_frames += buffer.sample_frame_count
127
+ end
128
+
129
+ yield writer
130
+ io.write("\x00") if total_data_bytes.odd?
131
+ 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)
134
+ io_or_path
135
+ ensure
136
+ io.close if close_io && io
137
+ end
138
+
139
+ # Reads WAV metadata without fully decoding samples.
140
+ #
141
+ # @param io_or_path [String, IO]
142
+ # @return [Hash]
143
+ def metadata(io_or_path)
144
+ io, close_io = open_input(io_or_path)
145
+ ensure_seekable!(io)
146
+
147
+ info = parse_chunk_directory(io)
148
+ format = info.fetch(:format)
149
+ sample_frame_count = info.fetch(:sample_frame_count)
150
+
151
+ {
152
+ format: format,
153
+ sample_frame_count: sample_frame_count,
154
+ duration: Core::Duration.from_samples(sample_frame_count, format.sample_rate),
155
+ fact_sample_length: info[:fact_sample_length],
156
+ smpl: info[:smpl]
157
+ }
158
+ ensure
159
+ io.close if close_io && io
160
+ end
161
+
162
+ private
163
+
164
+ def write_stream_header(io, format)
165
+ io.write("RIFF")
166
+ riff_size_offset = io.pos
167
+ io.write([0].pack("V"))
168
+ io.write("WAVE")
169
+
170
+ fmt_chunk = build_fmt_chunk(format)
171
+ write_chunk(io, "fmt ", fmt_chunk)
172
+
173
+ fact_sample_offset = nil
174
+ if format.sample_format != :pcm
175
+ io.write("fact")
176
+ io.write([4].pack("V"))
177
+ fact_sample_offset = io.pos
178
+ io.write([0].pack("V"))
179
+ end
180
+
181
+ io.write("data")
182
+ data_size_offset = io.pos
183
+ io.write([0].pack("V"))
184
+
185
+ {
186
+ riff_size_offset: riff_size_offset,
187
+ data_size_offset: data_size_offset,
188
+ fact_sample_offset: fact_sample_offset
189
+ }
190
+ end
191
+
192
+ def finalize_stream_header(io, header, total_data_bytes, total_sample_frames)
193
+ file_end = io.pos
194
+
195
+ io.seek(header.fetch(:data_size_offset), IO::SEEK_SET)
196
+ io.write([total_data_bytes].pack("V"))
197
+
198
+ fact_sample_offset = header[:fact_sample_offset]
199
+ if fact_sample_offset
200
+ io.seek(fact_sample_offset, IO::SEEK_SET)
201
+ io.write([total_sample_frames].pack("V"))
202
+ end
203
+
204
+ io.seek(header.fetch(:riff_size_offset), IO::SEEK_SET)
205
+ io.write([file_end - 8].pack("V"))
206
+ io.seek(file_end, IO::SEEK_SET)
207
+ end
208
+
209
+ def parse_chunk_directory(io)
210
+ io.rewind
211
+ header = read_exact(io, 12, "missing RIFF/WAVE header")
212
+ raise InvalidFormatError, "invalid WAV header" unless header.start_with?("RIFF") && header[8, 4] == "WAVE"
213
+
214
+ info = {
215
+ format: nil,
216
+ data_offset: nil,
217
+ data_size: nil,
218
+ sample_frame_count: nil,
219
+ fact_sample_length: nil,
220
+ smpl: nil
221
+ }
222
+
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
227
+
228
+ chunk_id = chunk_header[0, 4]
229
+ chunk_size = chunk_header[4, 4].unpack1("V")
230
+
231
+ case chunk_id
232
+ when "fmt "
233
+ chunk_data = read_exact(io, chunk_size, "truncated fmt chunk")
234
+ info[:format] = parse_fmt_chunk(chunk_data)
235
+ when "data"
236
+ info[:data_offset] = io.pos
237
+ info[:data_size] = chunk_size
238
+ skip_bytes(io, chunk_size)
239
+ when "fact"
240
+ chunk_data = read_exact(io, chunk_size, "truncated fact chunk")
241
+ info[:fact_sample_length] = chunk_data.unpack1("V") if chunk_data.bytesize >= 4
242
+ when "smpl"
243
+ chunk_data = read_exact(io, chunk_size, "truncated smpl chunk")
244
+ info[:smpl] = parse_smpl_chunk(chunk_data)
245
+ else
246
+ skip_bytes(io, chunk_size)
247
+ end
248
+
249
+ skip_padding_byte(io, chunk_size)
250
+ end
251
+
252
+ raise InvalidFormatError, "fmt chunk missing" unless info[:format]
253
+ raise InvalidFormatError, "data chunk missing" unless info[:data_offset] && info[:data_size]
254
+
255
+ validate_data_chunk!(io, info)
256
+ info[:sample_frame_count] = info[:data_size] / info[:format].block_align
257
+ info
258
+ end
259
+
260
+ def validate_data_chunk!(io, info)
261
+ format = info.fetch(:format)
262
+ data_size = info.fetch(:data_size)
263
+ data_offset = info.fetch(:data_offset)
264
+ block_align = format.block_align
265
+
266
+ raise InvalidFormatError, "data chunk size is not aligned to frame size" unless (data_size % block_align).zero?
267
+
268
+ return unless io.respond_to?(:size)
269
+ return unless data_offset + data_size > io.size
270
+
271
+ raise InvalidFormatError, "data chunk exceeds file size"
272
+ end
273
+
274
+ def read_data_chunk(io, info, format)
275
+ io.seek(info.fetch(:data_offset), IO::SEEK_SET)
276
+ data = read_exact(io, info.fetch(:data_size), "truncated data chunk")
277
+ decode_samples(data, format)
278
+ end
279
+
280
+ def parse_fmt_chunk(chunk)
281
+ raise InvalidFormatError, "fmt chunk is too small" if chunk.bytesize < 16
282
+
283
+ audio_format, channels, sample_rate, byte_rate, block_align, bit_depth = chunk.unpack("v v V V v v")
284
+
285
+ if audio_format == WAV_FORMAT_EXTENSIBLE
286
+ raise InvalidFormatError, "fmt extensible chunk is too small" if chunk.bytesize < 40
287
+
288
+ extension_size = chunk[16, 2].unpack1("v")
289
+ raise InvalidFormatError, "invalid extensible fmt chunk size" if extension_size < 22 || chunk.bytesize < (18 + extension_size)
290
+
291
+ valid_bits = chunk[18, 2].unpack1("v")
292
+ sub_format_guid = chunk[24, 16]
293
+ audio_format = sub_format_guid.unpack1("v")
294
+ bit_depth = valid_bits if valid_bits.positive?
295
+ end
296
+
297
+ sample_format = case audio_format
298
+ when WAV_FORMAT_PCM then :pcm
299
+ when WAV_FORMAT_FLOAT then :float
300
+ else
301
+ raise UnsupportedFormatError, "unsupported WAV format code: #{audio_format}"
302
+ end
303
+
304
+ format = Core::Format.new(
305
+ channels: channels,
306
+ sample_rate: sample_rate,
307
+ bit_depth: bit_depth,
308
+ sample_format: sample_format
309
+ )
310
+
311
+ expected_block_align = format.block_align
312
+ 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"
315
+ end
316
+
317
+ format
318
+ end
319
+
320
+ def parse_smpl_chunk(chunk)
321
+ return nil if chunk.bytesize < 36
322
+
323
+ manufacturer, product, sample_period, midi_unity_note, midi_pitch_fraction,
324
+ smpte_format, smpte_offset, loop_count, sampler_data = chunk.unpack("V9")
325
+
326
+ loops = []
327
+ offset = 36
328
+ loop_count.times do
329
+ break if offset + 24 > chunk.bytesize
330
+
331
+ identifier, loop_type, start_frame, end_frame, fraction, play_count = chunk.byteslice(offset, 24).unpack("V6")
332
+ loops << {
333
+ identifier: identifier,
334
+ type: loop_type,
335
+ start_frame: start_frame,
336
+ end_frame: end_frame,
337
+ fraction: fraction,
338
+ play_count: play_count
339
+ }
340
+ offset += 24
341
+ end
342
+
343
+ {
344
+ manufacturer: manufacturer,
345
+ product: product,
346
+ sample_period: sample_period,
347
+ midi_unity_note: midi_unity_note,
348
+ midi_pitch_fraction: midi_pitch_fraction,
349
+ smpte_format: smpte_format,
350
+ smpte_offset: smpte_offset,
351
+ sampler_data: sampler_data,
352
+ loop_count: loop_count,
353
+ loops: loops
354
+ }
355
+ end
356
+
357
+ def decode_samples(data_chunk, format)
358
+ if format.sample_format == :float
359
+ return data_chunk.unpack("e*") if format.bit_depth == 32
360
+ return data_chunk.unpack("E*") if format.bit_depth == 64
361
+ 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
366
+ end
367
+
368
+ raise UnsupportedFormatError, "unsupported WAV bit depth: #{format.bit_depth}"
369
+ end
370
+
371
+ 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)
375
+ value -= 0x1000000 if value.anybits?(0x800000)
376
+ value
377
+ end
378
+ end
379
+
380
+ def encode_samples(samples, format)
381
+ if format.sample_format == :float
382
+ normalized = samples.map { |sample| sample.to_f.clamp(-1.0, 1.0) }
383
+ return normalized.pack("e*") if format.bit_depth == 32
384
+ return normalized.pack("E*") if format.bit_depth == 64
385
+ elsif format.sample_format == :pcm
386
+ min = -(2**(format.bit_depth - 1))
387
+ max = (2**(format.bit_depth - 1)) - 1
388
+ ints = samples.map { |sample| sample.to_i.clamp(min, max) }
389
+ return ints.map { |sample| sample + 128 }.pack("C*") if format.bit_depth == 8
390
+ return ints.pack("s<*") if format.bit_depth == 16
391
+ return encode_pcm24(ints) if format.bit_depth == 24
392
+ return ints.pack("l<*") if format.bit_depth == 32
393
+ end
394
+
395
+ raise UnsupportedFormatError, "cannot encode WAV format: #{format.inspect}"
396
+ end
397
+
398
+ def encode_pcm24(samples)
399
+ bytes = samples.flat_map do |sample|
400
+ value = sample
401
+ value += 0x1000000 if value.negative?
402
+ [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF]
403
+ end
404
+ bytes.pack("C*")
405
+ end
406
+
407
+ def build_fmt_chunk(format)
408
+ return build_standard_fmt_chunk(format) unless use_extensible_format?(format)
409
+
410
+ base_format_code = format.sample_format == :pcm ? WAV_FORMAT_PCM : WAV_FORMAT_FLOAT
411
+ channel_mask = channel_mask_for(format.channels)
412
+ sub_format_guid = base_format_code == WAV_FORMAT_PCM ? PCM_SUBFORMAT_GUID : FLOAT_SUBFORMAT_GUID
413
+
414
+ [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
416
+ end
417
+
418
+ def build_standard_fmt_chunk(format)
419
+ format_code = format.sample_format == :pcm ? WAV_FORMAT_PCM : WAV_FORMAT_FLOAT
420
+ [format_code, format.channels, format.sample_rate, format.byte_rate, format.block_align, format.bit_depth]
421
+ .pack("v v V V v v")
422
+ end
423
+
424
+ def use_extensible_format?(format)
425
+ format.channels > 2 || format.bit_depth > 16
426
+ end
427
+
428
+ def channel_mask_for(channels)
429
+ return 0 if channels <= 0
430
+ return 0x4 if channels == 1
431
+ return 0x3 if channels == 2
432
+ return 0x7 if channels == 3
433
+ return 0x33 if channels == 4
434
+ return 0x37 if channels == 5
435
+ return 0x3F if channels == 6
436
+ return 0x13F if channels == 7
437
+ return 0x63F if channels == 8
438
+
439
+ ((1 << [channels, 32].min) - 1) & 0xFFFF_FFFF
440
+ end
441
+
442
+ 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?
447
+ end
448
+
449
+ def skip_padding_byte(io, chunk_size)
450
+ return unless chunk_size.odd?
451
+
452
+ padding = io.read(1)
453
+ raise InvalidFormatError, "missing padding byte after odd-sized chunk" unless padding && padding.bytesize == 1
454
+ end
455
+
456
+ end
457
+ end
458
+ end
459
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module Core
5
+ # Immutable duration value object used across codecs and sequencer APIs.
6
+ class Duration
7
+ include Comparable
8
+
9
+ attr_reader :total_seconds
10
+
11
+ # Builds a duration from a frame count and sample rate.
12
+ #
13
+ # @param sample_frames [Integer]
14
+ # @param sample_rate [Integer]
15
+ # @return [Duration]
16
+ def self.from_samples(sample_frames, sample_rate)
17
+ unless sample_frames.is_a?(Integer) && sample_frames >= 0
18
+ raise InvalidParameterError, "sample_frames must be a non-negative Integer: #{sample_frames.inspect}"
19
+ end
20
+ unless sample_rate.is_a?(Integer) && sample_rate.positive?
21
+ raise InvalidParameterError, "sample_rate must be a positive Integer: #{sample_rate.inspect}"
22
+ end
23
+
24
+ new(sample_frames.to_f / sample_rate)
25
+ end
26
+
27
+ # @param total_seconds [Numeric] non-negative duration in seconds
28
+ def initialize(total_seconds)
29
+ unless total_seconds.is_a?(Numeric) && total_seconds >= 0
30
+ raise InvalidParameterError, "total_seconds must be a non-negative Numeric: #{total_seconds.inspect}"
31
+ end
32
+
33
+ @total_seconds = total_seconds.to_f
34
+ end
35
+
36
+ # Compares two durations.
37
+ #
38
+ # @param other [Duration]
39
+ # @return [-1, 0, 1, nil]
40
+ def <=>(other)
41
+ return nil unless other.is_a?(Duration)
42
+
43
+ @total_seconds <=> other.total_seconds
44
+ end
45
+
46
+ def +(other)
47
+ raise InvalidParameterError, "expected Duration: #{other.inspect}" unless other.is_a?(Duration)
48
+
49
+ self.class.new(@total_seconds + other.total_seconds)
50
+ end
51
+
52
+ def -(other)
53
+ raise InvalidParameterError, "expected Duration: #{other.inspect}" unless other.is_a?(Duration)
54
+
55
+ value = @total_seconds - other.total_seconds
56
+ raise InvalidParameterError, "resulting duration cannot be negative: #{value}" if value.negative?
57
+
58
+ self.class.new(value)
59
+ end
60
+
61
+ # @return [Integer] hours component for {#to_s}
62
+ def hours
63
+ (total_milliseconds / 3_600_000).floor
64
+ end
65
+
66
+ # @return [Integer] minutes component for {#to_s}
67
+ def minutes
68
+ ((total_milliseconds % 3_600_000) / 60_000).floor
69
+ end
70
+
71
+ # @return [Integer] seconds component for {#to_s}
72
+ def seconds
73
+ ((total_milliseconds % 60_000) / 1000).floor
74
+ end
75
+
76
+ # @return [Integer] milliseconds component for {#to_s}
77
+ def milliseconds
78
+ (total_milliseconds % 1000).floor
79
+ end
80
+
81
+ # Returns a clock-style string (`HH:MM:SS.mmm`).
82
+ #
83
+ # @return [String]
84
+ def to_s
85
+ format("%<hours>02d:%<minutes>02d:%<seconds>02d.%<milliseconds>03d",
86
+ hours: hours,
87
+ minutes: minutes,
88
+ seconds: seconds,
89
+ milliseconds: milliseconds)
90
+ end
91
+
92
+ private
93
+
94
+ def total_milliseconds
95
+ (@total_seconds * 1000).round
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ # Core immutable value objects used by codecs, DSP, and high-level APIs.
5
+ module Core
6
+ # Describes the audio sample layout (channels, sample rate, and sample type).
7
+ class Format
8
+ # Supported symbolic sample format kinds.
9
+ SUPPORTED_SAMPLE_FORMATS = %i[pcm float].freeze
10
+ # Allowed bit depths for integer PCM.
11
+ PCM_BIT_DEPTHS = [8, 16, 24, 32].freeze
12
+ # Allowed bit depths for floating point samples.
13
+ FLOAT_BIT_DEPTHS = [32, 64].freeze
14
+
15
+ attr_reader :channels, :sample_rate, :bit_depth, :sample_format
16
+
17
+ # @param channels [Integer] number of interleaved channels (1..32)
18
+ # @param sample_rate [Integer] sampling rate in Hz
19
+ # @param bit_depth [Integer] bits per sample
20
+ # @param sample_format [Symbol,String] `:pcm` or `:float`
21
+ def initialize(channels:, sample_rate:, bit_depth:, sample_format: :pcm)
22
+ @channels = validate_channels(channels)
23
+ @sample_rate = validate_sample_rate(sample_rate)
24
+ @sample_format = validate_sample_format(sample_format)
25
+ @bit_depth = validate_bit_depth(bit_depth, @sample_format)
26
+ freeze
27
+ end
28
+
29
+ # Returns a new format with one or more fields replaced.
30
+ #
31
+ # @return [Format]
32
+ def with(channels: nil, sample_rate: nil, bit_depth: nil, sample_format: nil)
33
+ self.class.new(
34
+ channels: channels || @channels,
35
+ sample_rate: sample_rate || @sample_rate,
36
+ bit_depth: bit_depth || @bit_depth,
37
+ sample_format: sample_format || @sample_format
38
+ )
39
+ end
40
+
41
+ def mono?
42
+ @channels == 1
43
+ end
44
+
45
+ def stereo?
46
+ @channels == 2
47
+ end
48
+
49
+ # @return [Integer] bytes per audio frame (all channels)
50
+ def block_align
51
+ @channels * bytes_per_sample
52
+ end
53
+
54
+ # @return [Integer] bytes per second for this format
55
+ def byte_rate
56
+ @sample_rate * block_align
57
+ end
58
+
59
+ # @return [Integer] bytes used by one sample value
60
+ def bytes_per_sample
61
+ @bit_depth / 8
62
+ end
63
+
64
+ # Value equality for audio format parameters.
65
+ #
66
+ # @param other [Object]
67
+ # @return [Boolean]
68
+ def ==(other)
69
+ return false unless other.is_a?(Format)
70
+
71
+ @channels == other.channels &&
72
+ @sample_rate == other.sample_rate &&
73
+ @bit_depth == other.bit_depth &&
74
+ @sample_format == other.sample_format
75
+ end
76
+
77
+ alias eql? ==
78
+
79
+ # @return [Integer] hash value compatible with {#eql?}
80
+ def hash
81
+ [@channels, @sample_rate, @bit_depth, @sample_format].hash
82
+ end
83
+
84
+ private
85
+
86
+ def validate_channels(channels)
87
+ unless channels.is_a?(Integer) && channels.between?(1, 32)
88
+ raise InvalidFormatError, "channels must be an Integer between 1 and 32: #{channels.inspect}"
89
+ end
90
+
91
+ channels
92
+ end
93
+
94
+ def validate_sample_rate(sample_rate)
95
+ unless sample_rate.is_a?(Integer) && sample_rate.between?(8_000, 768_000)
96
+ raise InvalidFormatError, "sample_rate must be an Integer between 8000 and 768000: #{sample_rate.inspect}"
97
+ end
98
+
99
+ sample_rate
100
+ end
101
+
102
+ def validate_sample_format(sample_format)
103
+ format = sample_format.to_sym
104
+ raise UnsupportedFormatError, "unsupported sample_format: #{sample_format.inspect}" unless SUPPORTED_SAMPLE_FORMATS.include?(format)
105
+
106
+ format
107
+ rescue NoMethodError
108
+ raise InvalidFormatError, "sample_format must be Symbol/String: #{sample_format.inspect}"
109
+ end
110
+
111
+ def validate_bit_depth(bit_depth, sample_format)
112
+ raise InvalidFormatError, "bit_depth must be an Integer: #{bit_depth.inspect}" unless bit_depth.is_a?(Integer)
113
+
114
+ allowed_depths = sample_format == :pcm ? PCM_BIT_DEPTHS : FLOAT_BIT_DEPTHS
115
+ unless allowed_depths.include?(bit_depth)
116
+ raise InvalidFormatError,
117
+ "bit_depth #{bit_depth} is invalid for #{sample_format}. Allowed: #{allowed_depths.join(', ')}"
118
+ end
119
+
120
+ bit_depth
121
+ end
122
+
123
+ public
124
+
125
+ # Stereo 44.1kHz 16-bit PCM preset.
126
+ CD_QUALITY = new(channels: 2, sample_rate: 44_100, bit_depth: 16, sample_format: :pcm)
127
+ # Stereo 96kHz 24-bit PCM preset.
128
+ DVD_QUALITY = new(channels: 2, sample_rate: 96_000, bit_depth: 24, sample_format: :pcm)
129
+ # Mono 16kHz 16-bit PCM preset for speech-focused workflows.
130
+ VOICE = new(channels: 1, sample_rate: 16_000, bit_depth: 16, sample_format: :pcm)
131
+ end
132
+ end
133
+ end