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
@@ -25,11 +25,19 @@ module Wavify
25
25
  # @param io_or_path [String, IO]
26
26
  # @param format [Wavify::Core::Format]
27
27
  # @return [Wavify::Core::SampleBuffer]
28
- def read(io_or_path, format: nil)
28
+ def read(io_or_path, format: nil, endianness: :little, signed: nil, float_domain: :normalized, **codec_options)
29
+ validate_no_codec_options!(codec_options, operation: "raw read")
29
30
  target_format = validate_format!(format)
31
+ encoding = normalize_raw_encoding(
32
+ target_format,
33
+ endianness: endianness,
34
+ signed: signed,
35
+ float_domain: float_domain
36
+ )
30
37
  io, close_io = open_input(io_or_path)
31
- data = io.read || "".b
32
- samples = decode_samples(data, target_format)
38
+ data = read_to_end(io)
39
+ validate_frame_alignment!(data.bytesize, target_format)
40
+ samples = decode_samples(data, target_format, **encoding)
33
41
  Core::SampleBuffer.new(samples, target_format)
34
42
  ensure
35
43
  io.close if close_io && io
@@ -41,16 +49,24 @@ module Wavify
41
49
  # @param sample_buffer [Wavify::Core::SampleBuffer]
42
50
  # @param format [Wavify::Core::Format]
43
51
  # @return [String, IO]
44
- def write(io_or_path, sample_buffer, format:)
52
+ def write(io_or_path, sample_buffer, format:, endianness: :little, signed: nil, float_domain: :normalized,
53
+ **codec_options)
54
+ validate_no_codec_options!(codec_options, operation: "raw write")
45
55
  raise InvalidParameterError, "sample_buffer must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
46
56
 
47
57
  target_format = validate_format!(format)
58
+ encoding = normalize_raw_encoding(
59
+ target_format,
60
+ endianness: endianness,
61
+ signed: signed,
62
+ float_domain: float_domain
63
+ )
48
64
  buffer = sample_buffer.format == target_format ? sample_buffer : sample_buffer.convert(target_format)
49
65
 
50
66
  io, close_io = open_output(io_or_path)
51
- io.write(encode_samples(buffer.samples, target_format))
52
- io.flush if io.respond_to?(:flush)
53
- io.rewind if io.respond_to?(:rewind)
67
+ prepare_output!(io, owned: close_io)
68
+ write_all(io, encode_samples(buffer.samples, target_format, **encoding))
69
+ finalize_output!(io, owned: close_io)
54
70
  io_or_path
55
71
  ensure
56
72
  io.close if close_io && io
@@ -62,23 +78,48 @@ module Wavify
62
78
  # @param format [Wavify::Core::Format]
63
79
  # @param chunk_size [Integer]
64
80
  # @return [Enumerator]
65
- def stream_read(io_or_path, format:, chunk_size: 4096)
66
- return enum_for(__method__, io_or_path, format: format, chunk_size: chunk_size) unless block_given?
81
+ def stream_read(io_or_path, format:, chunk_size: 4096, endianness: :little, signed: nil,
82
+ float_domain: :normalized, **codec_options)
83
+ validate_no_codec_options!(codec_options, operation: "raw stream_read")
84
+ unless block_given?
85
+ return enum_for(
86
+ __method__,
87
+ io_or_path,
88
+ format: format,
89
+ chunk_size: chunk_size,
90
+ endianness: endianness,
91
+ signed: signed,
92
+ float_domain: float_domain,
93
+ **codec_options
94
+ )
95
+ end
67
96
 
68
97
  target_format = validate_format!(format)
98
+ encoding = normalize_raw_encoding(
99
+ target_format,
100
+ endianness: endianness,
101
+ signed: signed,
102
+ float_domain: float_domain
103
+ )
69
104
  io, close_io = open_input(io_or_path)
70
105
  bytes_per_frame = target_format.block_align
71
106
  raw_chunk_size = chunk_size * bytes_per_frame
107
+ pending = +"".b
72
108
 
73
109
  loop do
74
110
  chunk = io.read(raw_chunk_size)
75
111
  break if chunk.nil? || chunk.empty?
76
112
 
77
- raise InvalidFormatError, "raw data chunk does not align with format frame size" unless (chunk.bytesize % bytes_per_frame).zero?
113
+ pending << chunk
114
+ usable_bytes = pending.bytesize - (pending.bytesize % bytes_per_frame)
115
+ next if usable_bytes.zero?
78
116
 
