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,483 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ # High-level immutable audio object backed by a {Core::SampleBuffer}.
5
+ #
6
+ # Most processing methods return a new instance and expose `!` variants for
7
+ # in-place replacement of the internal buffer.
8
+ class Audio
9
+ attr_reader :buffer
10
+
11
+ # Reads audio from a file path using codec auto-detection.
12
+ #
13
+ # @param path [String]
14
+ # @param format [Core::Format, nil] optional target format to convert into
15
+ # @param codec_options [Hash] codec-specific options forwarded to `.read`
16
+ # @return [Audio]
17
+ def self.read(path, format: nil, codec_options: nil)
18
+ codec = Codecs::Registry.detect(path)
19
+ options = codec_options || {}
20
+ raise InvalidParameterError, "codec_options must be a Hash" unless options.is_a?(Hash)
21
+
22
+ new(codec.read(path, format: format, **options))
23
+ end
24
+
25
+ # Mixes multiple audio objects and clips summed samples into range.
26
+ #
27
+ # @param audios [Array<Audio>]
28
+ # @return [Audio]
29
+ def self.mix(*audios)
30
+ raise InvalidParameterError, "at least one Audio is required" if audios.empty?
31
+ raise InvalidParameterError, "all arguments must be Audio instances" unless audios.all? { |audio| audio.is_a?(self) }
32
+
33
+ sample_rates = audios.map { |audio| audio.format.sample_rate }.uniq
34
+ raise InvalidParameterError, "all audios must have the same sample_rate to mix" if sample_rates.length > 1
35
+
36
+ target_format = audios.first.format
37
+ work_format = target_format.with(sample_format: :float, bit_depth: 32)
38
+ converted = audios.map { |audio| audio.buffer.convert(work_format) }
39
+ max_frames = converted.map(&:sample_frame_count).max || 0
40
+ channels = work_format.channels
41
+ mixed = Array.new(max_frames * channels, 0.0)
42
+
43
+ converted.each do |buffer|
44
+ buffer.samples.each_with_index do |sample, index|
45
+ mixed[index] += sample
46
+ end
47
+ end
48
+
49
+ mixed.map! { |sample| clip_value(sample, -1.0, 1.0) }
50
+ new(Core::SampleBuffer.new(mixed, work_format).convert(target_format))
51
+ end
52
+
53
+ # Creates a streaming processing pipeline for an input path/IO.
54
+ #
55
+ # @param path_or_io [String, IO]
56
+ # @param chunk_size [Integer] chunk size in frames
57
+ # @param format [Core::Format, nil] optional source format override
58
+ # @param codec_options [Hash] codec-specific options forwarded to `.stream_read`
59
+ # @return [Core::Stream]
60
+ def self.stream(path_or_io, chunk_size: 4096, format: nil, codec_options: nil)
61
+ codec = Codecs::Registry.detect(path_or_io)
62
+ source_format = format || codec.metadata(path_or_io)[:format]
63
+ options = codec_options || {}
64
+ raise InvalidParameterError, "codec_options must be a Hash" unless options.is_a?(Hash)
65
+
66
+ stream = Core::Stream.new(
67
+ path_or_io,
68
+ codec: codec,
69
+ format: source_format,
70
+ chunk_size: chunk_size,
71
+ codec_read_options: options
72
+ )
73
+ return stream unless block_given?
74
+
75
+ yield stream
76
+ stream
77
+ end
78
+
79
+ # Generates a tone using the built-in oscillator.
80
+ #
81
+ # @param frequency [Numeric] oscillator frequency in Hz
82
+ # @param duration [Numeric] duration in seconds
83
+ # @param format [Core::Format] output format
84
+ # @param waveform [Symbol] `:sine`, `:square`, `:triangle`, `:sawtooth`, `:white_noise`
85
+ # @return [Audio]
86
+ def self.tone(frequency:, duration:, format:, waveform: :sine)
87
+ oscillator = DSP::Oscillator.new(
88
+ waveform: waveform,
89
+ frequency: frequency
90
+ )
91
+ new(oscillator.generate(duration, format: format))
92
+ end
93
+
94
+ # Builds silent audio in the requested format.
95
+ #
96
+ # @param duration_seconds [Numeric]
97
+ # @param format [Core::Format]
98
+ # @return [Audio]
99
+ def self.silence(duration_seconds, format:)
100
+ unless duration_seconds.is_a?(Numeric) && duration_seconds >= 0
101
+ raise InvalidParameterError, "duration_seconds must be a non-negative Numeric: #{duration_seconds.inspect}"
102
+ end
103
+ raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
104
+
105
+ frame_count = (duration_seconds.to_f * format.sample_rate).round
106
+ default_sample = format.sample_format == :float ? 0.0 : 0
107
+ samples = Array.new(frame_count * format.channels, default_sample)
108
+ new(Core::SampleBuffer.new(samples, format))
109
+ end
110
+
111
+ # @param buffer [Core::SampleBuffer]
112
+ def initialize(buffer)
113
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
114
+
115
+ @buffer = buffer
116
+ end
117
+
118
+ # Writes the audio to a file path using codec auto-detection.
119
+ #
120
+ # @param path [String]
121
+ # @param format [Core::Format, nil] optional output format
122
+ # @return [Audio] self
123
+ def write(path, format: nil)
124
+ codec = Codecs::Registry.detect(path)
125
+ codec.write(path, @buffer, format: format || @buffer.format)
126
+ self
127
+ end
128
+
129
+ # @return [Core::Format]
130
+ def format
131
+ @buffer.format
132
+ end
133
+
134
+ # @return [Core::Duration]
135
+ def duration
136
+ @buffer.duration
137
+ end
138
+
139
+ # @return [Integer] frame count
140
+ def sample_frame_count
141
+ @buffer.sample_frame_count
142
+ end
143
+
144
+ # Converts to a new format/channels.
145
+ #
146
+ # @param new_format [Core::Format]
147
+ # @return [Audio]
148
+ def convert(new_format)
149
+ self.class.new(@buffer.convert(new_format))
150
+ end
151
+
152
+ # Splits the audio into two clips at a time offset.
153
+ #
154
+ # @param at [Numeric, Core::Duration] split point in seconds
155
+ # @return [Array<Audio>] `[left, right]`
156
+ def split(at:)
157
+ split_frame = coerce_split_point_to_frame(at)
158
+ left = @buffer.slice(0, split_frame)
159
+ right = @buffer.slice(split_frame, [@buffer.sample_frame_count - split_frame, 0].max)
160
+
161
+ [self.class.new(left), self.class.new(right)]
162
+ end
163
+
164
+ # Repeats the audio content.
165
+ #
166
+ # @param times [Integer] repetition count
167
+ # @return [Audio]
168
+ def loop(times:)
169
+ raise InvalidParameterError, "times must be a non-negative Integer" unless times.is_a?(Integer) && times >= 0
170
+
171
+ return self.class.new(Core::SampleBuffer.new([], @buffer.format)) if times.zero?
172
+
173
+ result = @buffer
174
+ (times - 1).times { result += @buffer }
175
+ self.class.new(result)
176
+ end
177
+
178
+ # In-place variant of {#loop}.
179
+ #
180
+ # @param times [Integer]
181
+ # @return [Audio] self
182
+ def loop!(times:)
183
+ replace_buffer!(self.loop(times: times).buffer)
184
+ self
185
+ end
186
+
187
+ # Reverses sample frame order.
188
+ #
189
+ # @return [Audio]
190
+ def reverse
191
+ self.class.new(@buffer.reverse)
192
+ end
193
+
194
+ # In-place variant of {#reverse}.
195
+ #
196
+ # @return [Audio] self
197
+ def reverse!
198
+ replace_buffer!(reverse.buffer)
199
+ self
200
+ end
201
+
202
+ # Applies linear gain in decibels.
203
+ #
204
+ # @param db [Numeric]
205
+ # @return [Audio]
206
+ def gain(db)
207
+ factor = 10.0**(db.to_f / 20.0)
208
+ transform_samples do |samples, _format|
209
+ samples.map { |sample| (sample * factor).clamp(-1.0, 1.0) }
210
+ end
211
+ end
212
+
213
+ # In-place variant of {#gain}.
214
+ #
215
+ # @param db [Numeric]
216
+ # @return [Audio] self
217
+ def gain!(db)
218
+ replace_buffer!(gain(db).buffer)
219
+ self
220
+ end
221
+
222
+ # Scales audio so the peak amplitude reaches the target dBFS.
223
+ #
224
+ # @param target_db [Numeric]
225
+ # @return [Audio]
226
+ def normalize(target_db: 0.0)
227
+ transform_samples do |samples, _format|
228
+ peak = samples.map(&:abs).max || 0.0
229
+ next samples if peak.zero?
230
+
231
+ target = 10.0**(target_db.to_f / 20.0)
232
+ factor = target / peak
233
+ samples.map { |sample| (sample * factor).clamp(-1.0, 1.0) }
234
+ end
235
+ end
236
+
237
+ # In-place variant of {#normalize}.
238
+ #
239
+ # @param target_db [Numeric]
240
+ # @return [Audio] self
241
+ def normalize!(target_db: 0.0)
242
+ replace_buffer!(normalize(target_db: target_db).buffer)
243
+ self
244
+ end
245
+
246
+ # Removes leading and trailing frames below a threshold.
247
+ #
248
+ # @param threshold [Numeric] amplitude threshold in 0.0..1.0
249
+ # @return [Audio]
250
+ def trim(threshold: 0.01)
251
+ raise InvalidParameterError, "threshold must be Numeric in 0.0..1.0" unless threshold.is_a?(Numeric) && threshold.between?(0.0, 1.0)
252
+
253
+ float_buffer = @buffer.convert(float_work_format(@buffer.format))
254
+ channels = float_buffer.format.channels
255
+ frames = float_buffer.samples.each_slice(channels).to_a
256
+ first = frames.index { |frame| frame.any? { |sample| sample.abs >= threshold } }
257
+ return self.class.new(Core::SampleBuffer.new([], @buffer.format)) unless first
258
+
259
+ last = frames.rindex { |frame| frame.any? { |sample| sample.abs >= threshold } }
260
+ trimmed = frames[first..last].flatten
261
+ self.class.new(Core::SampleBuffer.new(trimmed, float_buffer.format).convert(@buffer.format))
262
+ end
263
+
264
+ # In-place variant of {#trim}.
265
+ #
266
+ # @param threshold [Numeric]
267
+ # @return [Audio] self
268
+ def trim!(threshold: 0.01)
269
+ replace_buffer!(trim(threshold: threshold).buffer)
270
+ self
271
+ end
272
+
273
+ # Applies a linear fade-in.
274
+ #
275
+ # @param seconds [Numeric]
276
+ # @return [Audio]
277
+ def fade_in(seconds)
278
+ apply_fade(seconds: seconds, mode: :in)
279
+ end
280
+
281
+ # In-place variant of {#fade_in}.
282
+ #
283
+ # @param seconds [Numeric]
284
+ # @return [Audio] self
285
+ def fade_in!(seconds)
286
+ replace_buffer!(fade_in(seconds).buffer)
287
+ self
288
+ end
289
+
290
+ # Applies a linear fade-out.
291
+ #
292
+ # @param seconds [Numeric]
293
+ # @return [Audio]
294
+ def fade_out(seconds)
295
+ apply_fade(seconds: seconds, mode: :out)
296
+ end
297
+
298
+ # In-place variant of {#fade_out}.
299
+ #
300
+ # @param seconds [Numeric]
301
+ # @return [Audio] self
302
+ def fade_out!(seconds)
303
+ replace_buffer!(fade_out(seconds).buffer)
304
+ self
305
+ end
306
+
307
+ # Constant-power pan for mono/stereo sources.
308
+ #
309
+ # Mono inputs are first upmixed to stereo.
310
+ #
311
+ # @param position [Numeric] `-1.0` (left) to `1.0` (right)
312
+ # @return [Audio]
313
+ def pan(position)
314
+ validate_pan_position!(position)
315
+
316
+ case @buffer.format.channels
317
+ when 1
318
+ source_format = @buffer.format.with(channels: 2)
319
+ when 2
320
+ source_format = @buffer.format
321
+ else
322
+ raise InvalidParameterError, "pan is only supported for mono/stereo input"
323
+ end
324
+
325
+ transform_samples(target_format: source_format) do |samples, _format|
326
+ left_gain, right_gain = constant_power_pan_gains(position.to_f)
327
+ result = samples.dup
328
+ result.each_slice(2).with_index do |(left, right), frame_index|
329
+ base = frame_index * 2
330
+ result[base] = (left * left_gain).clamp(-1.0, 1.0)
331
+ result[base + 1] = (right * right_gain).clamp(-1.0, 1.0)
332
+ end
333
+ result
334
+ end
335
+ end
336
+
337
+ # In-place variant of {#pan}.
338
+ #
339
+ # @param position [Numeric]
340
+ # @return [Audio] self
341
+ def pan!(position)
342
+ replace_buffer!(pan(position).buffer)
343
+ self
344
+ end
345
+
346
+ # Applies an effect/processor object to the audio buffer.
347
+ #
348
+ # Accepted interfaces: `#process`, `#call`, or `#apply`.
349
+ #
350
+ # @param effect [Object]
351
+ # @return [Audio]
352
+ def apply(effect)
353
+ processed = if effect.respond_to?(:process)
354
+ effect.process(@buffer)
355
+ elsif effect.respond_to?(:call)
356
+ effect.call(@buffer)
357
+ elsif effect.respond_to?(:apply)
358
+ effect.apply(@buffer)
359
+ else
360
+ raise InvalidParameterError, "effect must respond to :process, :call, or :apply"
361
+ end
362
+
363
+ raise ProcessingError, "effect must return Core::SampleBuffer" unless processed.is_a?(Core::SampleBuffer)
364
+
365
+ self.class.new(processed)
366
+ end
367
+
368
+ # In-place variant of {#apply}.
369
+ #
370
+ # @param effect [Object]
371
+ # @return [Audio] self
372
+ def apply!(effect)
373
+ replace_buffer!(apply(effect).buffer)
374
+ self
375
+ end
376
+
377
+ # Returns the absolute peak amplitude in float working space.
378
+ #
379
+ # @return [Float] 0.0..1.0
380
+ def peak_amplitude
381
+ float_buffer = @buffer.convert(float_work_format(@buffer.format))
382
+ float_buffer.samples.map(&:abs).max || 0.0
383
+ end
384
+
385
+ # Returns RMS amplitude in float working space.
386
+ #
387
+ # @return [Float] 0.0..1.0
388
+ def rms_amplitude
389
+ float_buffer = @buffer.convert(float_work_format(@buffer.format))
390
+ return 0.0 if float_buffer.samples.empty?
391
+
392
+ square_sum = float_buffer.samples.sum { |sample| sample * sample }
393
+ Math.sqrt(square_sum / float_buffer.samples.length)
394
+ end
395
+
396
+ private
397
+
398
+ def apply_fade(seconds:, mode:)
399
+ raise InvalidParameterError, "seconds must be a non-negative Numeric" unless seconds.is_a?(Numeric) && seconds >= 0
400
+
401
+ transform_samples do |samples, format|
402
+ channels = format.channels
403
+ sample_frames = samples.length / channels
404
+ fade_frames = [(seconds.to_f * format.sample_rate).round, sample_frames].min
405
+ return samples if fade_frames.zero?
406
+
407
+ result = samples.dup
408
+ start_frame = sample_frames - fade_frames
409
+
410
+ result.each_slice(channels).with_index do |frame, frame_index|
411
+ factor = case mode
412
+ when :in
413
+ frame_index < fade_frames ? frame_index.to_f / fade_frames : 1.0
414
+ when :out
415
+ frame_index >= start_frame ? (sample_frames - frame_index - 1).to_f / fade_frames : 1.0
416
+ else
417
+ 1.0
418
+ end
419
+
420
+ factor = factor.clamp(0.0, 1.0)
421
+ base = frame_index * channels
422
+ frame.each_index do |channel_index|
423
+ result[base + channel_index] = (frame[channel_index] * factor).clamp(-1.0, 1.0)
424
+ end
425
+ end
426
+
427
+ result
428
+ end
429
+ end
430
+
431
+ def transform_samples(target_format: @buffer.format)
432
+ raise InvalidParameterError, "target_format must be Core::Format" unless target_format.is_a?(Core::Format)
433
+
434
+ work_format = float_work_format(target_format)
435
+ working_buffer = @buffer.convert(work_format)
436
+ transformed_samples = yield(working_buffer.samples.dup, work_format)
437
+ processed = Core::SampleBuffer.new(transformed_samples, work_format).convert(target_format)
438
+ self.class.new(processed)
439
+ end
440
+
441
+ def float_work_format(format)
442
+ format.with(sample_format: :float, bit_depth: 32)
443
+ end
444
+
445
+ def constant_power_pan_gains(position)
446
+ angle = (position + 1.0) * (Math::PI / 4.0)
447
+ [Math.cos(angle), Math.sin(angle)]
448
+ end
449
+
450
+ def validate_pan_position!(position)
451
+ raise InvalidParameterError, "position must be Numeric in -1.0..1.0" unless position.is_a?(Numeric) && position.between?(-1.0, 1.0)
452
+ end
453
+
454
+ def replace_buffer!(new_buffer)
455
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless new_buffer.is_a?(Core::SampleBuffer)
456
+
457
+ @buffer = new_buffer
458
+ end
459
+
460
+ def coerce_split_point_to_frame(at)
461
+ frame = case at
462
+ when Core::Duration
463
+ (at.total_seconds * @buffer.format.sample_rate).round
464
+ when Numeric
465
+ (at.to_f * @buffer.format.sample_rate).round
466
+ else
467
+ raise InvalidParameterError, "split point must be Numeric or Core::Duration"
468
+ end
469
+
470
+ raise InvalidParameterError, "split point is out of range: #{frame}" if frame.negative? || frame > @buffer.sample_frame_count
471
+
472
+ frame
473
+ end
474
+
475
+ def self.clip_value(value, min, max)
476
+ return min if value < min
477
+ return max if value > max
478
+
479
+ value
480
+ end
481
+ private_class_method :clip_value
482
+ end
483
+ end