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.
- checksums.yaml +7 -0
- data/.serena/.gitignore +1 -0
- data/.serena/memories/project_overview.md +5 -0
- data/.serena/memories/style_and_completion.md +5 -0
- data/.serena/memories/suggested_commands.md +11 -0
- data/.serena/project.yml +126 -0
- data/.simplecov +18 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/Rakefile +190 -0
- data/benchmarks/README.md +46 -0
- data/benchmarks/benchmark_helper.rb +112 -0
- data/benchmarks/dsp_effects_benchmark.rb +46 -0
- data/benchmarks/flac_benchmark.rb +74 -0
- data/benchmarks/streaming_memory_benchmark.rb +94 -0
- data/benchmarks/wav_io_benchmark.rb +110 -0
- data/examples/audio_processing.rb +73 -0
- data/examples/cinematic_transition.rb +118 -0
- data/examples/drum_machine.rb +74 -0
- data/examples/format_convert.rb +81 -0
- data/examples/hybrid_arrangement.rb +165 -0
- data/examples/streaming_master_chain.rb +129 -0
- data/examples/synth_pad.rb +42 -0
- data/lib/wavify/audio.rb +483 -0
- data/lib/wavify/codecs/aiff.rb +338 -0
- data/lib/wavify/codecs/base.rb +108 -0
- data/lib/wavify/codecs/flac.rb +1322 -0
- data/lib/wavify/codecs/ogg_vorbis.rb +1447 -0
- data/lib/wavify/codecs/raw.rb +193 -0
- data/lib/wavify/codecs/registry.rb +87 -0
- data/lib/wavify/codecs/wav.rb +459 -0
- data/lib/wavify/core/duration.rb +99 -0
- data/lib/wavify/core/format.rb +133 -0
- data/lib/wavify/core/sample_buffer.rb +216 -0
- data/lib/wavify/core/stream.rb +129 -0
- data/lib/wavify/dsl.rb +537 -0
- data/lib/wavify/dsp/effects/chorus.rb +98 -0
- data/lib/wavify/dsp/effects/compressor.rb +85 -0
- data/lib/wavify/dsp/effects/delay.rb +69 -0
- data/lib/wavify/dsp/effects/distortion.rb +64 -0
- data/lib/wavify/dsp/effects/effect_base.rb +68 -0
- data/lib/wavify/dsp/effects/reverb.rb +112 -0
- data/lib/wavify/dsp/effects.rb +21 -0
- data/lib/wavify/dsp/envelope.rb +97 -0
- data/lib/wavify/dsp/filter.rb +271 -0
- data/lib/wavify/dsp/oscillator.rb +123 -0
- data/lib/wavify/errors.rb +34 -0
- data/lib/wavify/sequencer/engine.rb +278 -0
- data/lib/wavify/sequencer/note_sequence.rb +132 -0
- data/lib/wavify/sequencer/pattern.rb +102 -0
- data/lib/wavify/sequencer/track.rb +298 -0
- data/lib/wavify/sequencer.rb +12 -0
- data/lib/wavify/version.rb +6 -0
- data/lib/wavify.rb +28 -0
- data/tools/fixture_writer.rb +85 -0
- 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
|