79
- samples = decode_samples(chunk, target_format)
117
+ frame_data = pending.byteslice(0, usable_bytes)
118
+ pending = pending.byteslice(usable_bytes, pending.bytesize - usable_bytes) || +"".b
119
+ samples = decode_samples(frame_data, target_format, **encoding)
80
120
  yield Core::SampleBuffer.new(samples, target_format)
81
121
  end
122
+ raise InvalidFormatError, "raw data ends with a partial sample frame" unless pending.empty?
82
123
  ensure
83
124
  io.close if close_io && io
84
125
  end
@@ -88,22 +129,40 @@ module Wavify
88
129
  # @param io_or_path [String, IO]
89
130
  # @param format [Wavify::Core::Format]
90
131
  # @return [Enumerator, String, IO]
91
- def stream_write(io_or_path, format:)
92
- return enum_for(__method__, io_or_path, format: format) unless block_given?
132
+ def stream_write(io_or_path, format:, endianness: :little, signed: nil, float_domain: :normalized,
133
+ **codec_options)
134
+ validate_no_codec_options!(codec_options, operation: "raw stream_write")
135
+ unless block_given?
136
+ return enum_for(
137
+ __method__,
138
+ io_or_path,
139
+ format: format,
140
+ endianness: endianness,
141
+ signed: signed,
142
+ float_domain: float_domain,
143
+ **codec_options
144
+ )
145
+ end
93
146
 
94
147
  target_format = validate_format!(format)
148
+ encoding = normalize_raw_encoding(
149
+ target_format,
150
+ endianness: endianness,
151
+ signed: signed,
152
+ float_domain: float_domain
153
+ )
95
154
  io, close_io = open_output(io_or_path)
155
+ prepare_output!(io, owned: close_io)
96
156
 
97
157
  writer = lambda do |sample_buffer|
98
158
  raise InvalidParameterError, "stream chunk must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
99
159
 
100
160
  buffer = sample_buffer.format == target_format ? sample_buffer : sample_buffer.convert(target_format)
101
- io.write(encode_samples(buffer.samples, target_format))
161
+ write_all(io, encode_samples(buffer.samples, target_format, **encoding))
102
162
  end
103
163
 
104
164
  yield writer
105
- io.flush if io.respond_to?(:flush)
106
- io.rewind if io.respond_to?(:rewind)
165
+ finalize_output!(io, owned: close_io)
107
166
  io_or_path
108
167
  ensure
109
168
  io.close if close_io && io
@@ -114,10 +173,13 @@ module Wavify
114
173
  # @param io_or_path [String, IO]
115
174
  # @param format [Wavify::Core::Format]
116
175
  # @return [Hash]
117
- def metadata(io_or_path, format:)
176
+ def metadata(io_or_path, format:, endianness: :little, signed: nil, float_domain: :normalized, **codec_options)
177
+ validate_no_codec_options!(codec_options, operation: "raw metadata")
118
178
  target_format = validate_format!(format)
179
+ normalize_raw_encoding(target_format, endianness: endianness, signed: signed, float_domain: float_domain)
119
180
  io, close_io = open_input(io_or_path)
120
- byte_size = io.respond_to?(:size) ? io.size : io.read&.bytesize.to_i
181
+ byte_size = byte_size_without_consuming!(io)
182
+ validate_frame_alignment!(byte_size, target_format)
121
183
  sample_frames = byte_size / target_format.block_align
122
184
 
