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,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module Core
5
+ # Immutable interleaved sample container with format metadata.
6
+ #
7
+ # Samples are stored in interleaved order (`L, R, L, R, ...`) for
8
+ # multi-channel audio.
9
+ class SampleBuffer
10
+ include Enumerable
11
+
12
+ attr_reader :samples, :format, :duration
13
+
14
+ # @param samples [Array<Numeric>] interleaved sample values
15
+ # @param format [Format] sample encoding and channel layout
16
+ def initialize(samples, format)
17
+ raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Format)
18
+ raise InvalidParameterError, "samples must be an Array" unless samples.is_a?(Array)
19
+
20
+ validate_samples!(samples)
21
+ validate_interleaving!(samples.length, format.channels)
22
+
23
+ @format = format
24
+ @samples = coerce_samples(samples, format).freeze
25
+ @duration = Duration.from_samples(sample_frame_count, format.sample_rate)
26
+ end
27
+
28
+ # Enumerates sample values in interleaved order.
29
+ #
30
+ # @yield [sample]
31
+ # @yieldparam sample [Numeric]
32
+ # @return [Enumerator, Array<Numeric>]
33
+ def each(&)
34
+ return enum_for(:each) unless block_given?
35
+
36
+ @samples.each(&)
37
+ end
38
+
39
+ # @return [Integer] number of interleaved samples
40
+ def length
41
+ @samples.length
42
+ end
43
+
44
+ alias size length
45
+
46
+ # @return [Integer] number of audio frames
47
+ def sample_frame_count
48
+ @samples.length / @format.channels
49
+ end
50
+
51
+ # Converts the buffer to another audio format/channels.
52
+ #
53
+ # @param new_format [Format]
54
+ # @return [SampleBuffer]
55
+ def convert(new_format)
56
+ raise InvalidParameterError, "new_format must be Core::Format" unless new_format.is_a?(Format)
57
+
58
+ frames = frame_view.map do |frame|
59
+ frame.map { |sample| to_normalized_float(sample, @format) }
60
+ end
61
+
62
+ converted_frames = convert_channels(frames, new_format.channels)
63
+ converted_samples = converted_frames.flatten.map do |sample|
64
+ from_normalized_float(sample, new_format)
65
+ end
66
+
67
+ self.class.new(converted_samples, new_format)
68
+ rescue InvalidParameterError, InvalidFormatError
69
+ raise
70
+ rescue StandardError => e
71
+ raise BufferConversionError, "failed to convert sample buffer: #{e.message}"
72
+ end
73
+
74
+ # Reverses sample frame order while preserving channel ordering per frame.
75
+ #
76
+ # @return [SampleBuffer]
77
+ def reverse
78
+ reversed = frame_view.reverse.flatten
79
+ self.class.new(reversed, @format)
80
+ end
81
+
82
+ # Slices the buffer by frame index and frame count.
83
+ #
84
+ # @param start_frame [Integer]
85
+ # @param frame_length [Integer]
86
+ # @return [SampleBuffer]
87
+ def slice(start_frame, frame_length)
88
+ unless start_frame.is_a?(Integer) && start_frame >= 0
89
+ raise InvalidParameterError, "start_frame must be a non-negative Integer: #{start_frame.inspect}"
90
+ end
91
+ unless frame_length.is_a?(Integer) && frame_length >= 0
92
+ raise InvalidParameterError, "frame_length must be a non-negative Integer: #{frame_length.inspect}"
93
+ end
94
+
95
+ sliced = frame_view.slice(start_frame, frame_length) || []
96
+ self.class.new(sliced.flatten, @format)
97
+ end
98
+
99
+ def concat(other)
100
+ raise InvalidParameterError, "other must be Core::SampleBuffer" unless other.is_a?(SampleBuffer)
101
+
102
+ rhs = other.format == @format ? other : other.convert(@format)
103
+ self.class.new(@samples + rhs.samples, @format)
104
+ end
105
+
106
+ alias + concat
107
+
108
+ private
109
+
110
+ def validate_samples!(samples)
111
+ invalid_index = samples.index { |sample| !sample.is_a?(Numeric) }
112
+ return unless invalid_index
113
+
114
+ raise InvalidParameterError, "sample at index #{invalid_index} must be Numeric"
115
+ end
116
+
117
+ def validate_interleaving!(sample_count, channels)
118
+ return if (sample_count % channels).zero?
119
+
120
+ raise InvalidParameterError,
121
+ "sample count (#{sample_count}) must be divisible by channels (#{channels})"
122
+ end
123
+
124
+ def coerce_samples(samples, format)
125
+ samples.map do |sample|
126
+ if format.sample_format == :float
127
+ sample.to_f.clamp(-1.0, 1.0)
128
+ else
129
+ coerce_pcm_sample(sample, format.bit_depth)
130
+ end
131
+ end
132
+ end
133
+
134
+ def coerce_pcm_sample(sample, bit_depth)
135
+ if sample.is_a?(Float) && sample.between?(-1.0, 1.0)
136
+ float_to_pcm(sample, bit_depth)
137
+ else
138
+ min = -(2**(bit_depth - 1))
139
+ max = (2**(bit_depth - 1)) - 1
140
+ sample.to_i.clamp(min, max)
141
+ end
142
+ end
143
+
144
+ def frame_view
145
+ @samples.each_slice(@format.channels).map(&:dup)
146
+ end
147
+
148
+ def to_normalized_float(sample, format)
149
+ return sample.to_f.clamp(-1.0, 1.0) if format.sample_format == :float
150
+
151
+ max = ((2**(format.bit_depth - 1)) - 1).to_f
152
+ (sample.to_f / max).clamp(-1.0, 1.0)
153
+ end
154
+
155
+ def from_normalized_float(sample, format)
156
+ value = sample.to_f.clamp(-1.0, 1.0)
157
+ return value if format.sample_format == :float
158
+
159
+ float_to_pcm(value, format.bit_depth)
160
+ end
161
+
162
+ def float_to_pcm(sample, bit_depth)
163
+ max = (2**(bit_depth - 1)) - 1
164
+ min = -(2**(bit_depth - 1))
165
+ (sample * max).round.clamp(min, max)
166
+ end
167
+
168
+ def convert_channels(frames, target_channels)
169
+ return frames if frames.empty?
170
+
171
+ source_channels = frames.first.length
172
+ return frames if source_channels == target_channels
173
+
174
+ return frames.map { |frame| [frame.sum / frame.length.to_f] } if target_channels == 1
175
+
176
+ return frames.map { |frame| Array.new(target_channels, frame.first) } if source_channels == 1
177
+
178
+ return frames.map { |frame| downmix_to_stereo(frame) } if source_channels > 2 && target_channels == 2
179
+
180
+ return frames.map { |frame| truncate_and_fold(frame, target_channels) } if source_channels > target_channels
181
+
182
+ frames.map { |frame| upmix_with_duplication(frame, target_channels) }
183
+ end
184
+
185
+ def downmix_to_stereo(frame)
186
+ left = frame[0] || 0.0
187
+ right = frame[1] || left
188
+ center = frame[2] || 0.0
189
+ lfe = frame[3] || 0.0
190
+ left_surround = frame[4] || 0.0
191
+ right_surround = frame[5] || 0.0
192
+ extras = (frame[6..] || []).sum
193
+
194
+ left_mix = left + (center * 0.707) + (lfe * 0.5) + (left_surround * 0.707) + (extras * 0.5)
195
+ right_mix = right + (center * 0.707) + (lfe * 0.5) + (right_surround * 0.707) + (extras * 0.5)
196
+ [left_mix.clamp(-1.0, 1.0), right_mix.clamp(-1.0, 1.0)]
197
+ end
198
+
199
+ def truncate_and_fold(frame, target_channels)
200
+ reduced = frame.first(target_channels).dup
201
+ extras = frame.drop(target_channels)
202
+ return reduced if extras.empty?
203
+
204
+ extra_mix = extras.sum / target_channels.to_f
205
+ reduced.map { |sample| (sample + extra_mix).clamp(-1.0, 1.0) }
206
+ end
207
+
208
+ def upmix_with_duplication(frame, target_channels)
209
+ result = frame.dup
210
+ result << frame[result.length % frame.length] while result.length < target_channels
211
+ result
212
+ end
213
+
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module Core
5
+ # Lazy streaming pipeline for chunk-based audio processing.
6
+ #
7
+ # Instances are typically created via {Wavify::Audio.stream}.
8
+ class Stream
9
+ include Enumerable
10
+
11
+ attr_reader :format, :chunk_size
12
+
13
+ # @param source [String, IO] input path or IO
14
+ # @param codec [Class] codec class implementing `stream_read/stream_write`
15
+ # @param format [Format, nil] source format (may be inferred later)
16
+ # @param chunk_size [Integer] chunk size in frames
17
+ # @param codec_read_options [Hash] codec-specific options forwarded to `stream_read`
18
+ def initialize(source, codec:, format:, chunk_size: 4096, codec_read_options: {})
19
+ @source = source
20
+ @codec = codec
21
+ @format = format
22
+ @chunk_size = validate_chunk_size!(chunk_size)
23
+ @codec_read_options = validate_codec_read_options!(codec_read_options)
24
+ @pipeline = []
25
+ end
26
+
27
+ # Adds a processor to the stream pipeline.
28
+ #
29
+ # Processors may respond to `#call`, `#process`, or `#apply`.
30
+ #
31
+ # @param processor [#call, #process, #apply, nil]
32
+ # @return [Stream] self
33
+ def pipe(processor = nil, &block)
34
+ candidate = processor || block
35
+ unless candidate.respond_to?(:call) || candidate.respond_to?(:process) || candidate.respond_to?(:apply)
36
+ raise InvalidParameterError, "processor must respond to :call, :process, or :apply"
37
+ end
38
+
39
+ @pipeline << candidate
40
+ self
41
+ end
42
+
43
+ # Iterates processed chunks.
44
+ #
45
+ # @yield [chunk]
46
+ # @yieldparam chunk [SampleBuffer]
47
+ # @return [Enumerator]
48
+ def each_chunk
49
+ return enum_for(:each_chunk) unless block_given?
50
+
51
+ @codec.stream_read(@source, chunk_size: @chunk_size, **@codec_read_options) do |chunk|
52
+ @format ||= chunk.format
53
+ yield apply_pipeline(chunk)
54
+ end
55
+ end
56
+
57
+ alias each each_chunk
58
+
59
+ # Writes the processed stream to a path or writable IO.
60
+ #
61
+ # @param path_or_io [String, IO]
62
+ # @param format [Format, nil] output format (required for raw output if unknown)
63
+ # @return [String, IO] the same target argument
64
+ def write_to(path_or_io, format: nil)
65
+ output_codec = detect_output_codec(path_or_io)
66
+ target_format = resolve_target_format(format, output_codec)
67
+
68
+ output_codec.stream_write(path_or_io, format: target_format) do |writer|
69
+ each_chunk do |chunk|
70
+ output_chunk = target_format ? chunk.convert(target_format) : chunk
71
+ writer.call(output_chunk)
72
+ end
73
+ end
74
+
75
+ path_or_io
76
+ end
77
+
78
+ private
79
+
80
+ def apply_pipeline(chunk)
81
+ @pipeline.reduce(chunk) do |current, processor|
82
+ result = if processor.respond_to?(:call)
83
+ processor.call(current)
84
+ elsif processor.respond_to?(:process)
85
+ processor.process(current)
86
+ else
87
+ processor.apply(current)
88
+ end
89
+
90
+ if result.is_a?(Audio)
91
+ result.buffer
92
+ elsif result.is_a?(SampleBuffer)
93
+ result
94
+ else
95
+ raise ProcessingError, "stream processor must return Core::SampleBuffer or Audio"
96
+ end
97
+ end
98
+ end
99
+
100
+ def detect_output_codec(path_or_io)
101
+ return @codec unless path_or_io.is_a?(String)
102
+
103
+ Codecs::Registry.detect(path_or_io)
104
+ end
105
+
106
+ def resolve_target_format(format, output_codec)
107
+ return format if format
108
+ return @format if @format
109
+
110
+ raise InvalidFormatError, "format is required when writing raw stream output" if output_codec == Codecs::Raw
111
+
112
+ nil
113
+ end
114
+
115
+ def validate_chunk_size!(chunk_size)
116
+ raise InvalidParameterError, "chunk_size must be a positive Integer" unless chunk_size.is_a?(Integer) && chunk_size.positive?
117
+
118
+ chunk_size
119
+ end
120
+
121
+ def validate_codec_read_options!(codec_read_options)
122
+ return {} if codec_read_options.nil?
123
+ raise InvalidParameterError, "codec_read_options must be a Hash" unless codec_read_options.is_a?(Hash)
124
+
125
+ codec_read_options.dup
126
+ end
127
+ end
128
+ end
129
+ end