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,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module Codecs
5
+ # AIFF codec for PCM audio (AIFC is not currently supported).
6
+ class Aiff < Base
7
+ # Recognized filename extensions.
8
+ EXTENSIONS = %w[.aiff .aif].freeze
9
+
10
+ class << self
11
+ # @param io_or_path [String, IO]
12
+ # @return [Boolean]
13
+ def can_read?(io_or_path)
14
+ if io_or_path.is_a?(String)
15
+ return true if EXTENSIONS.include?(File.extname(io_or_path).downcase)
16
+ return false unless File.file?(io_or_path)
17
+ end
18
+
19
+ io, close_io = open_input(io_or_path)
20
+ return false unless io
21
+
22
+ header = io.read(12)
23
+ io.rewind if io.respond_to?(:rewind)
24
+ header&.start_with?("FORM") && header[8, 4] == "AIFF"
25
+ ensure
26
+ io.close if close_io && io
27
+ end
28
+
29
+ # Reads an AIFF file/IO into a sample buffer.
30
+ #
31
+ # @param io_or_path [String, IO]
32
+ # @param format [Wavify::Core::Format, nil]
33
+ # @return [Wavify::Core::SampleBuffer]
34
+ def read(io_or_path, format: nil)
35
+ io, close_io = open_input(io_or_path)
36
+ ensure_seekable!(io)
37
+
38
+ info = parse_chunks(io)
39
+ source_format = info.fetch(:format)
40
+ samples = read_sound_data(io, info, source_format)
41
+ buffer = Core::SampleBuffer.new(samples, source_format)
42
+ format ? buffer.convert(format) : buffer
43
+ ensure
44
+ io.close if close_io && io
45
+ end
46
+
47
+ # Writes a sample buffer as AIFF (PCM only).
48
+ #
49
+ # @param io_or_path [String, IO]
50
+ # @param sample_buffer [Wavify::Core::SampleBuffer]
51
+ # @param format [Wavify::Core::Format, nil]
52
+ # @return [String, IO]
53
+ def write(io_or_path, sample_buffer, format: nil)
54
+ raise InvalidParameterError, "sample_buffer must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
55
+
56
+ target_format = format || sample_buffer.format
57
+ raise InvalidParameterError, "format must be Core::Format" unless target_format.is_a?(Core::Format)
58
+ raise UnsupportedFormatError, "AIFF writer supports PCM only" unless target_format.sample_format == :pcm
59
+
60
+ 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
82
+ end
83
+
84
+ # Streams AIFF decoding in frame chunks.
85
+ #
86
+ # @param io_or_path [String, IO]
87
+ # @param chunk_size [Integer]
88
+ # @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?
91
+ raise InvalidParameterError, "chunk_size must be a positive Integer" unless chunk_size.is_a?(Integer) && chunk_size.positive?
92
+
93
+ io, close_io = open_input(io_or_path)
94
+ ensure_seekable!(io)
95
+
96
+ info = parse_chunks(io)
97
+ format = info.fetch(:format)
98
+ bytes_per_frame = format.block_align
99
+ remaining = info.fetch(:sound_data_size)
100
+ io.seek(info.fetch(:sound_data_offset), IO::SEEK_SET)
101
+
102
+ while remaining.positive?
103
+ bytes = [remaining, bytes_per_frame * chunk_size].min
104
+ chunk_data = read_exact(io, bytes, "truncated SSND data")
105
+ yield Core::SampleBuffer.new(decode_samples(chunk_data, format), format)
106
+ remaining -= bytes
107
+ end
108
+ ensure
109
+ io.close if close_io && io
110
+ end
111
+
112
+ # Streams AIFF encoding through a yielded chunk writer.
113
+ #
114
+ # @param io_or_path [String, IO]
115
+ # @param format [Wavify::Core::Format]
116
+ # @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?
119
+ raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
120
+ raise UnsupportedFormatError, "AIFF stream writer supports PCM only" unless format.sample_format == :pcm
121
+
122
+ chunks = []
123
+ sample_frames = 0
124
+ writer = lambda do |buffer|
125
+ raise InvalidParameterError, "stream chunk must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
126
+
127
+ converted = buffer.format == format ? buffer : buffer.convert(format)
128
+ chunks << encode_samples(converted.samples, format)
129
+ sample_frames += converted.sample_frame_count
130
+ end
131
+ yield writer
132
+
133
+ data = chunks.join
134
+ temp_buffer = Core::SampleBuffer.new(decode_samples(data, format), format)
135
+ write(io_or_path, temp_buffer, format: format)
136
+ end
137
+
138
+ # Reads AIFF metadata without decoding the full audio payload.
139
+ #
140
+ # @param io_or_path [String, IO]
141
+ # @return [Hash]
142
+ def metadata(io_or_path)
143
+ io, close_io = open_input(io_or_path)
144
+ ensure_seekable!(io)
145
+
146
+ info = parse_chunks(io)
147
+ format = info.fetch(:format)
148
+ sample_frame_count = info.fetch(:sample_frame_count)
149
+
150
+ {
151
+ format: format,
152
+ sample_frame_count: sample_frame_count,
153
+ duration: Core::Duration.from_samples(sample_frame_count, format.sample_rate)
154
+ }
155
+ ensure
156
+ io.close if close_io && io
157
+ end
158
+
159
+ private
160
+
161
+ def parse_chunks(io)
162
+ io.rewind
163
+ header = read_exact(io, 12, "missing FORM header")
164
+ raise InvalidFormatError, "invalid AIFF header" unless header.start_with?("FORM")
165
+
166
+ 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"
169
+
170
+ info = {
171
+ format: nil,
172
+ sample_frame_count: nil,
173
+ sound_data_offset: nil,
174
+ sound_data_size: nil
175
+ }
176
+
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
181
+
182
+ chunk_id = chunk_header[0, 4]
183
+ chunk_size = chunk_header[4, 4].unpack1("N")
184
+
185
+ case chunk_id
186
+ when "COMM"
187
+ chunk_data = read_exact(io, chunk_size, "truncated COMM chunk")
188
+ parse_comm_chunk(chunk_data, info)
189
+ when "SSND"
190
+ 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?
194
+
195
+ info[:sound_data_offset] = io.pos
196
+ info[:sound_data_size] = sound_data_size
197
+ skip_bytes(io, sound_data_size)
198
+ else
199
+ skip_bytes(io, chunk_size)
200
+ end
201
+
202
+ io.read(1) if chunk_size.odd?
203
+ end
204
+
205
+ raise InvalidFormatError, "COMM chunk missing" unless info[:format]
206
+ raise InvalidFormatError, "SSND chunk missing" unless info[:sound_data_offset] && info[:sound_data_size]
207
+
208
+ info[:sample_frame_count] = info[:sound_data_size] / info[:format].block_align if info[:sample_frame_count].nil?
209
+
210
+ if (info[:sound_data_size] % info[:format].block_align) != 0
211
+ raise InvalidFormatError, "SSND data size is not aligned to frame size"
212
+ end
213
+
214
+ info
215
+ end
216
+
217
+ def parse_comm_chunk(chunk, info)
218
+ raise InvalidFormatError, "COMM chunk too small" if chunk.bytesize < 18
219
+
220
+ channels, sample_frames, bit_depth = chunk.unpack("n N n")
221
+ sample_rate = decode_extended80(chunk[8, 10])
222
+ rounded_rate = sample_rate.round
223
+
224
+ format = Core::Format.new(
225
+ channels: channels,
226
+ sample_rate: rounded_rate,
227
+ bit_depth: bit_depth,
228
+ sample_format: :pcm
229
+ )
230
+
231
+ info[:format] = format
232
+ info[:sample_frame_count] = sample_frames
233
+ end
234
+
235
+ def read_sound_data(io, info, format)
236
+ io.seek(info.fetch(:sound_data_offset), IO::SEEK_SET)
237
+ data = read_exact(io, info.fetch(:sound_data_size), "truncated SSND data")
238
+ decode_samples(data, format)
239
+ end
240
+
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}"
253
+ end
254
+ end
255
+
256
+ def encode_samples(samples, format)
257
+ case format.bit_depth
258
+ when 8
259
+ samples.pack("c*")
260
+ when 16
261
+ samples.pack("s>*")
262
+ when 24
263
+ encode_pcm24_be(samples)
264
+ when 32
265
+ samples.pack("l>*")
266
+ else
267
+ raise UnsupportedFormatError, "unsupported AIFF bit depth: #{format.bit_depth}"
268
+ end
269
+ end
270
+
271
+ def decode_pcm24_be(data)
272
+ bytes = data.unpack("C*")
273
+ bytes.each_slice(3).map do |b0, b1, b2|
274
+ value = (b0 << 16) | (b1 << 8) | b2
275
+ value -= 0x1000000 if value.anybits?(0x800000)
276
+ value
277
+ end
278
+ end
279
+
280
+ def encode_pcm24_be(samples)
281
+ bytes = samples.flat_map do |sample|
282
+ value = sample.to_i
283
+ value += 0x1000000 if value.negative?
284
+ [(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]
285
+ end
286
+ bytes.pack("C*")
287
+ end
288
+
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)
291
+ end
292
+
293
+ def build_ssnd_chunk(samples, format)
294
+ [0, 0].pack("N2") + encode_samples(samples, format)
295
+ end
296
+
297
+ 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?
302
+ end
303
+
304
+ def chunk_size(chunk_data)
305
+ 8 + chunk_data.bytesize + (chunk_data.bytesize.odd? ? 1 : 0)
306
+ end
307
+
308
+ def decode_extended80(bytes)
309
+ raise InvalidFormatError, "invalid 80-bit float" unless bytes && bytes.bytesize == 10
310
+
311
+ exponent_word = bytes[0, 2].unpack1("n")
312
+ return 0.0 if exponent_word.zero?
313
+
314
+ sign = exponent_word.nobits?(0x8000) ? 1.0 : -1.0
315
+ exponent = (exponent_word & 0x7FFF) - 16_383
316
+ mantissa = bytes[2, 8].unpack1("Q>")
317
+ sign * mantissa * (2.0**(exponent - 63))
318
+ end
319
+
320
+ def encode_extended80(value)
321
+ raise InvalidParameterError, "AIFF sample_rate must be positive" unless value.is_a?(Numeric) && value.positive?
322
+
323
+ fraction, exponent = Math.frexp(value.to_f) # value = fraction * 2**exponent, fraction in [0.5,1)
324
+ exponent_word = (exponent - 1) + 16_383
325
+ mantissa = ((fraction * 2.0) * (2**63)).round
326
+
327
+ if mantissa >= (2**64)
328
+ mantissa >>= 1
329
+ exponent_word += 1
330
+ end
331
+
332
+ [exponent_word, mantissa].pack("n Q>")
333
+ end
334
+
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ # Codec implementations for supported container/audio formats.
5
+ module Codecs
6
+ # Abstract codec interface used by {Registry} and {Wavify::Audio}.
7
+ #
8
+ # Concrete codecs implement the class methods below.
9
+ class Base
10
+ class << self
11
+ # Returns whether this codec can read the given path/IO.
12
+ #
13
+ # @param _io_or_path [String, IO]
14
+ # @return [Boolean]
15
+ def can_read?(_io_or_path)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ # Reads a full audio buffer from a path/IO.
20
+ #
21
+ # @param _io_or_path [String, IO]
22
+ # @param format [Wavify::Core::Format, nil]
23
+ # @return [Wavify::Core::SampleBuffer]
24
+ def read(_io_or_path, format: nil)
25
+ raise NotImplementedError
26
+ end
27
+
28
+ # Writes a full audio buffer to a path/IO.
29
+ #
30
+ # @param _io_or_path [String, IO]
31
+ # @param _sample_buffer [Wavify::Core::SampleBuffer]
32
+ # @param format [Wavify::Core::Format]
33
+ # @return [String, IO]
34
+ def write(_io_or_path, _sample_buffer, format:)
35
+ raise NotImplementedError
36
+ end
37
+
38
+ # Streams decoded audio as chunked sample buffers.
39
+ #
40
+ # @param _io_or_path [String, IO]
41
+ # @param chunk_size [Integer] chunk size in frames
42
+ # @return [Enumerator]
43
+ def stream_read(_io_or_path, chunk_size: 4096)
44
+ raise NotImplementedError
45
+ end
46
+
47
+ # Streams encoded chunks to a path/IO through a yielded writer.
48
+ #
49
+ # @param _io_or_path [String, IO]
50
+ # @param format [Wavify::Core::Format]
51
+ # @return [Enumerator, String, IO]
52
+ def stream_write(_io_or_path, format:)
53
+ raise NotImplementedError
54
+ end
55
+
56
+ # Reads metadata (format and duration-related info) without full decode.
57
+ #
58
+ # @param _io_or_path [String, IO]
59
+ # @return [Hash]
60
+ def metadata(_io_or_path)
61
+ raise NotImplementedError
62
+ end
63
+
64
+ private
65
+
66
+ def open_input(io_or_path)
67
+ return [io_or_path, false] if io_or_path.respond_to?(:read)
68
+ raise InvalidParameterError, "input path must be String or IO: #{io_or_path.inspect}" unless io_or_path.is_a?(String)
69
+
70
+ [File.open(io_or_path, "rb"), true]
71
+ rescue Errno::ENOENT
72
+ raise InvalidFormatError, "input file not found: #{io_or_path}"
73
+ end
74
+
75
+ def open_output(io_or_path)
76
+ return [io_or_path, false] if io_or_path.respond_to?(:write)
77
+ raise InvalidParameterError, "output path must be String or IO: #{io_or_path.inspect}" unless io_or_path.is_a?(String)
78
+
79
+ [File.open(io_or_path, "wb"), true]
80
+ end
81
+
82
+ def read_exact(io, size, message)
83
+ data = io.read(size)
84
+ raise InvalidFormatError, message if data.nil? || data.bytesize != size
85
+
86
+ data
87
+ end
88
+
89
+ def skip_bytes(io, count)
90
+ return if count.zero?
91
+
92
+ if io.respond_to?(:seek)
93
+ io.seek(count, IO::SEEK_CUR)
94
+ else
95
+ discarded = io.read(count)
96
+ raise InvalidFormatError, "truncated chunk body" unless discarded && discarded.bytesize == count
97
+ end
98
+ end
99
+
100
+ def ensure_seekable!(io)
101
+ return if io.respond_to?(:seek) && io.respond_to?(:pos)
102
+
103
+ raise StreamError, "codec requires seekable IO"
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end