123
185
  {
@@ -137,54 +199,182 @@ module Wavify
137
199
  format
138
200
  end
139
201
 
140
- def decode_samples(data, format)
202
+ def validate_frame_alignment!(byte_size, format)
203
+ return if (byte_size % format.block_align).zero?
204
+
205
+ raise InvalidFormatError, "raw data size does not align with format frame size"
206
+ end
207
+
208
+ def byte_size_without_consuming!(io)
209
+ return io.size - io.pos if io.respond_to?(:size) && io.respond_to?(:pos)
210
+ return io.size if io.respond_to?(:size)
211
+
212
+ if io.respond_to?(:pos) && io.respond_to?(:seek)
213
+ original_position = io.pos
214
+ io.seek(0, IO::SEEK_END)
215
+ byte_size = io.pos - original_position
216
+ io.seek(original_position, IO::SEEK_SET)
217
+ return byte_size
218
+ end
219
+
220
+ raise InvalidParameterError, "raw metadata requires IO with size or seek support"
221
+ end
222
+
223
+ def decode_samples(data, format, endianness:, signed:, float_domain:)
141
224
  if format.sample_format == :float
142
- return data.unpack("e*") if format.bit_depth == 32
143
- return data.unpack("E*") if format.bit_depth == 64
225
+ directive = if format.bit_depth == 32
226
+ endianness == :little ? "e*" : "g*"
227
+ else
228
+ endianness == :little ? "E*" : "G*"
229
+ end
230
+ samples = data.unpack(directive)
231
+ validate_normalized_float_samples!(samples) if float_domain == :normalized
232
+ return samples
144
233
  elsif format.sample_format == :pcm
145
- return data.unpack("C*").map { |byte| byte - 128 } if format.bit_depth == 8
146
- return data.unpack("s<*") if format.bit_depth == 16
147
- return decode_pcm24(data) if format.bit_depth == 24
148
- return data.unpack("l<*") if format.bit_depth == 32
234
+ return decode_pcm8(data, signed: signed) if format.bit_depth == 8
235
+ return decode_pcm_word(data, bits: 16, endianness: endianness, signed: signed) if format.bit_depth == 16
236
+ return decode_pcm24(data, endianness: endianness, signed: signed) if format.bit_depth == 24
237
+ return decode_pcm_word(data, bits: 32, endianness: endianness, signed: signed) if format.bit_depth == 32
149
238
  end
150
239
 
151
240
  raise UnsupportedFormatError, "unsupported raw format: #{format.sample_format}/#{format.bit_depth}"
152
241
  end
153
242
 
154
- def encode_samples(samples, format)
243
+ def encode_samples(samples, format, endianness:, signed:, float_domain:)
155
244
  if format.sample_format == :float
156
- normalized = samples.map { |sample| sample.to_f.clamp(-1.0, 1.0) }
157
- return normalized.pack("e*") if format.bit_depth == 32
158
- return normalized.pack("E*") if format.bit_depth == 64
245
+ values = if float_domain == :normalized
246
+ samples.map { |sample| sample.to_f.clamp(-1.0, 1.0) }
247
+ else
248
+ samples.map(&:to_f)
249
+ end
250
+ directive = if format.bit_depth == 32
251
+ endianness == :little ? "e*" : "g*"
252
+ else
253
+ endianness == :little ? "E*" : "G*"
254
+ end
255
+ return values.pack(directive)
159
256
  elsif format.sample_format == :pcm
160
257
  min = -(2**(format.bit_depth - 1))
161
258
  max = (2**(format.bit_depth - 1)) - 1
162
259
  ints = samples.map { |sample| sample.to_i.clamp(min, max) }
163
- return ints.map { |sample| sample + 128 }.pack("C*") if format.bit_depth == 8
164
- return ints.pack("s<*") if format.bit_depth == 16
165
- return encode_pcm24(ints) if format.bit_depth == 24
166
- return ints.pack("l<*") if format.bit_depth == 32
260
+ return encode_pcm8(ints, signed: signed) if format.bit_depth == 8
261
+ return encode_pcm_word(ints, bits: 16, endianness: endianness, signed: signed) if format.bit_depth == 16
262
+ return encode_pcm24(ints, endianness: endianness, signed: signed) if format.bit_depth == 24
263
+ return encode_pcm_word(ints, bits: 32, endianness: endianness, signed: signed) if format.bit_depth == 32
167
264
  end
168
265
 
169
266
  raise UnsupportedFormatError, "unsupported raw format: #{format.sample_format}/#{format.bit_depth}"
170
267
  end
171
268
 
172
- def decode_pcm24(data)
173
- bytes = data.unpack("C*")
174
- bytes.each_slice(3).map do |b0, b1, b2|
175
- value = b0 | (b1 << 8) | (b2 << 16)
176
- value -= 0x1000000 if value.anybits?(0x800000)
177
- value
269
+ def decode_pcm8(data, signed:)
270
+ return data.unpack("c*") if signed
271
+
272
+ data.each_byte.map { |byte| byte - 128 }
273
+ end
274
+
275
+ def encode_pcm8(samples, signed:)
276
+ return samples.pack("c*") if signed
277
+
278
+ samples.map { |sample| sample + 128 }.pack("C*")
279
+ end
280
+
281
+ def decode_pcm_word(data, bits:, endianness:, signed:)
282
+ directive = pcm_word_directive(bits, endianness, signed)
283
+ values = data.unpack("#{directive}*")
284
+ return values if signed
285
+
286
+ midpoint = 2**(bits - 1)
287
+ values.map! { |value| value - midpoint }
288
+ end
289
+
290
+ def encode_pcm_word(samples, bits:, endianness:, signed:)
291
+ directive = pcm_word_directive(bits, endianness, signed)
292
+ values = if signed
293
+ samples
294
+ else
295
+ midpoint = 2**(bits - 1)
296
+ samples.map { |sample| sample + midpoint }
297
+ end
298
+ values.pack("#{directive}*")
299
+ end
300
+
301
+ def pcm_word_directive(bits, endianness, signed)
302
+ base = if bits == 16
303
+ signed ? "s" : "S"
304
+ else
305
+ signed ? "l" : "L"
306
+ end
307
+ "#{base}#{endianness == :little ? '<' : '>'}"
308
+ end
309
+
310
+ def decode_pcm24(data, endianness:, signed:)
311
+ midpoint = 0x800000
312
+ samples = Array.new(data.bytesize / 3)
313
+ samples.length.times do |index|
314
+ offset = index * 3
315
+ first = data.getbyte(offset)
316
+ middle = data.getbyte(offset + 1)
317
+ last = data.getbyte(offset + 2)
318
+ value = if endianness == :little
319
+ first | (middle << 8) | (last << 16)
320
+ else
321
+ last | (middle << 8) | (first << 16)
322
+ end
323
+ samples[index] = signed ? (value.anybits?(midpoint) ? value - 0x1000000 : value) : value - midpoint
324
+ end
325
+ samples
326
+ end
327
+
328
+ def encode_pcm24(samples, endianness:, signed:)
329
+ bytes = String.new(capacity: samples.length * 3, encoding: Encoding::BINARY)
330
+ samples.each do |sample|
331
+ value = signed ? (sample.negative? ? sample + 0x1000000 : sample) : sample + 0x800000
332
+ low = value & 0xFF
333
+ middle = (value >> 8) & 0xFF
334
+ high = (value >> 16) & 0xFF
335
+ if endianness == :little
336
+ bytes << low << middle << high
337
+ else
338
+ bytes << high << middle << low
339
+ end
178
340
  end
341
+ bytes
179
342
  end
180
343
 
181
- def encode_pcm24(samples)
182
- bytes = samples.flat_map do |sample|
183
- value = sample
184
- value += 0x1000000 if value.negative?
185
- [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF]
344
+ def normalize_raw_encoding(format, endianness:, signed:, float_domain:)
345
+ byte_order = endianness.to_sym
346
+ unless %i[little big].include?(byte_order)
347
+ raise InvalidParameterError, "endianness must be :little or :big"
186
348
  end
187
- bytes.pack("C*")
349
+
350
+ domain = float_domain.to_sym
351
+ unless %i[normalized ieee].include?(domain)
352
+ raise InvalidParameterError, "float_domain must be :normalized or :ieee"
353
+ end
354
+ if format.sample_format == :float
355
+ raise InvalidParameterError, "signed is only valid for PCM raw audio" unless signed.nil?
356
+
357
+ return { endianness: byte_order, signed: nil, float_domain: domain }
358
+ end
359
+ unless signed.nil? || signed == true || signed == false
360
+ raise InvalidParameterError, "signed must be true, false, or nil"
361
+ end
362
+
363
+ {
364
+ endianness: byte_order,
365
+ signed: signed.nil? ? format.bit_depth != 8 : signed,
366
+ float_domain: domain
367
+ }
368
+ rescue NoMethodError
369
+ raise InvalidParameterError, "endianness and float_domain must be Symbol/String"
370
+ end
371
+
372
+ def validate_normalized_float_samples!(samples)
373
+ invalid_index = samples.index { |sample| !sample.finite? || !sample.between?(-1.0, 1.0) }
374
+ return unless invalid_index
375
+
376
+ raise InvalidFormatError,
377
+ "normalized raw float sample #{invalid_index} must be finite and within -1.0..1.0"
188
378
  end
189
379
 
190
380
  end