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
data/lib/wavify/audio.rb
ADDED
|
@@ -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
|