wavify 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +40 -1
- data/README.md +204 -22
- data/exe/wavify +6 -0
- data/lib/wavify/adapters.rb +81 -0
- data/lib/wavify/audio.rb +1066 -120
- data/lib/wavify/cli.rb +237 -0
- data/lib/wavify/codecs/aiff.rb +388 -87
- data/lib/wavify/codecs/base.rb +98 -5
- data/lib/wavify/codecs/flac.rb +660 -170
- data/lib/wavify/codecs/ogg_vorbis.rb +515 -155
- data/lib/wavify/codecs/raw.rb +235 -45
- data/lib/wavify/codecs/registry.rb +272 -10
- data/lib/wavify/codecs/wav.rb +453 -74
- data/lib/wavify/core/duration.rb +54 -2
- data/lib/wavify/core/format.rb +96 -11
- data/lib/wavify/core/sample_buffer.rb +648 -60
- data/lib/wavify/core/stream.rb +590 -34
- data/lib/wavify/dsl.rb +712 -100
- data/lib/wavify/dsp/automation.rb +109 -0
- data/lib/wavify/dsp/effects/auto_pan.rb +71 -0
- data/lib/wavify/dsp/effects/bitcrusher.rb +74 -0
- data/lib/wavify/dsp/effects/chorus.rb +15 -4
- data/lib/wavify/dsp/effects/compressor.rb +133 -23
- data/lib/wavify/dsp/effects/delay.rb +46 -1
- data/lib/wavify/dsp/effects/distortion.rb +3 -2
- data/lib/wavify/dsp/effects/effect_base.rb +69 -2
- data/lib/wavify/dsp/effects/effect_chain.rb +94 -0
- data/lib/wavify/dsp/effects/envelope_controlled_effect.rb +93 -0
- data/lib/wavify/dsp/effects/eq.rb +102 -0
- data/lib/wavify/dsp/effects/expander.rb +90 -0
- data/lib/wavify/dsp/effects/flanger.rb +112 -0
- data/lib/wavify/dsp/effects/limiter.rb +259 -0
- data/lib/wavify/dsp/effects/mastering_chain.rb +31 -0
- data/lib/wavify/dsp/effects/noise_gate.rb +72 -0
- data/lib/wavify/dsp/effects/phaser.rb +112 -0
- data/lib/wavify/dsp/effects/podcast_chain.rb +32 -0
- data/lib/wavify/dsp/effects/reverb.rb +100 -14
- data/lib/wavify/dsp/effects/soft_limiter.rb +55 -0
- data/lib/wavify/dsp/effects/stereo_widener.rb +73 -0
- data/lib/wavify/dsp/effects/tremolo.rb +66 -0
- data/lib/wavify/dsp/effects/vibrato.rb +102 -0
- data/lib/wavify/dsp/effects.rb +106 -0
- data/lib/wavify/dsp/envelope.rb +90 -19
- data/lib/wavify/dsp/filter.rb +149 -8
- data/lib/wavify/dsp/headroom.rb +57 -0
- data/lib/wavify/dsp/lfo.rb +65 -0
- data/lib/wavify/dsp/loudness_meter.rb +281 -0
- data/lib/wavify/dsp/oscillator.rb +261 -22
- data/lib/wavify/dsp/processor.rb +90 -0
- data/lib/wavify/errors.rb +2 -2
- data/lib/wavify/sequencer/engine.rb +405 -89
- data/lib/wavify/sequencer/note_sequence.rb +45 -7
- data/lib/wavify/sequencer/pattern.rb +121 -12
- data/lib/wavify/sequencer/track.rb +149 -10
- data/lib/wavify/version.rb +1 -1
- data/lib/wavify.rb +18 -0
- data/sig/audio.rbs +86 -0
- data/sig/codecs.rbs +77 -0
- data/sig/core.rbs +108 -0
- data/sig/dsl.rbs +32 -0
- data/sig/dsp.rbs +87 -0
- data/sig/effects.rbs +129 -0
- data/sig/public_api.yml +111 -0
- data/sig/sequencer.rbs +126 -0
- data/sig/stream.rbs +30 -0
- data/sig/wavify.rbs +24 -0
- metadata +44 -58
- data/.serena/.gitignore +0 -1
- data/.serena/memories/project_overview.md +0 -5
- data/.serena/memories/style_and_completion.md +0 -5
- data/.serena/memories/suggested_commands.md +0 -11
- data/.serena/project.yml +0 -126
- data/.simplecov +0 -18
- data/.yardopts +0 -4
- data/Rakefile +0 -190
- data/benchmarks/README.md +0 -46
- data/benchmarks/benchmark_helper.rb +0 -112
- data/benchmarks/dsp_effects_benchmark.rb +0 -46
- data/benchmarks/flac_benchmark.rb +0 -74
- data/benchmarks/streaming_memory_benchmark.rb +0 -94
- data/benchmarks/wav_io_benchmark.rb +0 -110
- data/examples/audio_processing.rb +0 -73
- data/examples/cinematic_transition.rb +0 -118
- data/examples/drum_machine.rb +0 -74
- data/examples/format_convert.rb +0 -81
- data/examples/hybrid_arrangement.rb +0 -165
- data/examples/streaming_master_chain.rb +0 -129
- data/examples/synth_pad.rb +0 -42
- data/tools/fixture_writer.rb +0 -85
|
@@ -5,26 +5,322 @@ module Wavify
|
|
|
5
5
|
# Immutable interleaved sample container with format metadata.
|
|
6
6
|
#
|
|
7
7
|
# Samples are stored in interleaved order (`L, R, L, R, ...`) for
|
|
8
|
-
# multi-channel audio.
|
|
8
|
+
# multi-channel audio. Packed buffers remain packed when enumerated,
|
|
9
|
+
# compared, hashed, or converted to the same format without dither.
|
|
10
|
+
# Random-access operations (`samples`, `frame_view`, `view`, `slice`,
|
|
11
|
+
# `concat`, `reverse`, and format-changing `convert`) materialize them.
|
|
12
|
+
# Hashing samples a fixed number of positions, so it is O(1); collisions
|
|
13
|
+
# are resolved by full value equality, whose worst case is O(n).
|
|
9
14
|
class SampleBuffer
|
|
10
15
|
include Enumerable
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
# Number of packed samples unpacked per sequential enumeration batch.
|
|
18
|
+
PACKED_ENUMERATION_CHUNK_SAMPLES = 4_096
|
|
19
|
+
|
|
20
|
+
# Builds integer PCM samples without normalized-float ambiguity.
|
|
21
|
+
def self.from_pcm_samples(samples, format, storage: :array)
|
|
22
|
+
unless format.is_a?(Format) && format.sample_format == :pcm
|
|
23
|
+
raise InvalidParameterError, "format must be a PCM Core::Format"
|
|
24
|
+
end
|
|
25
|
+
unless samples.is_a?(Array) && samples.all? { |sample| sample.is_a?(Integer) }
|
|
26
|
+
raise InvalidParameterError, "PCM samples must be Integers"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
new(samples, format, storage: storage)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Builds a buffer from normalized floating-point samples.
|
|
33
|
+
def self.from_normalized_samples(samples, format, storage: :array)
|
|
34
|
+
unless samples.is_a?(Array) && samples.all? do |sample|
|
|
35
|
+
sample.is_a?(Numeric) && sample.real? && sample.respond_to?(:finite?) && sample.finite?
|
|
36
|
+
end
|
|
37
|
+
raise InvalidParameterError, "normalized samples must be finite real Numeric values"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
new(samples.map(&:to_f), format, storage: storage)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.adopt_mutable_float_samples!(samples, format) # :nodoc:
|
|
44
|
+
buffer = allocate
|
|
45
|
+
buffer.send(:initialize_owned_float_samples!, samples, format)
|
|
46
|
+
buffer
|
|
47
|
+
end
|
|
48
|
+
private_class_method :adopt_mutable_float_samples!
|
|
49
|
+
|
|
50
|
+
# Lazy no-copy view over interleaved samples as sample frames.
|
|
51
|
+
class FrameView
|
|
52
|
+
include Enumerable
|
|
53
|
+
|
|
54
|
+
attr_reader :channels, :frame_count
|
|
55
|
+
|
|
56
|
+
def initialize(samples, channels, start_frame: 0, frame_count: nil)
|
|
57
|
+
raise InvalidParameterError, "samples must be an Array" unless samples.is_a?(Array)
|
|
58
|
+
raise InvalidParameterError, "channels must be a positive Integer" unless channels.is_a?(Integer) && channels.positive?
|
|
59
|
+
raise InvalidParameterError, "start_frame must be a non-negative Integer" unless start_frame.is_a?(Integer) && start_frame >= 0
|
|
60
|
+
|
|
61
|
+
max_frames = samples.length / channels
|
|
62
|
+
@samples = samples
|
|
63
|
+
@channels = channels
|
|
64
|
+
@start_frame = [start_frame, max_frames].min
|
|
65
|
+
requested_count = frame_count.nil? ? max_frames - @start_frame : frame_count
|
|
66
|
+
raise InvalidParameterError, "frame_count must be a non-negative Integer" unless requested_count.is_a?(Integer) && requested_count >= 0
|
|
67
|
+
|
|
68
|
+
@frame_count = [requested_count, max_frames - @start_frame].min
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def each
|
|
72
|
+
return enum_for(:each) unless block_given?
|
|
73
|
+
|
|
74
|
+
@frame_count.times { |index| yield self[index] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def [](index)
|
|
78
|
+
raise InvalidParameterError, "frame index must be an Integer" unless index.is_a?(Integer)
|
|
79
|
+
|
|
80
|
+
normalized = index.negative? ? @frame_count + index : index
|
|
81
|
+
return nil unless normalized.between?(0, @frame_count - 1)
|
|
82
|
+
|
|
83
|
+
sample_index = (@start_frame + normalized) * @channels
|
|
84
|
+
@samples.slice(sample_index, @channels).dup
|
|
85
|
+
end
|
|
86
|
+
|
|
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
|
+
self.class.new(@samples, @channels, start_frame: @start_frame + start_frame, frame_count: frame_length)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def length
|
|
99
|
+
@frame_count
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
alias size length
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Lazy no-copy view over a contiguous frame range of a sample buffer.
|
|
106
|
+
class View
|
|
107
|
+
include Enumerable
|
|
108
|
+
|
|
109
|
+
attr_reader :format, :duration
|
|
110
|
+
|
|
111
|
+
def initialize(samples, format, start_frame: 0, frame_count: nil)
|
|
112
|
+
raise InvalidParameterError, "samples must be an Array" unless samples.is_a?(Array)
|
|
113
|
+
raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Format)
|
|
114
|
+
raise InvalidParameterError, "start_frame must be a non-negative Integer" unless start_frame.is_a?(Integer) && start_frame >= 0
|
|
115
|
+
|
|
116
|
+
max_frames = samples.length / format.channels
|
|
117
|
+
@samples = samples
|
|
118
|
+
@format = format
|
|
119
|
+
@start_frame = [start_frame, max_frames].min
|
|
120
|
+
requested_count = frame_count.nil? ? max_frames - @start_frame : frame_count
|
|
121
|
+
raise InvalidParameterError, "frame_count must be a non-negative Integer" unless requested_count.is_a?(Integer) && requested_count >= 0
|
|
122
|
+
|
|
123
|
+
@frame_count = [requested_count, max_frames - @start_frame].min
|
|
124
|
+
@duration = Duration.from_samples(@frame_count, format.sample_rate)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Enumerates interleaved sample values in the view range.
|
|
128
|
+
#
|
|
129
|
+
# @yieldparam sample [Numeric]
|
|
130
|
+
# @return [Enumerator, Array<Numeric>]
|
|
131
|
+
def each
|
|
132
|
+
return enum_for(:each) unless block_given?
|
|
133
|
+
|
|
134
|
+
start_index = @start_frame * @format.channels
|
|
135
|
+
length.times { |offset| yield @samples.fetch(start_index + offset) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Materializes the view's interleaved samples.
|
|
139
|
+
#
|
|
140
|
+
# @return [Array<Numeric>]
|
|
141
|
+
def samples
|
|
142
|
+
start_index = @start_frame * @format.channels
|
|
143
|
+
@samples.slice(start_index, length).freeze
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [Integer] number of interleaved samples in the view
|
|
147
|
+
def length
|
|
148
|
+
@frame_count * @format.channels
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
alias size length
|
|
152
|
+
|
|
153
|
+
# @return [Integer] number of sample frames in the view
|
|
154
|
+
def sample_frame_count
|
|
155
|
+
@frame_count
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [FrameView]
|
|
159
|
+
def frame_view
|
|
160
|
+
FrameView.new(@samples, @format.channels, start_frame: @start_frame, frame_count: @frame_count)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Returns another lazy view relative to this view.
|
|
164
|
+
#
|
|
165
|
+
# @param start_frame [Integer]
|
|
166
|
+
# @param frame_length [Integer]
|
|
167
|
+
# @return [View]
|
|
168
|
+
def slice(start_frame, frame_length)
|
|
169
|
+
unless start_frame.is_a?(Integer) && start_frame >= 0
|
|
170
|
+
raise InvalidParameterError, "start_frame must be a non-negative Integer: #{start_frame.inspect}"
|
|
171
|
+
end
|
|
172
|
+
unless frame_length.is_a?(Integer) && frame_length >= 0
|
|
173
|
+
raise InvalidParameterError, "frame_length must be a non-negative Integer: #{frame_length.inspect}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
self.class.new(@samples, @format, start_frame: @start_frame + start_frame, frame_count: frame_length)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Materializes the view as an immutable sample buffer.
|
|
180
|
+
#
|
|
181
|
+
# @return [SampleBuffer]
|
|
182
|
+
def to_sample_buffer
|
|
183
|
+
SampleBuffer.new(samples, @format)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Converts the materialized view to another format.
|
|
187
|
+
#
|
|
188
|
+
# @param new_format [Format]
|
|
189
|
+
# @return [SampleBuffer]
|
|
190
|
+
def convert(new_format, **options)
|
|
191
|
+
to_sample_buffer.convert(new_format, **options)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Supported internal storage backends.
|
|
196
|
+
STORAGE_TYPES = %i[array packed].freeze
|
|
197
|
+
# Number of representative samples included in the constant-time hash.
|
|
198
|
+
HASH_PROBE_COUNT = 8
|
|
199
|
+
StorageState = Struct.new(:samples, :packed_samples, :type, keyword_init: true) # :nodoc:
|
|
200
|
+
|
|
201
|
+
# Internal mutable processing workspace whose final Array ownership is
|
|
202
|
+
# transferred to an immutable SampleBuffer without another Array copy.
|
|
203
|
+
class MutableFloatWorkspace # :nodoc:
|
|
204
|
+
attr_reader :samples, :format
|
|
205
|
+
|
|
206
|
+
def self.from_buffer(buffer, format:)
|
|
207
|
+
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(SampleBuffer)
|
|
208
|
+
|
|
209
|
+
converted = buffer.format == format ? buffer : buffer.convert(format)
|
|
210
|
+
new(converted.samples.dup, format: format)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def initialize(samples, format:)
|
|
214
|
+
unless format.is_a?(Format) && format.sample_format == :float
|
|
215
|
+
raise InvalidParameterError, "workspace format must be a floating-point Core::Format"
|
|
216
|
+
end
|
|
217
|
+
raise InvalidParameterError, "workspace samples must be an Array" unless samples.is_a?(Array)
|
|
218
|
+
|
|
219
|
+
@samples = samples
|
|
220
|
+
@format = format
|
|
221
|
+
@consumed = false
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def replace!(samples)
|
|
225
|
+
raise InvalidParameterError, "workspace samples must be an Array" unless samples.is_a?(Array)
|
|
226
|
+
raise InvalidParameterError, "workspace has already been consumed" if @consumed
|
|
227
|
+
|
|
228
|
+
@samples = samples
|
|
229
|
+
self
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def to_sample_buffer
|
|
233
|
+
raise InvalidParameterError, "workspace has already been consumed" if @consumed
|
|
234
|
+
|
|
235
|
+
@consumed = true
|
|
236
|
+
SampleBuffer.send(:adopt_mutable_float_samples!, @samples, @format)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
attr_reader :format, :duration
|
|
13
241
|
|
|
14
242
|
# @param samples [Array<Numeric>] interleaved sample values
|
|
15
243
|
# @param format [Format] sample encoding and channel layout
|
|
16
|
-
def initialize(samples, format)
|
|
244
|
+
def initialize(samples, format, storage: :array)
|
|
17
245
|
raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Format)
|
|
18
246
|
raise InvalidParameterError, "samples must be an Array" unless samples.is_a?(Array)
|
|
19
247
|
|
|
20
248
|
validate_samples!(samples)
|
|
21
249
|
validate_interleaving!(samples.length, format.channels)
|
|
250
|
+
storage = normalize_storage!(storage)
|
|
22
251
|
|
|
23
252
|
@format = format
|
|
24
|
-
|
|
253
|
+
coerced = coerce_samples(samples, format)
|
|
254
|
+
@sample_count = coerced.length
|
|
255
|
+
@value_hash = sample_value_hash(coerced, canonical_float: storage == :packed && format.sample_format == :float)
|
|
256
|
+
@storage_mutex = Mutex.new
|
|
257
|
+
@storage_state = if storage == :packed
|
|
258
|
+
storage_state(samples: nil, packed_samples: pack_samples(coerced, format).freeze, type: :packed)
|
|
259
|
+
else
|
|
260
|
+
storage_state(samples: coerced.freeze, packed_samples: nil, type: :array)
|
|
261
|
+
end
|
|
25
262
|
@duration = Duration.from_samples(sample_frame_count, format.sample_rate)
|
|
26
263
|
end
|
|
27
264
|
|
|
265
|
+
# Materializes packed storage on first random-access use.
|
|
266
|
+
def samples
|
|
267
|
+
state = @storage_state
|
|
268
|
+
return state.samples if state.samples
|
|
269
|
+
|
|
270
|
+
@storage_mutex.synchronize do
|
|
271
|
+
state = @storage_state
|
|
272
|
+
return state.samples if state.samples
|
|
273
|
+
|
|
274
|
+
materialized = unpack_samples(state.packed_samples, @format).freeze
|
|
275
|
+
@storage_state = storage_state(samples: materialized, packed_samples: nil, type: :array)
|
|
276
|
+
end
|
|
277
|
+
@storage_state.samples
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Iterates interleaved values without allocating a frame Array.
|
|
281
|
+
def each_frame_sample
|
|
282
|
+
return enum_for(:each_frame_sample) unless block_given?
|
|
283
|
+
|
|
284
|
+
each.with_index do |sample, sample_index|
|
|
285
|
+
yield sample, sample_index / @format.channels, sample_index % @format.channels
|
|
286
|
+
end
|
|
287
|
+
self
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def storage
|
|
291
|
+
@storage_state.type
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def packed?
|
|
295
|
+
@storage_state.type == :packed
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def packed_bytesize
|
|
299
|
+
@storage_state.packed_samples&.bytesize || 0
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Value equality for immutable samples and format metadata.
|
|
303
|
+
def ==(other)
|
|
304
|
+
return true if equal?(other)
|
|
305
|
+
return false unless other.is_a?(SampleBuffer) && @format == other.format && @sample_count == other.length
|
|
306
|
+
|
|
307
|
+
array_samples, packed_bytes = storage_snapshot
|
|
308
|
+
other_array_samples, other_packed_bytes = other.send(:storage_snapshot)
|
|
309
|
+
return array_samples == other_array_samples if array_samples && other_array_samples
|
|
310
|
+
|
|
311
|
+
if packed_bytes && other_packed_bytes
|
|
312
|
+
return packed_bytes == other_packed_bytes if @format.sample_format == :pcm || packed_bytes == other_packed_bytes
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
sample_values_equal?(other)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
alias eql? ==
|
|
319
|
+
|
|
320
|
+
def hash
|
|
321
|
+
@value_hash
|
|
322
|
+
end
|
|
323
|
+
|
|
28
324
|
# Enumerates sample values in interleaved order.
|
|
29
325
|
#
|
|
30
326
|
# @yield [sample]
|
|
@@ -33,35 +329,72 @@ module Wavify
|
|
|
33
329
|
def each(&)
|
|
34
330
|
return enum_for(:each) unless block_given?
|
|
35
331
|
|
|
36
|
-
|
|
332
|
+
return each_packed_sample(&) if packed?
|
|
333
|
+
|
|
334
|
+
@storage_state.samples.each(&)
|
|
37
335
|
end
|
|
38
336
|
|
|
39
337
|
# @return [Integer] number of interleaved samples
|
|
40
338
|
def length
|
|
41
|
-
@
|
|
339
|
+
@sample_count
|
|
42
340
|
end
|
|
43
341
|
|
|
44
342
|
alias size length
|
|
45
343
|
|
|
46
344
|
# @return [Integer] number of audio frames
|
|
47
345
|
def sample_frame_count
|
|
48
|
-
@
|
|
346
|
+
@sample_count / @format.channels
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Returns a lazy sample-frame view without copying the full buffer.
|
|
350
|
+
#
|
|
351
|
+
# @return [FrameView]
|
|
352
|
+
def frame_view
|
|
353
|
+
FrameView.new(samples, @format.channels)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Returns a lazy sample-buffer view without copying the selected frames.
|
|
357
|
+
#
|
|
358
|
+
# @param start_frame [Integer]
|
|
359
|
+
# @param frame_length [Integer, nil]
|
|
360
|
+
# @return [View]
|
|
361
|
+
def view(start_frame: 0, frame_length: nil)
|
|
362
|
+
View.new(samples, @format, start_frame: start_frame, frame_count: frame_length)
|
|
49
363
|
end
|
|
50
364
|
|
|
51
365
|
# Converts the buffer to another audio format/channels.
|
|
52
366
|
#
|
|
53
367
|
# @param new_format [Format]
|
|
54
368
|
# @return [SampleBuffer]
|
|
55
|
-
def convert(new_format)
|
|
369
|
+
def convert(new_format, dither: false, dither_seed: nil, resampler: :linear)
|
|
56
370
|
raise InvalidParameterError, "new_format must be Core::Format" unless new_format.is_a?(Format)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
371
|
+
return self if new_format == @format && !dither
|
|
372
|
+
|
|
373
|
+
resampler = normalize_resampler!(resampler)
|
|
374
|
+
dither_rng = dither_applicable?(new_format, dither) ? Random.new(dither_seed) : nil
|
|
375
|
+
if @format.channels == new_format.channels && @format.sample_rate == new_format.sample_rate
|
|
376
|
+
converted_samples = samples.map do |sample|
|
|
377
|
+
normalized = to_normalized_float(sample, @format)
|
|
378
|
+
from_normalized_float(normalized, new_format, dither_rng: dither_rng)
|
|
379
|
+
end
|
|
380
|
+
return self.class.new(converted_samples, new_format)
|
|
60
381
|
end
|
|
61
382
|
|
|
62
|
-
|
|
63
|
-
converted_samples =
|
|
64
|
-
|
|
383
|
+
normalized_samples = samples.map { |sample| to_normalized_float(sample, @format) }
|
|
384
|
+
converted_samples = convert_channels_interleaved(
|
|
385
|
+
normalized_samples,
|
|
386
|
+
source_channels: @format.channels,
|
|
387
|
+
target_channels: new_format.channels,
|
|
388
|
+
source_layout: @format.channel_layout
|
|
389
|
+
)
|
|
390
|
+
converted_samples = resample_interleaved(
|
|
391
|
+
converted_samples,
|
|
392
|
+
channels: new_format.channels,
|
|
393
|
+
target_sample_rate: new_format.sample_rate,
|
|
394
|
+
resampler: resampler
|
|
395
|
+
)
|
|
396
|
+
converted_samples.map! do |sample|
|
|
397
|
+
from_normalized_float(sample, new_format, dither_rng: dither_rng)
|
|
65
398
|
end
|
|
66
399
|
|
|
67
400
|
self.class.new(converted_samples, new_format)
|
|
@@ -75,7 +408,12 @@ module Wavify
|
|
|
75
408
|
#
|
|
76
409
|
# @return [SampleBuffer]
|
|
77
410
|
def reverse
|
|
78
|
-
reversed =
|
|
411
|
+
reversed = []
|
|
412
|
+
channels = @format.channels
|
|
413
|
+
source_samples = samples
|
|
414
|
+
(source_samples.length - channels).step(0, -channels) do |sample_index|
|
|
415
|
+
reversed.concat(source_samples.slice(sample_index, channels))
|
|
416
|
+
end
|
|
79
417
|
self.class.new(reversed, @format)
|
|
80
418
|
end
|
|
81
419
|
|
|
@@ -92,26 +430,158 @@ module Wavify
|
|
|
92
430
|
raise InvalidParameterError, "frame_length must be a non-negative Integer: #{frame_length.inspect}"
|
|
93
431
|
end
|
|
94
432
|
|
|
95
|
-
|
|
96
|
-
self.class.new(sliced.flatten, @format)
|
|
433
|
+
view(start_frame: start_frame, frame_length: frame_length).to_sample_buffer
|
|
97
434
|
end
|
|
98
435
|
|
|
99
436
|
def concat(other)
|
|
100
437
|
raise InvalidParameterError, "other must be Core::SampleBuffer" unless other.is_a?(SampleBuffer)
|
|
101
438
|
|
|
102
439
|
rhs = other.format == @format ? other : other.convert(@format)
|
|
103
|
-
self.class.new(
|
|
440
|
+
self.class.new(samples + rhs.samples, @format)
|
|
104
441
|
end
|
|
105
442
|
|
|
106
443
|
alias + concat
|
|
107
444
|
|
|
108
445
|
private
|
|
109
446
|
|
|
447
|
+
def normalize_storage!(storage)
|
|
448
|
+
value = storage.to_sym
|
|
449
|
+
return value if STORAGE_TYPES.include?(value)
|
|
450
|
+
|
|
451
|
+
raise InvalidParameterError, "storage must be :array or :packed"
|
|
452
|
+
rescue NoMethodError
|
|
453
|
+
raise InvalidParameterError, "storage must be Symbol/String"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def pack_samples(samples, format)
|
|
457
|
+
return samples.pack(format.bit_depth == 32 ? "e*" : "E*") if format.sample_format == :float
|
|
458
|
+
|
|
459
|
+
case format.bit_depth
|
|
460
|
+
when 8 then samples.pack("c*")
|
|
461
|
+
when 16 then samples.pack("s<*")
|
|
462
|
+
when 24 then pack_pcm24(samples)
|
|
463
|
+
when 32 then samples.pack("l<*")
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def unpack_samples(bytes, format)
|
|
468
|
+
return bytes.unpack(format.bit_depth == 32 ? "e*" : "E*") if format.sample_format == :float
|
|
469
|
+
|
|
470
|
+
case format.bit_depth
|
|
471
|
+
when 8 then bytes.unpack("c*")
|
|
472
|
+
when 16 then bytes.unpack("s<*")
|
|
473
|
+
when 24 then unpack_pcm24(bytes)
|
|
474
|
+
when 32 then bytes.unpack("l<*")
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def pack_pcm24(samples)
|
|
479
|
+
packed = String.new(capacity: samples.length * 3, encoding: Encoding::BINARY)
|
|
480
|
+
samples.each do |sample|
|
|
481
|
+
value = sample.negative? ? sample + 0x1000000 : sample
|
|
482
|
+
packed << (value & 0xFF) << ((value >> 8) & 0xFF) << ((value >> 16) & 0xFF)
|
|
483
|
+
end
|
|
484
|
+
packed
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def unpack_pcm24(bytes)
|
|
488
|
+
samples = Array.new(bytes.bytesize / 3)
|
|
489
|
+
samples.length.times do |index|
|
|
490
|
+
offset = index * 3
|
|
491
|
+
low = bytes.getbyte(offset)
|
|
492
|
+
middle = bytes.getbyte(offset + 1)
|
|
493
|
+
high = bytes.getbyte(offset + 2)
|
|
494
|
+
value = low | (middle << 8) | (high << 16)
|
|
495
|
+
samples[index] = value.anybits?(0x800000) ? value - 0x1000000 : value
|
|
496
|
+
end
|
|
497
|
+
samples
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def each_packed_sample
|
|
501
|
+
array_samples, packed_samples = storage_snapshot
|
|
502
|
+
return array_samples.each { |sample| yield sample } unless packed_samples
|
|
503
|
+
|
|
504
|
+
if @format.sample_format == :pcm && @format.bit_depth == 24
|
|
505
|
+
chunk_bytes = PACKED_ENUMERATION_CHUNK_SAMPLES * 3
|
|
506
|
+
0.step(packed_samples.bytesize - 1, chunk_bytes) do |offset|
|
|
507
|
+
unpack_pcm24(packed_samples.byteslice(offset, chunk_bytes)).each { |sample| yield sample }
|
|
508
|
+
end
|
|
509
|
+
return self
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
directive, byte_width = packed_directive_and_width(@format)
|
|
513
|
+
chunk_bytes = PACKED_ENUMERATION_CHUNK_SAMPLES * byte_width
|
|
514
|
+
0.step(packed_samples.bytesize - 1, chunk_bytes) do |offset|
|
|
515
|
+
packed_samples.byteslice(offset, chunk_bytes).unpack("#{directive}*").each { |sample| yield sample }
|
|
516
|
+
end
|
|
517
|
+
self
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def storage_snapshot
|
|
521
|
+
state = @storage_mutex.synchronize { @storage_state }
|
|
522
|
+
[state.samples, state.packed_samples]
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def storage_state(samples:, packed_samples:, type:)
|
|
526
|
+
StorageState.new(samples: samples, packed_samples: packed_samples, type: type).freeze
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def sample_values_equal?(other)
|
|
530
|
+
left = each
|
|
531
|
+
right = other.each
|
|
532
|
+
@sample_count.times.all? { left.next == right.next }
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def sample_value_hash(values, canonical_float: false)
|
|
536
|
+
probe_count = [values.length, HASH_PROBE_COUNT].min
|
|
537
|
+
return [@format, values.length].hash if probe_count.zero?
|
|
538
|
+
|
|
539
|
+
indexes = if probe_count == 1
|
|
540
|
+
[0]
|
|
541
|
+
else
|
|
542
|
+
Array.new(probe_count) { |index| (index * (values.length - 1)) / (probe_count - 1) }
|
|
543
|
+
end
|
|
544
|
+
probes = indexes.map { |index| values.fetch(index) }
|
|
545
|
+
probes.map! { |value| canonical_float_value(value) } if canonical_float
|
|
546
|
+
[@format, values.length, *probes].hash
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def canonical_float_value(value)
|
|
550
|
+
directive = @format.bit_depth == 32 ? "e" : "E"
|
|
551
|
+
[value].pack(directive).unpack1(directive)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def packed_directive_and_width(format)
|
|
555
|
+
return format.bit_depth == 32 ? ["e", 4] : ["E", 8] if format.sample_format == :float
|
|
556
|
+
|
|
557
|
+
{ 8 => ["c", 1], 16 => ["s<", 2], 32 => ["l<", 4] }.fetch(format.bit_depth)
|
|
558
|
+
end
|
|
559
|
+
|
|
110
560
|
def validate_samples!(samples)
|
|
111
|
-
invalid_index = samples.index
|
|
561
|
+
invalid_index = samples.index do |sample|
|
|
562
|
+
!sample.is_a?(Numeric) || !sample.real? || !sample.respond_to?(:finite?) || !sample.finite?
|
|
563
|
+
end
|
|
112
564
|
return unless invalid_index
|
|
113
565
|
|
|
114
|
-
raise InvalidParameterError, "sample at index #{invalid_index} must be Numeric"
|
|
566
|
+
raise InvalidParameterError, "sample at index #{invalid_index} must be a finite real Numeric"
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def initialize_owned_float_samples!(samples, format)
|
|
570
|
+
unless format.is_a?(Format) && format.sample_format == :float
|
|
571
|
+
raise InvalidParameterError, "format must be a floating-point Core::Format"
|
|
572
|
+
end
|
|
573
|
+
raise InvalidParameterError, "samples must be an Array" unless samples.is_a?(Array)
|
|
574
|
+
|
|
575
|
+
validate_samples!(samples)
|
|
576
|
+
validate_interleaving!(samples.length, format.channels)
|
|
577
|
+
samples.map!(&:to_f)
|
|
578
|
+
|
|
579
|
+
@format = format
|
|
580
|
+
@sample_count = samples.length
|
|
581
|
+
@value_hash = sample_value_hash(samples)
|
|
582
|
+
@storage_mutex = Mutex.new
|
|
583
|
+
@storage_state = storage_state(samples: samples.freeze, packed_samples: nil, type: :array)
|
|
584
|
+
@duration = Duration.from_samples(sample_frame_count, format.sample_rate)
|
|
115
585
|
end
|
|
116
586
|
|
|
117
587
|
def validate_interleaving!(sample_count, channels)
|
|
@@ -124,76 +594,181 @@ module Wavify
|
|
|
124
594
|
def coerce_samples(samples, format)
|
|
125
595
|
samples.map do |sample|
|
|
126
596
|
if format.sample_format == :float
|
|
127
|
-
sample.to_f
|
|
597
|
+
sample.to_f
|
|
128
598
|
else
|
|
129
|
-
coerce_pcm_sample(sample, format
|
|
599
|
+
coerce_pcm_sample(sample, format)
|
|
130
600
|
end
|
|
131
601
|
end
|
|
132
602
|
end
|
|
133
603
|
|
|
134
|
-
def coerce_pcm_sample(sample,
|
|
604
|
+
def coerce_pcm_sample(sample, format)
|
|
135
605
|
if sample.is_a?(Float) && sample.between?(-1.0, 1.0)
|
|
136
|
-
float_to_pcm(sample,
|
|
606
|
+
float_to_pcm(sample, format)
|
|
137
607
|
else
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
608
|
+
shift = format.bit_depth - format.valid_bits
|
|
609
|
+
min = -(2**(format.valid_bits - 1)) << shift
|
|
610
|
+
max = ((2**(format.valid_bits - 1)) - 1) << shift
|
|
611
|
+
(sample.to_i.clamp(min, max) >> shift) << shift
|
|
141
612
|
end
|
|
142
613
|
end
|
|
143
614
|
|
|
144
|
-
def frame_view
|
|
145
|
-
@samples.each_slice(@format.channels).map(&:dup)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
615
|
def to_normalized_float(sample, format)
|
|
149
|
-
return sample.to_f
|
|
616
|
+
return sample.to_f if format.sample_format == :float
|
|
150
617
|
|
|
151
|
-
|
|
152
|
-
(
|
|
618
|
+
shift = format.bit_depth - format.valid_bits
|
|
619
|
+
positive_scale = (((2**(format.valid_bits - 1)) - 1) << shift).to_f
|
|
620
|
+
negative_scale = ((2**(format.valid_bits - 1)) << shift).to_f
|
|
621
|
+
scale = sample.negative? ? negative_scale : positive_scale
|
|
622
|
+
(sample.to_f / scale).clamp(-1.0, 1.0)
|
|
153
623
|
end
|
|
154
624
|
|
|
155
|
-
def from_normalized_float(sample, format)
|
|
156
|
-
value = sample.to_f
|
|
625
|
+
def from_normalized_float(sample, format, dither_rng: nil)
|
|
626
|
+
value = sample.to_f
|
|
157
627
|
return value if format.sample_format == :float
|
|
158
628
|
|
|
159
|
-
|
|
629
|
+
value = value.clamp(-1.0, 1.0)
|
|
630
|
+
value = apply_tpdf_dither(value, format, dither_rng) if dither_rng
|
|
631
|
+
float_to_pcm(value, format)
|
|
160
632
|
end
|
|
161
633
|
|
|
162
|
-
def
|
|
163
|
-
max = (2**(
|
|
164
|
-
|
|
165
|
-
(sample * max).round.clamp(min, max)
|
|
634
|
+
def apply_tpdf_dither(value, format, rng)
|
|
635
|
+
max = ((2**(format.valid_bits - 1)) - 1).to_f
|
|
636
|
+
(value + ((rng.rand - rng.rand) / max)).clamp(-1.0, 1.0)
|
|
166
637
|
end
|
|
167
638
|
|
|
168
|
-
def
|
|
169
|
-
return
|
|
639
|
+
def dither_applicable?(new_format, requested)
|
|
640
|
+
return false unless requested && new_format.sample_format == :pcm
|
|
641
|
+
return true if @format.sample_format == :float
|
|
170
642
|
|
|
171
|
-
|
|
172
|
-
|
|
643
|
+
@format.valid_bits > new_format.valid_bits
|
|
644
|
+
end
|
|
173
645
|
|
|
174
|
-
|
|
646
|
+
def float_to_pcm(sample, format)
|
|
647
|
+
max = (2**(format.valid_bits - 1)) - 1
|
|
648
|
+
min = -(2**(format.valid_bits - 1))
|
|
649
|
+
scale = sample.negative? ? -min : max
|
|
650
|
+
((sample * scale).round.clamp(min, max)) << (format.bit_depth - format.valid_bits)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def convert_channels_interleaved(samples, source_channels:, target_channels:, source_layout: nil)
|
|
654
|
+
return samples if samples.empty? || source_channels == target_channels
|
|
655
|
+
|
|
656
|
+
output = []
|
|
657
|
+
samples.each_slice(source_channels) do |frame|
|
|
658
|
+
converted = if target_channels == 1
|
|
659
|
+
[frame.sum / frame.length.to_f]
|
|
660
|
+
elsif source_channels == 1
|
|
661
|
+
Array.new(target_channels, frame.first)
|
|
662
|
+
elsif source_channels > 2 && target_channels == 2
|
|
663
|
+
downmix_to_stereo(frame, source_layout)
|
|
664
|
+
elsif source_channels > target_channels
|
|
665
|
+
truncate_and_fold(frame, target_channels)
|
|
666
|
+
else
|
|
667
|
+
upmix_with_duplication(frame, target_channels)
|
|
668
|
+
end
|
|
669
|
+
output.concat(converted)
|
|
670
|
+
end
|
|
671
|
+
output
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def normalize_resampler!(resampler)
|
|
675
|
+
value = resampler.to_sym
|
|
676
|
+
return value if %i[linear windowed_sinc].include?(value)
|
|
175
677
|
|
|
176
|
-
|
|
678
|
+
raise InvalidParameterError, "resampler must be :linear or :windowed_sinc"
|
|
679
|
+
rescue NoMethodError
|
|
680
|
+
raise InvalidParameterError, "resampler must be Symbol/String"
|
|
681
|
+
end
|
|
177
682
|
|
|
178
|
-
|
|
683
|
+
def resample_interleaved(samples, channels:, target_sample_rate:, resampler:)
|
|
684
|
+
return samples if samples.empty? || @format.sample_rate == target_sample_rate
|
|
685
|
+
|
|
686
|
+
source_frame_count = samples.length / channels
|
|
687
|
+
target_frame_count = resampled_frame_count(source_frame_count, target_sample_rate)
|
|
688
|
+
return [] if target_frame_count.zero?
|
|
689
|
+
|
|
690
|
+
output = Array.new(target_frame_count * channels)
|
|
691
|
+
target_frame_count.times do |target_index|
|
|
692
|
+
source_position = (target_index * @format.sample_rate.to_f) / target_sample_rate
|
|
693
|
+
if resampler == :windowed_sinc
|
|
694
|
+
channels.times do |channel|
|
|
695
|
+
output[(target_index * channels) + channel] = windowed_sinc_sample(
|
|
696
|
+
samples,
|
|
697
|
+
source_position,
|
|
698
|
+
channel,
|
|
699
|
+
channels,
|
|
700
|
+
source_frame_count,
|
|
701
|
+
target_sample_rate
|
|
702
|
+
)
|
|
703
|
+
end
|
|
704
|
+
next
|
|
705
|
+
end
|
|
179
706
|
|
|
180
|
-
|
|
707
|
+
lower_index = source_position.floor
|
|
708
|
+
upper_index = [lower_index + 1, source_frame_count - 1].min
|
|
709
|
+
fraction = source_position - lower_index
|
|
181
710
|
|
|
182
|
-
|
|
711
|
+
channels.times do |channel|
|
|
712
|
+
lower = samples.fetch((lower_index * channels) + channel)
|
|
713
|
+
upper = samples.fetch((upper_index * channels) + channel)
|
|
714
|
+
output[(target_index * channels) + channel] = lower + ((upper - lower) * fraction)
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
output
|
|
183
718
|
end
|
|
184
719
|
|
|
185
|
-
def
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
center =
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
720
|
+
def windowed_sinc_sample(samples, source_position, channel, channels, source_frame_count, target_sample_rate)
|
|
721
|
+
cutoff = [1.0, target_sample_rate.to_f / @format.sample_rate].min
|
|
722
|
+
radius = (8 / cutoff).ceil.clamp(8, 64)
|
|
723
|
+
center = source_position.floor
|
|
724
|
+
start_index = [center - radius + 1, 0].max
|
|
725
|
+
end_index = [center + radius, source_frame_count - 1].min
|
|
726
|
+
weighted_sum = 0.0
|
|
727
|
+
weight_sum = 0.0
|
|
728
|
+
(start_index..end_index).each do |source_index|
|
|
729
|
+
distance = source_position - source_index
|
|
730
|
+
weight = cutoff * sinc(distance * cutoff) * hann_window(distance, radius)
|
|
731
|
+
weighted_sum += samples.fetch((source_index * channels) + channel) * weight
|
|
732
|
+
weight_sum += weight
|
|
733
|
+
end
|
|
734
|
+
return weighted_sum / weight_sum unless weight_sum.zero?
|
|
735
|
+
|
|
736
|
+
samples.fetch((center.clamp(0, source_frame_count - 1) * channels) + channel)
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def sinc(value)
|
|
740
|
+
return 1.0 if value.abs < 1e-12
|
|
741
|
+
|
|
742
|
+
x = Math::PI * value
|
|
743
|
+
Math.sin(x) / x
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def hann_window(distance, radius)
|
|
747
|
+
normalized = distance.abs / radius.to_f
|
|
748
|
+
return 0.0 if normalized > 1.0
|
|
749
|
+
|
|
750
|
+
0.5 + (0.5 * Math.cos(Math::PI * normalized))
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def resampled_frame_count(source_frame_count, target_sample_rate)
|
|
754
|
+
((source_frame_count * target_sample_rate.to_f) / @format.sample_rate).round
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def downmix_to_stereo(frame, source_layout)
|
|
758
|
+
positions = source_layout || default_channel_layout(frame.length)
|
|
759
|
+
by_position = positions.zip(frame).to_h
|
|
760
|
+
left = by_position.fetch(:front_left, frame[0] || 0.0)
|
|
761
|
+
right = by_position.fetch(:front_right, frame[1] || left)
|
|
762
|
+
center = by_position.fetch(:front_center, 0.0)
|
|
763
|
+
lfe = by_position.fetch(:low_frequency, 0.0)
|
|
764
|
+
left_surround = by_position.fetch(:side_left, by_position.fetch(:back_left, 0.0))
|
|
765
|
+
right_surround = by_position.fetch(:side_right, by_position.fetch(:back_right, 0.0))
|
|
766
|
+
known = %i[front_left front_right front_center low_frequency side_left side_right back_left back_right]
|
|
767
|
+
extras = by_position.reject { |position, _| known.include?(position) }.values.sum
|
|
193
768
|
|
|
194
769
|
left_mix = left + (center * 0.707) + (lfe * 0.5) + (left_surround * 0.707) + (extras * 0.5)
|
|
195
770
|
right_mix = right + (center * 0.707) + (lfe * 0.5) + (right_surround * 0.707) + (extras * 0.5)
|
|
196
|
-
[left_mix
|
|
771
|
+
[left_mix, right_mix]
|
|
197
772
|
end
|
|
198
773
|
|
|
199
774
|
def truncate_and_fold(frame, target_channels)
|
|
@@ -202,7 +777,20 @@ module Wavify
|
|
|
202
777
|
return reduced if extras.empty?
|
|
203
778
|
|
|
204
779
|
extra_mix = extras.sum / target_channels.to_f
|
|
205
|
-
reduced.map { |sample|
|
|
780
|
+
reduced.map { |sample| sample + extra_mix }
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def default_channel_layout(channels)
|
|
784
|
+
{
|
|
785
|
+
1 => %i[front_center],
|
|
786
|
+
2 => %i[front_left front_right],
|
|
787
|
+
3 => %i[front_left front_right front_center],
|
|
788
|
+
4 => %i[front_left front_right back_left back_right],
|
|
789
|
+
5 => %i[front_left front_right front_center back_left back_right],
|
|
790
|
+
6 => %i[front_left front_right front_center low_frequency side_left side_right],
|
|
791
|
+
7 => %i[front_left front_right front_center low_frequency back_center side_left side_right],
|
|
792
|
+
8 => %i[front_left front_right front_center low_frequency back_left back_right side_left side_right]
|
|
793
|
+
}.fetch(channels) { Array.new(channels) { |index| :"channel_#{index}" } }
|
|
206
794
|
end
|
|
207
795
|
|
|
208
796
|
def upmix_with_duplication(frame, target_channels)
|