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
data/lib/wavify/audio.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
3
5
|
module Wavify
|
|
4
6
|
# High-level immutable audio object backed by a {Core::SampleBuffer}.
|
|
5
7
|
#
|
|
@@ -8,46 +10,173 @@ module Wavify
|
|
|
8
10
|
class Audio
|
|
9
11
|
attr_reader :buffer
|
|
10
12
|
|
|
13
|
+
# Supported policies for summing multiple sources.
|
|
14
|
+
MIX_STRATEGIES = %i[none clip normalize headroom soft_limit].freeze
|
|
15
|
+
# Supported timeline alignment modes for sources of different lengths.
|
|
16
|
+
MIX_ALIGNMENTS = %i[start center end].freeze
|
|
17
|
+
# Supported normalization reference measurements.
|
|
18
|
+
NORMALIZE_MODES = %i[peak rms lufs].freeze
|
|
19
|
+
# Supported fade interpolation curves.
|
|
20
|
+
FADE_CURVES = %i[linear exp log].freeze
|
|
21
|
+
# Knee threshold used by the built-in soft-limit mix policy.
|
|
22
|
+
SOFT_LIMIT_THRESHOLD = 0.8
|
|
23
|
+
# Maximum number of frames allowed by eager repeat.
|
|
24
|
+
MAX_REPEAT_FRAMES = 50_000_000
|
|
25
|
+
# Maximum estimated allocation allowed by eager repeat.
|
|
26
|
+
MAX_REPEAT_BYTES = 512 * 1024 * 1024
|
|
27
|
+
|
|
11
28
|
# Reads audio from a file path using codec auto-detection.
|
|
12
29
|
#
|
|
13
|
-
# @param
|
|
30
|
+
# @param path_or_io [String, IO]
|
|
14
31
|
# @param format [Core::Format, nil] optional target format to convert into
|
|
15
32
|
# @param codec_options [Hash] codec-specific options forwarded to `.read`
|
|
33
|
+
# @param strict [Boolean] verify extension and magic-byte codec agreement
|
|
34
|
+
# @param filename [String, nil] optional filename hint for IO inputs
|
|
16
35
|
# @return [Audio]
|
|
17
|
-
def self.read(
|
|
18
|
-
codec = Codecs::Registry.
|
|
19
|
-
options = codec_options
|
|
20
|
-
|
|
36
|
+
def self.read(path_or_io, format: nil, codec_options: nil, strict: false, filename: nil)
|
|
37
|
+
codec = Codecs::Registry.detect_for_read(path_or_io, strict: strict, filename: filename)
|
|
38
|
+
options = normalize_codec_options!(codec_options)
|
|
39
|
+
|
|
40
|
+
new(codec.read(path_or_io, format: format, **options))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Reads audio metadata without decoding the full payload when the codec supports it.
|
|
44
|
+
#
|
|
45
|
+
# @param path_or_io [String, IO]
|
|
46
|
+
# @param format [Core::Format, nil] required for raw PCM/float input
|
|
47
|
+
# @param codec_options [Hash] codec-specific options forwarded to `.metadata`
|
|
48
|
+
# @param strict [Boolean] verify extension and magic-byte codec agreement
|
|
49
|
+
# @param filename [String, nil] optional filename hint for IO inputs
|
|
50
|
+
# @return [Hash]
|
|
51
|
+
def self.metadata(path_or_io, format: nil, codec_options: nil, strict: false, filename: nil)
|
|
52
|
+
codec = Codecs::Registry.detect_for_read(path_or_io, strict: strict, filename: filename)
|
|
53
|
+
options = normalize_codec_options!(codec_options)
|
|
54
|
+
return codec.metadata(path_or_io, format: format, **options) if codec == Codecs::Raw
|
|
55
|
+
|
|
56
|
+
metadata = codec.metadata(path_or_io, **options)
|
|
57
|
+
return metadata unless format
|
|
58
|
+
raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
|
|
59
|
+
|
|
60
|
+
project_metadata(metadata, format)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
singleton_class.alias_method :info, :metadata
|
|
64
|
+
|
|
65
|
+
def self.project_metadata(metadata, target_format)
|
|
66
|
+
source_format = metadata.fetch(:format)
|
|
67
|
+
projected_frames = project_frame_count(
|
|
68
|
+
metadata.fetch(:sample_frame_count),
|
|
69
|
+
source_format.sample_rate,
|
|
70
|
+
target_format.sample_rate
|
|
71
|
+
)
|
|
72
|
+
projected = metadata.merge(
|
|
73
|
+
format: target_format,
|
|
74
|
+
sample_frame_count: projected_frames,
|
|
75
|
+
duration: Core::Duration.from_samples(projected_frames, target_format.sample_rate)
|
|
76
|
+
)
|
|
77
|
+
project_sample_coordinates!(projected, source_format.sample_rate, target_format.sample_rate)
|
|
78
|
+
end
|
|
79
|
+
private_class_method :project_metadata
|
|
80
|
+
|
|
81
|
+
def self.project_sample_coordinates!(metadata, source_rate, target_rate)
|
|
82
|
+
projector = ->(frame) { project_frame_count(frame, source_rate, target_rate) }
|
|
83
|
+
metadata[:fact_sample_length] = projector.call(metadata[:fact_sample_length]) if metadata[:fact_sample_length]
|
|
84
|
+
metadata[:loops] = project_loops(metadata[:loops], projector) if metadata[:loops]
|
|
85
|
+
metadata[:cue_points] = project_cue_points(metadata[:cue_points], projector) if metadata[:cue_points]
|
|
86
|
+
metadata[:smpl] = project_smpl(metadata[:smpl], projector, target_rate) if metadata[:smpl]
|
|
87
|
+
metadata[:cue] = metadata[:cue].merge(points: metadata[:cue_points]) if metadata[:cue]
|
|
88
|
+
if metadata[:bext]
|
|
89
|
+
metadata[:bext] = metadata[:bext].merge(time_reference: projector.call(metadata[:bext].fetch(:time_reference)))
|
|
90
|
+
metadata[:broadcast_extension] = metadata[:bext]
|
|
91
|
+
end
|
|
92
|
+
metadata
|
|
93
|
+
end
|
|
94
|
+
private_class_method :project_sample_coordinates!
|
|
21
95
|
|
|
22
|
-
|
|
96
|
+
def self.project_frame_count(frame_count, source_rate, target_rate)
|
|
97
|
+
(Rational(frame_count) * Rational(target_rate, source_rate)).round
|
|
98
|
+
end
|
|
99
|
+
private_class_method :project_frame_count
|
|
100
|
+
|
|
101
|
+
def self.project_loops(loops, projector)
|
|
102
|
+
loops.map do |loop|
|
|
103
|
+
start_frame = projector.call(loop.fetch(:start_frame))
|
|
104
|
+
end_frame = projector.call(loop.fetch(:end_frame) + 1) - 1
|
|
105
|
+
end_frame = start_frame if end_frame < start_frame
|
|
106
|
+
loop.merge(start_frame: start_frame, end_frame: end_frame, length_frames: end_frame - start_frame + 1)
|
|
107
|
+
end
|
|
23
108
|
end
|
|
109
|
+
private_class_method :project_loops
|
|
110
|
+
|
|
111
|
+
def self.project_cue_points(cue_points, projector)
|
|
112
|
+
cue_points.map do |point|
|
|
113
|
+
point.merge(
|
|
114
|
+
position: projector.call(point.fetch(:position)),
|
|
115
|
+
sample_offset: projector.call(point.fetch(:sample_offset))
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
private_class_method :project_cue_points
|
|
120
|
+
|
|
121
|
+
def self.project_smpl(smpl, projector, target_rate)
|
|
122
|
+
loops = smpl.fetch(:loops).map do |loop|
|
|
123
|
+
start_frame = projector.call(loop.fetch(:start_frame))
|
|
124
|
+
end_frame = projector.call(loop.fetch(:end_frame) + 1) - 1
|
|
125
|
+
loop.merge(
|
|
126
|
+
start_frame: start_frame,
|
|
127
|
+
end_frame: [end_frame, start_frame].max
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
smpl.merge(sample_period: (1_000_000_000.0 / target_rate).round, loops: loops)
|
|
131
|
+
end
|
|
132
|
+
private_class_method :project_smpl
|
|
24
133
|
|
|
25
|
-
# Mixes multiple audio objects
|
|
134
|
+
# Mixes multiple audio objects using a selectable clipping policy.
|
|
26
135
|
#
|
|
27
136
|
# @param audios [Array<Audio>]
|
|
137
|
+
# @param strategy [Symbol] `:none`, `:clip`, `:normalize`, `:headroom`, or `:soft_limit`;
|
|
138
|
+
# `:none` preserves above-full-scale sums only when the output format is float
|
|
139
|
+
# @param gains [Array<Numeric>, nil] optional per-source gain in dB
|
|
140
|
+
# @param align [Symbol] `:start`, `:center`, or `:end`
|
|
141
|
+
# @param format [Core::Format, nil] output format, defaulting to the first source format
|
|
142
|
+
# @param work_format [Core::Format, nil] intermediate channel/sample-rate format; samples are processed as float
|
|
143
|
+
# @param headroom_smoothing [Numeric] seconds of gain smoothing around peaks
|
|
28
144
|
# @return [Audio]
|
|
29
|
-
def self.mix(*audios
|
|
145
|
+
def self.mix(*audios, strategy: :clip, gains: nil, align: :start, format: nil, work_format: nil,
|
|
146
|
+
headroom_smoothing: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS)
|
|
30
147
|
raise InvalidParameterError, "at least one Audio is required" if audios.empty?
|
|
31
148
|
raise InvalidParameterError, "all arguments must be Audio instances" unless audios.all? { |audio| audio.is_a?(self) }
|
|
32
149
|
|
|
33
|
-
|
|
34
|
-
|
|
150
|
+
mix_strategy = normalize_mix_strategy!(strategy)
|
|
151
|
+
mix_gains = normalize_mix_gains!(gains, audios.length)
|
|
152
|
+
mix_alignment = normalize_mix_alignment!(align)
|
|
153
|
+
target_format = format || audios.first.format
|
|
154
|
+
raise InvalidParameterError, "format must be Core::Format" unless target_format.is_a?(Core::Format)
|
|
155
|
+
if work_format && !work_format.is_a?(Core::Format)
|
|
156
|
+
raise InvalidParameterError, "work_format must be Core::Format"
|
|
157
|
+
end
|
|
158
|
+
if !format && !work_format && audios.map { |audio| audio.format.sample_rate }.uniq.length > 1
|
|
159
|
+
raise InvalidParameterError, "different sample rates require an explicit format or work_format"
|
|
160
|
+
end
|
|
35
161
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
converted = audios.map { |audio| audio.buffer.convert(
|
|
162
|
+
workspace_format = work_format || target_format.with(sample_format: :float, bit_depth: 32)
|
|
163
|
+
workspace_format = workspace_format.with(sample_format: :float, bit_depth: 32)
|
|
164
|
+
converted = audios.map { |audio| audio.buffer.convert(workspace_format) }
|
|
39
165
|
max_frames = converted.map(&:sample_frame_count).max || 0
|
|
40
|
-
channels =
|
|
166
|
+
channels = workspace_format.channels
|
|
41
167
|
mixed = Array.new(max_frames * channels, 0.0)
|
|
42
168
|
|
|
43
|
-
converted.
|
|
169
|
+
converted.each_with_index do |buffer, audio_index|
|
|
170
|
+
gain_factor = db_to_amplitude(mix_gains.fetch(audio_index))
|
|
171
|
+
sample_offset = mix_alignment_offset(mix_alignment, max_frames, buffer.sample_frame_count) * channels
|
|
44
172
|
buffer.samples.each_with_index do |sample, index|
|
|
45
|
-
|
|
173
|
+
target_index = sample_offset + index
|
|
174
|
+
mixed[target_index] += sample * gain_factor
|
|
46
175
|
end
|
|
47
176
|
end
|
|
48
177
|
|
|
49
|
-
mixed
|
|
50
|
-
new(Core::SampleBuffer.new(mixed,
|
|
178
|
+
apply_mix_strategy!(mixed, mix_strategy, format: workspace_format, headroom_smoothing: headroom_smoothing)
|
|
179
|
+
new(Core::SampleBuffer.new(mixed, workspace_format).convert(target_format))
|
|
51
180
|
end
|
|
52
181
|
|
|
53
182
|
# Creates a streaming processing pipeline for an input path/IO.
|
|
@@ -56,12 +185,17 @@ module Wavify
|
|
|
56
185
|
# @param chunk_size [Integer] chunk size in frames
|
|
57
186
|
# @param format [Core::Format, nil] optional source format override
|
|
58
187
|
# @param codec_options [Hash] codec-specific options forwarded to `.stream_read`
|
|
188
|
+
# @param strict [Boolean] verify extension and magic-byte codec agreement
|
|
189
|
+
# @param filename [String, nil] optional filename hint for IO inputs
|
|
59
190
|
# @return [Core::Stream]
|
|
60
|
-
def self.stream(path_or_io, chunk_size: 4096, format: nil, codec_options: nil)
|
|
61
|
-
codec = Codecs::Registry.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
191
|
+
def self.stream(path_or_io, chunk_size: 4096, format: nil, codec_options: nil, strict: false, filename: nil)
|
|
192
|
+
codec = Codecs::Registry.detect_for_read(path_or_io, strict: strict, filename: filename)
|
|
193
|
+
options = normalize_codec_options!(codec_options)
|
|
194
|
+
source_format = format
|
|
195
|
+
if codec == Codecs::Raw && !source_format.is_a?(Core::Format)
|
|
196
|
+
raise InvalidFormatError, "format is required for raw stream input"
|
|
197
|
+
end
|
|
198
|
+
options = options.merge(format: source_format) if codec == Codecs::Raw
|
|
65
199
|
|
|
66
200
|
stream = Core::Stream.new(
|
|
67
201
|
path_or_io,
|
|
@@ -81,12 +215,13 @@ module Wavify
|
|
|
81
215
|
# @param frequency [Numeric] oscillator frequency in Hz
|
|
82
216
|
# @param duration [Numeric] duration in seconds
|
|
83
217
|
# @param format [Core::Format] output format
|
|
84
|
-
# @param waveform [Symbol] `:sine`, `:square`, `:triangle`, `:sawtooth`, `:white_noise`
|
|
218
|
+
# @param waveform [Symbol] `:sine`, `:square`, `:triangle`, `:sawtooth`, `:pulse`, `:white_noise`
|
|
85
219
|
# @return [Audio]
|
|
86
|
-
def self.tone(frequency:, duration:, format:, waveform: :sine)
|
|
220
|
+
def self.tone(frequency:, duration:, format:, waveform: :sine, **oscillator_options)
|
|
87
221
|
oscillator = DSP::Oscillator.new(
|
|
88
222
|
waveform: waveform,
|
|
89
|
-
frequency: frequency
|
|
223
|
+
frequency: frequency,
|
|
224
|
+
**oscillator_options
|
|
90
225
|
)
|
|
91
226
|
new(oscillator.generate(duration, format: format))
|
|
92
227
|
end
|
|
@@ -97,12 +232,13 @@ module Wavify
|
|
|
97
232
|
# @param format [Core::Format]
|
|
98
233
|
# @return [Audio]
|
|
99
234
|
def self.silence(duration_seconds, format:)
|
|
100
|
-
|
|
101
|
-
|
|
235
|
+
seconds = duration_seconds.is_a?(Core::Duration) ? duration_seconds.total_seconds : duration_seconds
|
|
236
|
+
unless seconds.is_a?(Numeric) && seconds.respond_to?(:finite?) && seconds.finite? && seconds >= 0
|
|
237
|
+
raise InvalidParameterError, "duration_seconds must be a non-negative finite Numeric or Core::Duration: #{duration_seconds.inspect}"
|
|
102
238
|
end
|
|
103
239
|
raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
|
|
104
240
|
|
|
105
|
-
frame_count = (
|
|
241
|
+
frame_count = (seconds.to_f * format.sample_rate).round
|
|
106
242
|
default_sample = format.sample_format == :float ? 0.0 : 0
|
|
107
243
|
samples = Array.new(frame_count * format.channels, default_sample)
|
|
108
244
|
new(Core::SampleBuffer.new(samples, format))
|
|
@@ -115,14 +251,33 @@ module Wavify
|
|
|
115
251
|
@buffer = buffer
|
|
116
252
|
end
|
|
117
253
|
|
|
254
|
+
# Value equality based on the underlying immutable sample buffer.
|
|
255
|
+
def ==(other)
|
|
256
|
+
other.is_a?(Audio) && @buffer == other.buffer
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
alias eql? ==
|
|
260
|
+
|
|
261
|
+
def hash
|
|
262
|
+
@buffer.hash
|
|
263
|
+
end
|
|
264
|
+
|
|
118
265
|
# Writes the audio to a file path using codec auto-detection.
|
|
119
266
|
#
|
|
120
|
-
# @param path [String]
|
|
267
|
+
# @param path [String, IO]
|
|
121
268
|
# @param format [Core::Format, nil] optional output format
|
|
269
|
+
# @param codec_options [Hash] codec-specific options forwarded to `.write`
|
|
270
|
+
# @param codec [Symbol, Class, nil] explicit output codec for generic IO targets
|
|
271
|
+
# @param filename [String, nil] filename hint used for codec selection
|
|
272
|
+
# @param overwrite [Boolean] whether existing path output may be replaced
|
|
122
273
|
# @return [Audio] self
|
|
123
|
-
def write(path, format: nil)
|
|
124
|
-
|
|
125
|
-
codec.
|
|
274
|
+
def write(path, format: nil, codec: nil, filename: nil, codec_options: nil, overwrite: true)
|
|
275
|
+
validate_overwrite!(path, overwrite)
|
|
276
|
+
output_codec = codec ? Codecs::Registry.resolve(codec) : Codecs::Registry.detect_for_write(path, filename: filename)
|
|
277
|
+
options = normalize_codec_options!(codec_options)
|
|
278
|
+
with_output_target(path, overwrite: overwrite) do |target|
|
|
279
|
+
output_codec.write(target, @buffer, format: format || @buffer.format, **options)
|
|
280
|
+
end
|
|
126
281
|
self
|
|
127
282
|
end
|
|
128
283
|
|
|
@@ -141,12 +296,76 @@ module Wavify
|
|
|
141
296
|
@buffer.sample_frame_count
|
|
142
297
|
end
|
|
143
298
|
|
|
299
|
+
# @return [Integer]
|
|
300
|
+
def channels
|
|
301
|
+
format.channels
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# @return [Integer]
|
|
305
|
+
def sample_rate
|
|
306
|
+
format.sample_rate
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# @return [Integer]
|
|
310
|
+
def bit_depth
|
|
311
|
+
format.bit_depth
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Converts to another bit depth while keeping the remaining format fields.
|
|
315
|
+
def with_bit_depth(value, dither: false, dither_seed: nil)
|
|
316
|
+
convert(format.with(bit_depth: value), dither: dither, dither_seed: dither_seed)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# @return [Array<Array<Numeric>>] sample frames
|
|
320
|
+
def frames
|
|
321
|
+
@buffer.frame_view.to_a
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Enumerates sample frames.
|
|
325
|
+
#
|
|
326
|
+
# @yield [frame, frame_index]
|
|
327
|
+
# @yieldparam frame [Array<Numeric>]
|
|
328
|
+
# @yieldparam frame_index [Integer]
|
|
329
|
+
# @return [Enumerator, Audio]
|
|
330
|
+
def each_frame
|
|
331
|
+
return enum_for(:each_frame) unless block_given?
|
|
332
|
+
|
|
333
|
+
@buffer.frame_view.each.with_index do |frame, frame_index|
|
|
334
|
+
yield frame, frame_index
|
|
335
|
+
end
|
|
336
|
+
self
|
|
337
|
+
end
|
|
338
|
+
|
|
144
339
|
# Converts to a new format/channels.
|
|
145
340
|
#
|
|
146
341
|
# @param new_format [Core::Format]
|
|
342
|
+
# @param dither [Boolean] add TPDF dither when converting to PCM
|
|
343
|
+
# @param dither_seed [Integer, nil] deterministic seed for dither noise
|
|
344
|
+
# @return [Audio]
|
|
345
|
+
def convert(new_format, dither: false, dither_seed: nil, resampler: :linear)
|
|
346
|
+
self.class.new(@buffer.convert(new_format, dither: dither, dither_seed: dither_seed, resampler: resampler))
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Converts to another sample rate.
|
|
350
|
+
#
|
|
351
|
+
# @param sample_rate [Integer]
|
|
352
|
+
# @return [Audio]
|
|
353
|
+
def resample(sample_rate:, resampler: :linear)
|
|
354
|
+
convert(format.with(sample_rate: sample_rate), resampler: resampler)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Converts to mono by downmixing channels.
|
|
358
|
+
#
|
|
147
359
|
# @return [Audio]
|
|
148
|
-
def
|
|
149
|
-
|
|
360
|
+
def to_mono
|
|
361
|
+
convert(format.with(channels: 1))
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Converts to stereo by upmixing/downmixing channels.
|
|
365
|
+
#
|
|
366
|
+
# @return [Audio]
|
|
367
|
+
def to_stereo
|
|
368
|
+
convert(format.with(channels: 2))
|
|
150
369
|
end
|
|
151
370
|
|
|
152
371
|
# Splits the audio into two clips at a time offset.
|
|
@@ -161,26 +380,178 @@ module Wavify
|
|
|
161
380
|
[self.class.new(left), self.class.new(right)]
|
|
162
381
|
end
|
|
163
382
|
|
|
383
|
+
# Slices audio between two time offsets.
|
|
384
|
+
#
|
|
385
|
+
# @param from [Numeric, Core::Duration]
|
|
386
|
+
# @param to [Numeric, Core::Duration]
|
|
387
|
+
# @return [Audio]
|
|
388
|
+
def slice(from:, to:)
|
|
389
|
+
start_frame = coerce_time_to_frame(from, upper_bound: @buffer.sample_frame_count)
|
|
390
|
+
end_frame = coerce_time_to_frame(to, upper_bound: @buffer.sample_frame_count)
|
|
391
|
+
raise InvalidParameterError, "to must be greater than or equal to from" if end_frame < start_frame
|
|
392
|
+
|
|
393
|
+
self.class.new(@buffer.slice(start_frame, end_frame - start_frame))
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Crops audio from a start time for a duration.
|
|
397
|
+
#
|
|
398
|
+
# @param start [Numeric, Core::Duration]
|
|
399
|
+
# @param duration [Numeric, Core::Duration]
|
|
400
|
+
# @return [Audio]
|
|
401
|
+
def crop(start:, duration:)
|
|
402
|
+
start_frame = coerce_time_to_frame(start, upper_bound: @buffer.sample_frame_count)
|
|
403
|
+
frame_length = coerce_duration_to_frame(duration)
|
|
404
|
+
self.class.new(@buffer.slice(start_frame, [frame_length, @buffer.sample_frame_count - start_frame].min))
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Concatenates another audio clip after this one.
|
|
408
|
+
#
|
|
409
|
+
# @param other [Audio]
|
|
410
|
+
# @return [Audio]
|
|
411
|
+
def concat(other)
|
|
412
|
+
validate_audio!(other, :other)
|
|
413
|
+
self.class.new(@buffer.concat(other.buffer))
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
alias append concat
|
|
417
|
+
alias + concat
|
|
418
|
+
|
|
419
|
+
# Prepends another audio clip before this one.
|
|
420
|
+
#
|
|
421
|
+
# @param other [Audio]
|
|
422
|
+
# @return [Audio]
|
|
423
|
+
def prepend(other)
|
|
424
|
+
validate_audio!(other, :other)
|
|
425
|
+
other.concat(self)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Overlays another audio clip at a time offset.
|
|
429
|
+
#
|
|
430
|
+
# @param other [Audio]
|
|
431
|
+
# @param at [Numeric, Core::Duration]
|
|
432
|
+
# @param strategy [Symbol] mix clipping policy
|
|
433
|
+
# @param headroom_smoothing [Numeric] seconds of gain smoothing around peaks
|
|
434
|
+
# @return [Audio]
|
|
435
|
+
def overlay(other, at:, strategy: :clip, headroom_smoothing: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS)
|
|
436
|
+
validate_audio!(other, :other)
|
|
437
|
+
start_frame = coerce_time_to_frame(at, upper_bound: nil)
|
|
438
|
+
work_format = float_work_format(format)
|
|
439
|
+
base = @buffer.convert(work_format)
|
|
440
|
+
overlay_buffer = other.buffer.convert(work_format)
|
|
441
|
+
channels = work_format.channels
|
|
442
|
+
mixed_length = [base.samples.length, (start_frame * channels) + overlay_buffer.samples.length].max
|
|
443
|
+
mixed = Array.new(mixed_length, 0.0)
|
|
444
|
+
base.samples.each_with_index do |sample, index|
|
|
445
|
+
mixed[index] += sample
|
|
446
|
+
end
|
|
447
|
+
offset = start_frame * channels
|
|
448
|
+
overlay_buffer.samples.each_with_index do |sample, index|
|
|
449
|
+
target_index = offset + index
|
|
450
|
+
mixed[target_index] += sample
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
self.class.send(
|
|
454
|
+
:apply_mix_strategy!,
|
|
455
|
+
mixed,
|
|
456
|
+
self.class.send(:normalize_mix_strategy!, strategy),
|
|
457
|
+
format: work_format,
|
|
458
|
+
headroom_smoothing: headroom_smoothing
|
|
459
|
+
)
|
|
460
|
+
self.class.new(Core::SampleBuffer.new(mixed, work_format).convert(format))
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Crossfades this audio into another clip.
|
|
464
|
+
#
|
|
465
|
+
# @param other [Audio]
|
|
466
|
+
# @param duration [Numeric, Core::Duration]
|
|
467
|
+
# @return [Audio]
|
|
468
|
+
def crossfade(other, duration:)
|
|
469
|
+
validate_audio!(other, :other)
|
|
470
|
+
rhs = other.convert(format)
|
|
471
|
+
overlap_frames = coerce_duration_to_frame(duration)
|
|
472
|
+
max_overlap = [sample_frame_count, rhs.sample_frame_count].min
|
|
473
|
+
raise InvalidParameterError, "duration is longer than one of the clips" if overlap_frames > max_overlap
|
|
474
|
+
|
|
475
|
+
seconds = overlap_frames.to_f / sample_rate
|
|
476
|
+
left_head = self.class.new(@buffer.slice(0, sample_frame_count - overlap_frames))
|
|
477
|
+
left_tail = self.class.new(@buffer.slice(sample_frame_count - overlap_frames, overlap_frames)).fade_out(seconds)
|
|
478
|
+
right_head = self.class.new(rhs.buffer.slice(0, overlap_frames)).fade_in(seconds)
|
|
479
|
+
right_tail = self.class.new(rhs.buffer.slice(overlap_frames, rhs.sample_frame_count - overlap_frames))
|
|
480
|
+
middle = self.class.mix(left_tail, right_head, strategy: :clip)
|
|
481
|
+
|
|
482
|
+
left_head.concat(middle).concat(right_tail)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Adds silence before the audio.
|
|
486
|
+
#
|
|
487
|
+
# @param seconds [Numeric, Core::Duration]
|
|
488
|
+
# @return [Audio]
|
|
489
|
+
def pad_start(seconds)
|
|
490
|
+
self.class.silence(coerce_seconds(seconds), format: format).concat(self)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Adds silence after the audio.
|
|
494
|
+
#
|
|
495
|
+
# @param seconds [Numeric, Core::Duration]
|
|
496
|
+
# @return [Audio]
|
|
497
|
+
def pad_end(seconds)
|
|
498
|
+
concat(self.class.silence(coerce_seconds(seconds), format: format))
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Inserts silence at a time offset.
|
|
502
|
+
#
|
|
503
|
+
# @param at [Numeric, Core::Duration]
|
|
504
|
+
# @param duration [Numeric, Core::Duration]
|
|
505
|
+
# @return [Audio]
|
|
506
|
+
def insert_silence(at:, duration:)
|
|
507
|
+
left, right = split(at: at)
|
|
508
|
+
left.concat(self.class.silence(coerce_seconds(duration), format: format)).concat(right)
|
|
509
|
+
end
|
|
510
|
+
|
|
164
511
|
# Repeats the audio content.
|
|
165
512
|
#
|
|
166
513
|
# @param times [Integer] repetition count
|
|
167
514
|
# @return [Audio]
|
|
168
|
-
def
|
|
515
|
+
def repeat(times:)
|
|
169
516
|
raise InvalidParameterError, "times must be a non-negative Integer" unless times.is_a?(Integer) && times >= 0
|
|
170
517
|
|
|
171
518
|
return self.class.new(Core::SampleBuffer.new([], @buffer.format)) if times.zero?
|
|
172
519
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
520
|
+
repeated_frames = sample_frame_count * times
|
|
521
|
+
repeated_bytes = repeated_frames * format.block_align
|
|
522
|
+
if repeated_frames > MAX_REPEAT_FRAMES || repeated_bytes > MAX_REPEAT_BYTES
|
|
523
|
+
raise InvalidParameterError,
|
|
524
|
+
"repeat output exceeds limits (#{repeated_frames} frames, #{repeated_bytes} bytes); use #repeat_chunks"
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
self.class.new(Core::SampleBuffer.new(@buffer.samples * times, @buffer.format))
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Lazily yields repeated audio in bounded chunks.
|
|
531
|
+
def repeat_chunks(times:, chunk_frames: 4_096)
|
|
532
|
+
raise InvalidParameterError, "times must be a non-negative Integer" unless times.is_a?(Integer) && times >= 0
|
|
533
|
+
unless chunk_frames.is_a?(Integer) && chunk_frames.positive?
|
|
534
|
+
raise InvalidParameterError, "chunk_frames must be a positive Integer"
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
Enumerator.new do |yielder|
|
|
538
|
+
times.times do
|
|
539
|
+
offset = 0
|
|
540
|
+
while offset < sample_frame_count
|
|
541
|
+
length = [chunk_frames, sample_frame_count - offset].min
|
|
542
|
+
yielder << @buffer.slice(offset, length)
|
|
543
|
+
offset += length
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
end
|
|
176
547
|
end
|
|
177
548
|
|
|
178
|
-
# In-place variant of {#
|
|
549
|
+
# In-place variant of {#repeat}.
|
|
179
550
|
#
|
|
180
551
|
# @param times [Integer]
|
|
181
552
|
# @return [Audio] self
|
|
182
|
-
def
|
|
183
|
-
replace_buffer!(
|
|
553
|
+
def repeat!(times:)
|
|
554
|
+
replace_buffer!(repeat(times: times).buffer)
|
|
184
555
|
self
|
|
185
556
|
end
|
|
186
557
|
|
|
@@ -204,9 +575,10 @@ module Wavify
|
|
|
204
575
|
# @param db [Numeric]
|
|
205
576
|
# @return [Audio]
|
|
206
577
|
def gain(db)
|
|
207
|
-
|
|
578
|
+
db = validate_finite_numeric!(db, :db)
|
|
579
|
+
factor = 10.0**(db / 20.0)
|
|
208
580
|
transform_samples do |samples, _format|
|
|
209
|
-
samples.map { |sample|
|
|
581
|
+
samples.map! { |sample| sample * factor }
|
|
210
582
|
end
|
|
211
583
|
end
|
|
212
584
|
|
|
@@ -223,14 +595,30 @@ module Wavify
|
|
|
223
595
|
#
|
|
224
596
|
# @param target_db [Numeric]
|
|
225
597
|
# @return [Audio]
|
|
226
|
-
def normalize(target_db: 0.0)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
598
|
+
def normalize(target_db: 0.0, mode: :peak)
|
|
599
|
+
normalize_mode = self.class.send(:normalize_mode!, mode)
|
|
600
|
+
unless target_db.is_a?(Numeric) && target_db.respond_to?(:finite?) && target_db.finite?
|
|
601
|
+
raise InvalidParameterError, "target_db must be a finite Numeric"
|
|
602
|
+
end
|
|
603
|
+
if normalize_mode == :peak && target_db.positive?
|
|
604
|
+
raise InvalidParameterError, "peak target_db must be <= 0 dBFS"
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
transform_samples do |samples, work_format|
|
|
608
|
+
if normalize_mode == :lufs
|
|
609
|
+
current_lufs = integrated_loudness(samples, work_format)
|
|
610
|
+
next samples unless current_lufs.finite?
|
|
611
|
+
|
|
612
|
+
factor = 10.0**((target_db.to_f - current_lufs) / 20.0)
|
|
613
|
+
next samples.map! { |sample| sample * factor }
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
current = normalize_reference_amplitude(samples, normalize_mode)
|
|
617
|
+
next samples if current.zero?
|
|
230
618
|
|
|
231
619
|
target = 10.0**(target_db.to_f / 20.0)
|
|
232
|
-
factor = target /
|
|
233
|
-
samples.map { |sample|
|
|
620
|
+
factor = target / current
|
|
621
|
+
samples.map! { |sample| sample * factor }
|
|
234
622
|
end
|
|
235
623
|
end
|
|
236
624
|
|
|
@@ -238,8 +626,8 @@ module Wavify
|
|
|
238
626
|
#
|
|
239
627
|
# @param target_db [Numeric]
|
|
240
628
|
# @return [Audio] self
|
|
241
|
-
def normalize!(target_db: 0.0)
|
|
242
|
-
replace_buffer!(normalize(target_db: target_db).buffer)
|
|
629
|
+
def normalize!(target_db: 0.0, mode: :peak)
|
|
630
|
+
replace_buffer!(normalize(target_db: target_db, mode: mode).buffer)
|
|
243
631
|
self
|
|
244
632
|
end
|
|
245
633
|
|
|
@@ -252,13 +640,18 @@ module Wavify
|
|
|
252
640
|
|
|
253
641
|
float_buffer = @buffer.convert(float_work_format(@buffer.format))
|
|
254
642
|
channels = float_buffer.format.channels
|
|
255
|
-
|
|
256
|
-
|
|
643
|
+
first = nil
|
|
644
|
+
last = nil
|
|
645
|
+
float_buffer.samples.each_slice(channels).with_index do |frame, frame_index|
|
|
646
|
+
next unless frame.any? { |sample| sample.abs >= threshold }
|
|
647
|
+
|
|
648
|
+
first ||= frame_index
|
|
649
|
+
last = frame_index
|
|
650
|
+
end
|
|
257
651
|
return self.class.new(Core::SampleBuffer.new([], @buffer.format)) unless first
|
|
258
652
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
self.class.new(Core::SampleBuffer.new(trimmed, float_buffer.format).convert(@buffer.format))
|
|
653
|
+
float_trimmed = float_buffer.slice(first, last - first + 1)
|
|
654
|
+
self.class.new(float_trimmed.convert(@buffer.format))
|
|
262
655
|
end
|
|
263
656
|
|
|
264
657
|
# In-place variant of {#trim}.
|
|
@@ -270,67 +663,72 @@ module Wavify
|
|
|
270
663
|
self
|
|
271
664
|
end
|
|
272
665
|
|
|
273
|
-
# Applies a
|
|
666
|
+
# Applies a fade-in.
|
|
274
667
|
#
|
|
275
|
-
# @param seconds [Numeric]
|
|
668
|
+
# @param seconds [Numeric, Core::Duration]
|
|
669
|
+
# @param curve [Symbol] `:linear`, `:exp`, or `:log`
|
|
276
670
|
# @return [Audio]
|
|
277
|
-
def fade_in(seconds)
|
|
278
|
-
apply_fade(seconds: seconds, mode: :in)
|
|
671
|
+
def fade_in(seconds, curve: :linear)
|
|
672
|
+
apply_fade(seconds: seconds, mode: :in, curve: curve)
|
|
279
673
|
end
|
|
280
674
|
|
|
281
675
|
# In-place variant of {#fade_in}.
|
|
282
676
|
#
|
|
283
|
-
# @param seconds [Numeric]
|
|
677
|
+
# @param seconds [Numeric, Core::Duration]
|
|
678
|
+
# @param curve [Symbol]
|
|
284
679
|
# @return [Audio] self
|
|
285
|
-
def fade_in!(seconds)
|
|
286
|
-
replace_buffer!(fade_in(seconds).buffer)
|
|
680
|
+
def fade_in!(seconds, curve: :linear)
|
|
681
|
+
replace_buffer!(fade_in(seconds, curve: curve).buffer)
|
|
287
682
|
self
|
|
288
683
|
end
|
|
289
684
|
|
|
290
|
-
# Applies a
|
|
685
|
+
# Applies a fade-out.
|
|
291
686
|
#
|
|
292
|
-
# @param seconds [Numeric]
|
|
687
|
+
# @param seconds [Numeric, Core::Duration]
|
|
688
|
+
# @param curve [Symbol] `:linear`, `:exp`, or `:log`
|
|
293
689
|
# @return [Audio]
|
|
294
|
-
def fade_out(seconds)
|
|
295
|
-
apply_fade(seconds: seconds, mode: :out)
|
|
690
|
+
def fade_out(seconds, curve: :linear)
|
|
691
|
+
apply_fade(seconds: seconds, mode: :out, curve: curve)
|
|
296
692
|
end
|
|
297
693
|
|
|
298
694
|
# In-place variant of {#fade_out}.
|
|
299
695
|
#
|
|
300
|
-
# @param seconds [Numeric]
|
|
696
|
+
# @param seconds [Numeric, Core::Duration]
|
|
697
|
+
# @param curve [Symbol]
|
|
301
698
|
# @return [Audio] self
|
|
302
|
-
def fade_out!(seconds)
|
|
303
|
-
replace_buffer!(fade_out(seconds).buffer)
|
|
699
|
+
def fade_out!(seconds, curve: :linear)
|
|
700
|
+
replace_buffer!(fade_out(seconds, curve: curve).buffer)
|
|
304
701
|
self
|
|
305
702
|
end
|
|
306
703
|
|
|
307
|
-
#
|
|
704
|
+
# Applies a fade in either direction.
|
|
308
705
|
#
|
|
309
|
-
#
|
|
706
|
+
# @param seconds [Numeric, Core::Duration]
|
|
707
|
+
# @param type [Symbol] `:in` or `:out`
|
|
708
|
+
# @param curve [Symbol] `:linear`, `:exp`, or `:log`
|
|
709
|
+
# @return [Audio]
|
|
710
|
+
def fade(seconds, type:, curve: :linear)
|
|
711
|
+
apply_fade(seconds: seconds, mode: type, curve: curve)
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# Constant-power pan for a mono source.
|
|
310
715
|
#
|
|
311
716
|
# @param position [Numeric] `-1.0` (left) to `1.0` (right)
|
|
312
717
|
# @return [Audio]
|
|
313
718
|
def pan(position)
|
|
314
719
|
validate_pan_position!(position)
|
|
720
|
+
raise InvalidParameterError, "pan requires mono input; use #balance for stereo" unless @buffer.format.channels == 1
|
|
315
721
|
|
|
316
|
-
|
|
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
|
|
722
|
+
source_format = @buffer.format.with(channels: 2)
|
|
324
723
|
|
|
325
724
|
transform_samples(target_format: source_format) do |samples, _format|
|
|
326
725
|
left_gain, right_gain = constant_power_pan_gains(position.to_f)
|
|
327
|
-
|
|
328
|
-
result.each_slice(2).with_index do |(left, right), frame_index|
|
|
726
|
+
samples.each_slice(2).with_index do |(left, right), frame_index|
|
|
329
727
|
base = frame_index * 2
|
|
330
|
-
|
|
331
|
-
|
|
728
|
+
samples[base] = left * left_gain
|
|
729
|
+
samples[base + 1] = right * right_gain
|
|
332
730
|
end
|
|
333
|
-
|
|
731
|
+
samples
|
|
334
732
|
end
|
|
335
733
|
end
|
|
336
734
|
|
|
@@ -343,6 +741,41 @@ module Wavify
|
|
|
343
741
|
self
|
|
344
742
|
end
|
|
345
743
|
|
|
744
|
+
# Adjusts the relative level of existing stereo channels without moving content between them.
|
|
745
|
+
def balance(position)
|
|
746
|
+
validate_pan_position!(position)
|
|
747
|
+
raise InvalidParameterError, "balance requires stereo input" unless channels == 2
|
|
748
|
+
|
|
749
|
+
transform_samples do |samples, _format|
|
|
750
|
+
left_gain = position.positive? ? Math.cos(position * Math::PI / 2.0) : 1.0
|
|
751
|
+
right_gain = position.negative? ? Math.cos(position.abs * Math::PI / 2.0) : 1.0
|
|
752
|
+
samples.each_slice(2).with_index do |(left, right), frame_index|
|
|
753
|
+
base = frame_index * 2
|
|
754
|
+
samples[base] = left * left_gain
|
|
755
|
+
samples[base + 1] = right * right_gain
|
|
756
|
+
end
|
|
757
|
+
samples
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# Rotates a stereo image, transferring energy between left and right.
|
|
762
|
+
def stereo_rotate(position)
|
|
763
|
+
validate_pan_position!(position)
|
|
764
|
+
raise InvalidParameterError, "stereo_rotate requires stereo input" unless channels == 2
|
|
765
|
+
|
|
766
|
+
angle = position * Math::PI / 4.0
|
|
767
|
+
cosine = Math.cos(angle)
|
|
768
|
+
sine = Math.sin(angle)
|
|
769
|
+
transform_samples do |samples, _format|
|
|
770
|
+
samples.each_slice(2).with_index do |(left, right), frame_index|
|
|
771
|
+
base = frame_index * 2
|
|
772
|
+
samples[base] = (left * cosine) - (right * sine)
|
|
773
|
+
samples[base + 1] = (left * sine) + (right * cosine)
|
|
774
|
+
end
|
|
775
|
+
samples
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
|
|
346
779
|
# Applies an effect/processor object to the audio buffer.
|
|
347
780
|
#
|
|
348
781
|
# Accepted interfaces: `#process`, `#call`, or `#apply`.
|
|
@@ -350,17 +783,7 @@ module Wavify
|
|
|
350
783
|
# @param effect [Object]
|
|
351
784
|
# @return [Audio]
|
|
352
785
|
def apply(effect)
|
|
353
|
-
processed =
|
|
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)
|
|
786
|
+
processed = DSP::Processor.render(effect, @buffer)
|
|
364
787
|
|
|
365
788
|
self.class.new(processed)
|
|
366
789
|
end
|
|
@@ -374,14 +797,64 @@ module Wavify
|
|
|
374
797
|
self
|
|
375
798
|
end
|
|
376
799
|
|
|
800
|
+
# Maps normalized float samples and returns a new audio object in the original format.
|
|
801
|
+
#
|
|
802
|
+
# @yield [sample, sample_index]
|
|
803
|
+
# @yieldparam sample [Float]
|
|
804
|
+
# @yieldparam sample_index [Integer]
|
|
805
|
+
# @return [Enumerator, Audio]
|
|
806
|
+
def map_samples
|
|
807
|
+
return enum_for(:map_samples) unless block_given?
|
|
808
|
+
|
|
809
|
+
transform_samples do |samples, _format|
|
|
810
|
+
samples.map!.with_index do |sample, sample_index|
|
|
811
|
+
validate_mapped_sample!(yield(sample, sample_index), "sample #{sample_index}")
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# Maps normalized float frames and returns a new audio object in the original format.
|
|
817
|
+
#
|
|
818
|
+
# @yield [frame, frame_index]
|
|
819
|
+
# @yieldparam frame [Array<Float>]
|
|
820
|
+
# @yieldparam frame_index [Integer]
|
|
821
|
+
# @return [Enumerator, Audio]
|
|
822
|
+
def map_frames
|
|
823
|
+
return enum_for(:map_frames) unless block_given?
|
|
824
|
+
|
|
825
|
+
transform_samples do |samples, work_format|
|
|
826
|
+
mapped = []
|
|
827
|
+
samples.each_slice(work_format.channels).with_index do |frame, frame_index|
|
|
828
|
+
output = yield(frame.dup, frame_index)
|
|
829
|
+
unless output.is_a?(Array) && output.length == work_format.channels
|
|
830
|
+
raise InvalidParameterError, "map_frames block must return an Array with #{work_format.channels} samples"
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
mapped.concat(output.map.with_index do |sample, channel_index|
|
|
834
|
+
validate_mapped_sample!(sample, "frame #{frame_index}, channel #{channel_index}")
|
|
835
|
+
end)
|
|
836
|
+
end
|
|
837
|
+
mapped
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
|
|
377
841
|
# Returns the absolute peak amplitude in float working space.
|
|
378
842
|
#
|
|
379
843
|
# @return [Float] 0.0..1.0
|
|
380
|
-
def
|
|
844
|
+
def sample_peak_amplitude
|
|
381
845
|
float_buffer = @buffer.convert(float_work_format(@buffer.format))
|
|
382
846
|
float_buffer.samples.map(&:abs).max || 0.0
|
|
383
847
|
end
|
|
384
848
|
|
|
849
|
+
|
|
850
|
+
alias peak_amplitude sample_peak_amplitude
|
|
851
|
+
|
|
852
|
+
# Returns oversampled inter-sample peak amplitude.
|
|
853
|
+
def true_peak_amplitude(oversampling: DSP::LoudnessMeter::TRUE_PEAK_OVERSAMPLING)
|
|
854
|
+
float_buffer = @buffer.convert(float_work_format(@buffer.format))
|
|
855
|
+
DSP::LoudnessMeter.true_peak(float_buffer, format: float_buffer.format, oversampling: oversampling)
|
|
856
|
+
end
|
|
857
|
+
|
|
385
858
|
# Returns RMS amplitude in float working space.
|
|
386
859
|
#
|
|
387
860
|
# @return [Float] 0.0..1.0
|
|
@@ -393,48 +866,390 @@ module Wavify
|
|
|
393
866
|
Math.sqrt(square_sum / float_buffer.samples.length)
|
|
394
867
|
end
|
|
395
868
|
|
|
869
|
+
# @return [Float] peak amplitude in dBFS
|
|
870
|
+
def peak_dbfs
|
|
871
|
+
sample_peak_dbfs
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def sample_peak_dbfs
|
|
875
|
+
amplitude_to_dbfs(sample_peak_amplitude)
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
def true_peak_dbfs(oversampling: DSP::LoudnessMeter::TRUE_PEAK_OVERSAMPLING)
|
|
879
|
+
amplitude_to_dbfs(true_peak_amplitude(oversampling: oversampling))
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
# @return [Float] RMS amplitude in dBFS
|
|
883
|
+
def rms_dbfs
|
|
884
|
+
amplitude_to_dbfs(rms_amplitude)
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# @return [Float] BS.1770 integrated loudness in LUFS
|
|
888
|
+
def lufs
|
|
889
|
+
float_buffer = @buffer.convert(float_work_format(@buffer.format))
|
|
890
|
+
integrated_loudness(float_buffer.samples, float_buffer.format)
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
# @return [Hash] basic audio statistics
|
|
894
|
+
def stats
|
|
895
|
+
float_buffer = @buffer.convert(float_work_format(@buffer.format))
|
|
896
|
+
samples = float_buffer.samples
|
|
897
|
+
accumulated = accumulate_statistics(samples, float_buffer.format.channels, consecutive_frames: 2)
|
|
898
|
+
peak = accumulated.fetch(:peak)
|
|
899
|
+
rms = accumulated.fetch(:rms)
|
|
900
|
+
true_peak = DSP::LoudnessMeter.true_peak(float_buffer, format: float_buffer.format)
|
|
901
|
+
{
|
|
902
|
+
format: format,
|
|
903
|
+
duration: duration,
|
|
904
|
+
sample_frame_count: sample_frame_count,
|
|
905
|
+
peak_amplitude: peak,
|
|
906
|
+
sample_peak_amplitude: peak,
|
|
907
|
+
true_peak_amplitude: true_peak,
|
|
908
|
+
rms_amplitude: rms,
|
|
909
|
+
peak_dbfs: amplitude_to_dbfs(peak),
|
|
910
|
+
sample_peak_dbfs: amplitude_to_dbfs(peak),
|
|
911
|
+
true_peak_dbfs: amplitude_to_dbfs(true_peak),
|
|
912
|
+
rms_dbfs: amplitude_to_dbfs(rms),
|
|
913
|
+
lufs: integrated_loudness(samples, float_buffer.format),
|
|
914
|
+
clipped: accumulated.fetch(:clipped),
|
|
915
|
+
silent: peak.zero?,
|
|
916
|
+
dc_offsets: accumulated.fetch(:dc_offsets),
|
|
917
|
+
zero_crossing_rate: accumulated.fetch(:zero_crossing_rate)
|
|
918
|
+
}
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
# @param threshold [Numeric]
|
|
922
|
+
# @return [Boolean]
|
|
923
|
+
def silent?(threshold: 0.0)
|
|
924
|
+
raise InvalidParameterError, "threshold must be Numeric in 0.0..1.0" unless threshold.is_a?(Numeric) && threshold.between?(0.0, 1.0)
|
|
925
|
+
|
|
926
|
+
peak_amplitude <= threshold
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# Detects likely digital clipping by looking for consecutive full-scale
|
|
930
|
+
# samples on the same channel. A single legal full-scale PCM sample is not
|
|
931
|
+
# sufficient evidence that the source was clipped.
|
|
932
|
+
#
|
|
933
|
+
# @param consecutive_frames [Integer] full-scale frames required per channel
|
|
934
|
+
# @return [Boolean]
|
|
935
|
+
def clipped?(consecutive_frames: 2)
|
|
936
|
+
unless consecutive_frames.is_a?(Integer) && consecutive_frames.positive?
|
|
937
|
+
raise InvalidParameterError, "consecutive_frames must be a positive Integer"
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
float_buffer = @buffer.convert(float_work_format(@buffer.format))
|
|
941
|
+
clipped_samples?(float_buffer.samples, float_buffer.format.channels, consecutive_frames: consecutive_frames)
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
# @return [Float] average sample offset in normalized float space
|
|
945
|
+
def dc_offset
|
|
946
|
+
float_buffer = @buffer.convert(float_work_format(@buffer.format))
|
|
947
|
+
return 0.0 if float_buffer.samples.empty?
|
|
948
|
+
|
|
949
|
+
float_buffer.samples.sum / float_buffer.samples.length.to_f
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
# Returns the mean offset for each channel.
|
|
953
|
+
def dc_offsets
|
|
954
|
+
float_buffer = @buffer.convert(float_work_format(@buffer.format))
|
|
955
|
+
channels = float_buffer.format.channels
|
|
956
|
+
return Array.new(channels, 0.0) if float_buffer.sample_frame_count.zero?
|
|
957
|
+
|
|
958
|
+
sums = Array.new(channels, 0.0)
|
|
959
|
+
float_buffer.each_frame_sample { |sample, _frame, channel| sums[channel] += sample }
|
|
960
|
+
sums.map { |sum| sum / float_buffer.sample_frame_count.to_f }
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# @return [Audio]
|
|
964
|
+
def remove_dc_offset
|
|
965
|
+
offsets = dc_offsets
|
|
966
|
+
map_samples { |sample, index| sample - offsets.fetch(index % channels) }
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
# @return [Float] zero-crossing rate per channel stream
|
|
970
|
+
def zero_crossing_rate
|
|
971
|
+
float_buffer = @buffer.convert(float_work_format(@buffer.format))
|
|
972
|
+
channels = float_buffer.format.channels
|
|
973
|
+
return 0.0 if float_buffer.sample_frame_count <= 1
|
|
974
|
+
|
|
975
|
+
crossings = 0
|
|
976
|
+
comparisons = 0
|
|
977
|
+
channels.times do |channel|
|
|
978
|
+
previous = nil
|
|
979
|
+
float_buffer.samples.each_slice(channels) do |frame|
|
|
980
|
+
current = frame.fetch(channel)
|
|
981
|
+
if previous
|
|
982
|
+
crossings += 1 if (previous.negative? && current >= 0.0) || (previous >= 0.0 && current.negative?)
|
|
983
|
+
comparisons += 1
|
|
984
|
+
end
|
|
985
|
+
previous = current
|
|
986
|
+
end
|
|
987
|
+
end
|
|
988
|
+
return 0.0 if comparisons.zero?
|
|
989
|
+
|
|
990
|
+
crossings.to_f / comparisons
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
# @return [String]
|
|
994
|
+
def inspect
|
|
995
|
+
"#<#{self.class.name} #{human_sample_rate} #{human_channels} #{format.bit_depth}-bit #{format.sample_format} #{Kernel.format('%.3fs', duration.total_seconds)}>"
|
|
996
|
+
end
|
|
997
|
+
|
|
396
998
|
private
|
|
397
999
|
|
|
398
|
-
def
|
|
399
|
-
|
|
1000
|
+
def accumulate_statistics(samples, channels, consecutive_frames:)
|
|
1001
|
+
peak = 0.0
|
|
1002
|
+
square_sum = 0.0
|
|
1003
|
+
channel_sums = Array.new(channels, 0.0)
|
|
1004
|
+
previous = Array.new(channels)
|
|
1005
|
+
crossings = 0
|
|
1006
|
+
comparisons = 0
|
|
1007
|
+
clipped_states = Array.new(channels) { { polarity: nil, count: 0 } }
|
|
1008
|
+
clipped = false
|
|
1009
|
+
|
|
1010
|
+
samples.each_with_index do |sample, index|
|
|
1011
|
+
channel = index % channels
|
|
1012
|
+
absolute = sample.abs
|
|
1013
|
+
peak = absolute if absolute > peak
|
|
1014
|
+
square_sum += sample * sample
|
|
1015
|
+
channel_sums[channel] += sample
|
|
1016
|
+
prior = previous[channel]
|
|
1017
|
+
if prior
|
|
1018
|
+
crossings += 1 if (prior.negative? && sample >= 0.0) || (prior >= 0.0 && sample.negative?)
|
|
1019
|
+
comparisons += 1
|
|
1020
|
+
end
|
|
1021
|
+
previous[channel] = sample
|
|
1022
|
+
|
|
1023
|
+
state = clipped_states.fetch(channel)
|
|
1024
|
+
polarity = sample <= -1.0 ? -1 : (1 if sample >= 1.0)
|
|
1025
|
+
if polarity
|
|
1026
|
+
state[:count] = state[:polarity] == polarity ? state[:count] + 1 : 1
|
|
1027
|
+
state[:polarity] = polarity
|
|
1028
|
+
clipped ||= state[:count] >= consecutive_frames
|
|
1029
|
+
else
|
|
1030
|
+
state[:polarity] = nil
|
|
1031
|
+
state[:count] = 0
|
|
1032
|
+
end
|
|
1033
|
+
end
|
|
1034
|
+
frames = samples.length / channels
|
|
1035
|
+
{
|
|
1036
|
+
peak: peak,
|
|
1037
|
+
rms: samples.empty? ? 0.0 : Math.sqrt(square_sum / samples.length),
|
|
1038
|
+
dc_offsets: frames.zero? ? Array.new(channels, 0.0) : channel_sums.map { |sum| sum / frames.to_f },
|
|
1039
|
+
zero_crossing_rate: comparisons.zero? ? 0.0 : crossings.to_f / comparisons,
|
|
1040
|
+
clipped: clipped
|
|
1041
|
+
}
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
def clipped_samples?(samples, channels, consecutive_frames:)
|
|
1045
|
+
clipped_channels = Array.new(channels) { { polarity: nil, count: 0 } }
|
|
1046
|
+
|
|
1047
|
+
samples.each_slice(channels) do |frame|
|
|
1048
|
+
frame.each_with_index do |sample, channel|
|
|
1049
|
+
state = clipped_channels.fetch(channel)
|
|
1050
|
+
polarity = sample <= -1.0 ? -1 : (1 if sample >= 1.0)
|
|
1051
|
+
unless polarity
|
|
1052
|
+
state[:polarity] = nil
|
|
1053
|
+
state[:count] = 0
|
|
1054
|
+
next
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
state[:count] = state[:polarity] == polarity ? state[:count] + 1 : 1
|
|
1058
|
+
state[:polarity] = polarity
|
|
1059
|
+
return true if state[:count] >= consecutive_frames
|
|
1060
|
+
end
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
false
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
def self.normalize_codec_options!(codec_options)
|
|
1067
|
+
return {} if codec_options.nil?
|
|
1068
|
+
raise InvalidParameterError, "codec_options must be a Hash" unless codec_options.is_a?(Hash)
|
|
1069
|
+
|
|
1070
|
+
invalid_keys = codec_options.keys.reject { |key| key.is_a?(Symbol) }
|
|
1071
|
+
unless invalid_keys.empty?
|
|
1072
|
+
raise InvalidParameterError, "codec_options keys must be Symbols: #{invalid_keys.map(&:inspect).join(', ')}"
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
codec_options.dup
|
|
1076
|
+
end
|
|
1077
|
+
private_class_method :normalize_codec_options!
|
|
1078
|
+
|
|
1079
|
+
def normalize_codec_options!(codec_options)
|
|
1080
|
+
self.class.send(:normalize_codec_options!, codec_options)
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
def validate_overwrite!(path, overwrite)
|
|
1084
|
+
raise InvalidParameterError, "overwrite must be true or false" unless overwrite == true || overwrite == false
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
def with_output_target(path, overwrite:)
|
|
1088
|
+
return yield(path) unless path.is_a?(String)
|
|
1089
|
+
|
|
1090
|
+
expanded_path = File.expand_path(path)
|
|
1091
|
+
temporary = Tempfile.new([".wavify-", File.extname(path)], File.dirname(expanded_path), binmode: true)
|
|
1092
|
+
yield temporary
|
|
1093
|
+
temporary.flush
|
|
1094
|
+
temporary.fsync
|
|
1095
|
+
temporary.close
|
|
1096
|
+
if overwrite
|
|
1097
|
+
File.rename(temporary.path, expanded_path)
|
|
1098
|
+
else
|
|
1099
|
+
File.link(temporary.path, expanded_path)
|
|
1100
|
+
File.unlink(temporary.path)
|
|
1101
|
+
end
|
|
1102
|
+
rescue Errno::EEXIST
|
|
1103
|
+
raise InvalidParameterError, "output file already exists: #{path}"
|
|
1104
|
+
ensure
|
|
1105
|
+
temporary&.close!
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
def validate_audio!(audio, name)
|
|
1109
|
+
raise InvalidParameterError, "#{name} must be Audio" unless audio.is_a?(self.class)
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
def coerce_seconds(value)
|
|
1113
|
+
seconds = case value
|
|
1114
|
+
when Core::Duration
|
|
1115
|
+
value.total_seconds
|
|
1116
|
+
when Numeric
|
|
1117
|
+
value.to_f
|
|
1118
|
+
else
|
|
1119
|
+
raise InvalidParameterError, "time value must be Numeric or Core::Duration"
|
|
1120
|
+
end
|
|
1121
|
+
unless seconds.respond_to?(:finite?) && seconds.finite? && seconds >= 0.0
|
|
1122
|
+
raise InvalidParameterError, "time value must be a non-negative finite Numeric"
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
seconds
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
def coerce_time_to_frame(value, upper_bound:)
|
|
1129
|
+
frame = (coerce_seconds(value) * @buffer.format.sample_rate).round
|
|
1130
|
+
if upper_bound && frame > upper_bound
|
|
1131
|
+
raise InvalidParameterError, "time value is out of range: #{frame}"
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
frame
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1137
|
+
def coerce_duration_to_frame(value)
|
|
1138
|
+
(coerce_seconds(value) * @buffer.format.sample_rate).round
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
def amplitude_to_dbfs(amplitude)
|
|
1142
|
+
return -Float::INFINITY if amplitude <= 0.0
|
|
1143
|
+
|
|
1144
|
+
20.0 * Math.log10(amplitude)
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
def normalize_reference_amplitude(samples, mode)
|
|
1148
|
+
case mode
|
|
1149
|
+
when :peak
|
|
1150
|
+
samples.map(&:abs).max || 0.0
|
|
1151
|
+
when :rms
|
|
1152
|
+
return 0.0 if samples.empty?
|
|
1153
|
+
|
|
1154
|
+
Math.sqrt(samples.sum { |sample| sample * sample } / samples.length)
|
|
1155
|
+
end
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
def integrated_loudness(samples, format)
|
|
1159
|
+
DSP::LoudnessMeter.integrated(
|
|
1160
|
+
samples,
|
|
1161
|
+
format: format
|
|
1162
|
+
)
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
def human_sample_rate
|
|
1166
|
+
if sample_rate >= 1000
|
|
1167
|
+
"#{(sample_rate / 1000.0).round(1)}kHz"
|
|
1168
|
+
else
|
|
1169
|
+
"#{sample_rate}Hz"
|
|
1170
|
+
end
|
|
1171
|
+
end
|
|
1172
|
+
|
|
1173
|
+
def human_channels
|
|
1174
|
+
case channels
|
|
1175
|
+
when 1 then "mono"
|
|
1176
|
+
when 2 then "stereo"
|
|
1177
|
+
else "#{channels}ch"
|
|
1178
|
+
end
|
|
1179
|
+
end
|
|
1180
|
+
|
|
1181
|
+
def apply_fade(seconds:, mode:, curve:)
|
|
1182
|
+
fade_seconds = coerce_seconds(seconds)
|
|
1183
|
+
fade_mode = self.class.send(:normalize_fade_mode!, mode)
|
|
1184
|
+
fade_curve = self.class.send(:normalize_fade_curve!, curve)
|
|
400
1185
|
|
|
401
1186
|
transform_samples do |samples, format|
|
|
402
1187
|
channels = format.channels
|
|
403
1188
|
sample_frames = samples.length / channels
|
|
404
|
-
fade_frames = [(
|
|
405
|
-
|
|
1189
|
+
fade_frames = [(fade_seconds * format.sample_rate).round, sample_frames].min
|
|
1190
|
+
next samples if fade_frames.zero?
|
|
406
1191
|
|
|
407
|
-
result = samples.dup
|
|
408
1192
|
start_frame = sample_frames - fade_frames
|
|
409
1193
|
|
|
410
|
-
|
|
411
|
-
factor =
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
factor = factor.clamp(0.0, 1.0)
|
|
1194
|
+
samples.each_slice(channels).with_index do |frame, frame_index|
|
|
1195
|
+
factor = fade_factor_for(
|
|
1196
|
+
frame_index,
|
|
1197
|
+
fade_frames: fade_frames,
|
|
1198
|
+
sample_frames: sample_frames,
|
|
1199
|
+
start_frame: start_frame,
|
|
1200
|
+
mode: fade_mode,
|
|
1201
|
+
curve: fade_curve
|
|
1202
|
+
)
|
|
421
1203
|
base = frame_index * channels
|
|
422
1204
|
frame.each_index do |channel_index|
|
|
423
|
-
|
|
1205
|
+
samples[base + channel_index] = frame[channel_index] * factor
|
|
424
1206
|
end
|
|
425
1207
|
end
|
|
426
1208
|
|
|
427
|
-
|
|
1209
|
+
samples
|
|
1210
|
+
end
|
|
1211
|
+
end
|
|
1212
|
+
|
|
1213
|
+
def fade_factor_for(frame_index, fade_frames:, sample_frames:, start_frame:, mode:, curve:)
|
|
1214
|
+
linear_factor = case mode
|
|
1215
|
+
when :in
|
|
1216
|
+
frame_index < fade_frames ? fade_endpoint_ratio(frame_index, fade_frames, single_frame: 1.0) : 1.0
|
|
1217
|
+
when :out
|
|
1218
|
+
if frame_index >= start_frame
|
|
1219
|
+
fade_endpoint_ratio(sample_frames - frame_index - 1, fade_frames, single_frame: 0.0)
|
|
1220
|
+
else
|
|
1221
|
+
1.0
|
|
1222
|
+
end
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1225
|
+
fade_curve_factor(linear_factor.clamp(0.0, 1.0), curve)
|
|
1226
|
+
end
|
|
1227
|
+
|
|
1228
|
+
def fade_curve_factor(value, curve)
|
|
1229
|
+
case curve
|
|
1230
|
+
when :linear
|
|
1231
|
+
value
|
|
1232
|
+
when :exp
|
|
1233
|
+
value * value
|
|
1234
|
+
when :log
|
|
1235
|
+
Math.log10(1.0 + (9.0 * value))
|
|
428
1236
|
end
|
|
429
1237
|
end
|
|
430
1238
|
|
|
1239
|
+
def fade_endpoint_ratio(numerator, fade_frames, single_frame:)
|
|
1240
|
+
return single_frame if fade_frames == 1
|
|
1241
|
+
|
|
1242
|
+
numerator.to_f / (fade_frames - 1)
|
|
1243
|
+
end
|
|
1244
|
+
|
|
431
1245
|
def transform_samples(target_format: @buffer.format)
|
|
432
1246
|
raise InvalidParameterError, "target_format must be Core::Format" unless target_format.is_a?(Core::Format)
|
|
433
1247
|
|
|
434
1248
|
work_format = float_work_format(target_format)
|
|
435
|
-
|
|
436
|
-
transformed_samples = yield(
|
|
437
|
-
|
|
1249
|
+
workspace = Core::SampleBuffer::MutableFloatWorkspace.from_buffer(@buffer, format: work_format)
|
|
1250
|
+
transformed_samples = yield(workspace.samples, work_format)
|
|
1251
|
+
workspace.replace!(transformed_samples) unless transformed_samples.equal?(workspace.samples)
|
|
1252
|
+
processed = workspace.to_sample_buffer.convert(target_format)
|
|
438
1253
|
self.class.new(processed)
|
|
439
1254
|
end
|
|
440
1255
|
|
|
@@ -448,7 +1263,25 @@ module Wavify
|
|
|
448
1263
|
end
|
|
449
1264
|
|
|
450
1265
|
def validate_pan_position!(position)
|
|
451
|
-
|
|
1266
|
+
unless position.is_a?(Numeric) && position.respond_to?(:finite?) && position.finite? && position.between?(-1.0, 1.0)
|
|
1267
|
+
raise InvalidParameterError, "position must be a finite Numeric in -1.0..1.0"
|
|
1268
|
+
end
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
def validate_finite_numeric!(value, name)
|
|
1272
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite?
|
|
1273
|
+
raise InvalidParameterError, "#{name} must be a finite Numeric"
|
|
1274
|
+
end
|
|
1275
|
+
|
|
1276
|
+
value.to_f
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
def validate_mapped_sample!(value, location)
|
|
1280
|
+
unless value.is_a?(Numeric) && value.real? && value.respond_to?(:finite?) && value.finite?
|
|
1281
|
+
raise InvalidParameterError, "mapped #{location} must be a finite real Numeric"
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1284
|
+
value.to_f
|
|
452
1285
|
end
|
|
453
1286
|
|
|
454
1287
|
def replace_buffer!(new_buffer)
|
|
@@ -479,5 +1312,118 @@ module Wavify
|
|
|
479
1312
|
value
|
|
480
1313
|
end
|
|
481
1314
|
private_class_method :clip_value
|
|
1315
|
+
|
|
1316
|
+
def self.normalize_mix_strategy!(strategy)
|
|
1317
|
+
normalized = strategy.to_sym if strategy.respond_to?(:to_sym)
|
|
1318
|
+
return normalized if MIX_STRATEGIES.include?(normalized)
|
|
1319
|
+
|
|
1320
|
+
raise InvalidParameterError, "strategy must be one of: #{MIX_STRATEGIES.join(', ')}"
|
|
1321
|
+
end
|
|
1322
|
+
private_class_method :normalize_mix_strategy!
|
|
1323
|
+
|
|
1324
|
+
def self.normalize_mix_alignment!(align)
|
|
1325
|
+
normalized = align.to_sym if align.respond_to?(:to_sym)
|
|
1326
|
+
return normalized if MIX_ALIGNMENTS.include?(normalized)
|
|
1327
|
+
|
|
1328
|
+
raise InvalidParameterError, "align must be one of: #{MIX_ALIGNMENTS.join(', ')}"
|
|
1329
|
+
end
|
|
1330
|
+
private_class_method :normalize_mix_alignment!
|
|
1331
|
+
|
|
1332
|
+
def self.normalize_mix_gains!(gains, source_count)
|
|
1333
|
+
return Array.new(source_count, 0.0) if gains.nil?
|
|
1334
|
+
raise InvalidParameterError, "gains must be an Array" unless gains.is_a?(Array)
|
|
1335
|
+
raise InvalidParameterError, "gains must have one value per Audio" unless gains.length == source_count
|
|
1336
|
+
|
|
1337
|
+
gains.map do |gain|
|
|
1338
|
+
unless gain.is_a?(Numeric) && gain.respond_to?(:finite?) && gain.finite?
|
|
1339
|
+
raise InvalidParameterError, "gains must contain finite Numeric dB values"
|
|
1340
|
+
end
|
|
1341
|
+
|
|
1342
|
+
gain.to_f
|
|
1343
|
+
end
|
|
1344
|
+
end
|
|
1345
|
+
private_class_method :normalize_mix_gains!
|
|
1346
|
+
|
|
1347
|
+
def self.mix_alignment_offset(align, max_frames, frame_count)
|
|
1348
|
+
case align
|
|
1349
|
+
when :start
|
|
1350
|
+
0
|
|
1351
|
+
when :center
|
|
1352
|
+
((max_frames - frame_count) / 2.0).round
|
|
1353
|
+
when :end
|
|
1354
|
+
max_frames - frame_count
|
|
1355
|
+
end
|
|
1356
|
+
end
|
|
1357
|
+
private_class_method :mix_alignment_offset
|
|
1358
|
+
|
|
1359
|
+
def self.db_to_amplitude(db)
|
|
1360
|
+
10.0**(db / 20.0)
|
|
1361
|
+
end
|
|
1362
|
+
private_class_method :db_to_amplitude
|
|
1363
|
+
|
|
1364
|
+
def self.normalize_mode!(mode)
|
|
1365
|
+
normalized = mode.to_sym if mode.respond_to?(:to_sym)
|
|
1366
|
+
return normalized if NORMALIZE_MODES.include?(normalized)
|
|
1367
|
+
|
|
1368
|
+
raise InvalidParameterError, "mode must be one of: #{NORMALIZE_MODES.join(', ')}"
|
|
1369
|
+
end
|
|
1370
|
+
private_class_method :normalize_mode!
|
|
1371
|
+
|
|
1372
|
+
def self.normalize_fade_mode!(mode)
|
|
1373
|
+
normalized = mode.to_sym if mode.respond_to?(:to_sym)
|
|
1374
|
+
return normalized if %i[in out].include?(normalized)
|
|
1375
|
+
|
|
1376
|
+
raise InvalidParameterError, "fade type must be :in or :out"
|
|
1377
|
+
end
|
|
1378
|
+
private_class_method :normalize_fade_mode!
|
|
1379
|
+
|
|
1380
|
+
def self.normalize_fade_curve!(curve)
|
|
1381
|
+
normalized = curve.to_sym if curve.respond_to?(:to_sym)
|
|
1382
|
+
return normalized if FADE_CURVES.include?(normalized)
|
|
1383
|
+
|
|
1384
|
+
raise InvalidParameterError, "fade curve must be one of: #{FADE_CURVES.join(', ')}"
|
|
1385
|
+
end
|
|
1386
|
+
private_class_method :normalize_fade_curve!
|
|
1387
|
+
|
|
1388
|
+
def self.apply_mix_strategy!(samples, strategy, format: nil, headroom_smoothing: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS)
|
|
1389
|
+
case strategy
|
|
1390
|
+
when :none
|
|
1391
|
+
samples
|
|
1392
|
+
when :clip
|
|
1393
|
+
samples.map! { |sample| clip_value(sample, -1.0, 1.0) }
|
|
1394
|
+
when :normalize
|
|
1395
|
+
normalize_mix_samples!(samples)
|
|
1396
|
+
when :headroom
|
|
1397
|
+
DSP::Headroom.apply!(
|
|
1398
|
+
samples,
|
|
1399
|
+
channels: format&.channels || 1,
|
|
1400
|
+
sample_rate: format&.sample_rate || 1,
|
|
1401
|
+
smoothing_seconds: headroom_smoothing
|
|
1402
|
+
)
|
|
1403
|
+
when :soft_limit
|
|
1404
|
+
samples.map! { |sample| soft_limit_value(sample) }
|
|
1405
|
+
end
|
|
1406
|
+
end
|
|
1407
|
+
private_class_method :apply_mix_strategy!
|
|
1408
|
+
|
|
1409
|
+
def self.normalize_mix_samples!(samples)
|
|
1410
|
+
peak = samples.map(&:abs).max || 0.0
|
|
1411
|
+
return samples if peak <= 1.0
|
|
1412
|
+
|
|
1413
|
+
samples.map! { |sample| sample / peak }
|
|
1414
|
+
end
|
|
1415
|
+
private_class_method :normalize_mix_samples!
|
|
1416
|
+
|
|
1417
|
+
def self.soft_limit_value(sample)
|
|
1418
|
+
value = sample.to_f
|
|
1419
|
+
magnitude = value.abs
|
|
1420
|
+
return value if magnitude <= SOFT_LIMIT_THRESHOLD
|
|
1421
|
+
|
|
1422
|
+
range = 1.0 - SOFT_LIMIT_THRESHOLD
|
|
1423
|
+
sign = value.negative? ? -1.0 : 1.0
|
|
1424
|
+
limited = SOFT_LIMIT_THRESHOLD + (range * (1.0 - Math.exp(-(magnitude - SOFT_LIMIT_THRESHOLD) / range)))
|
|
1425
|
+
sign * clip_value(limited, 0.0, 1.0)
|
|
1426
|
+
end
|
|
1427
|
+
private_class_method :soft_limit_value
|
|
482
1428
|
end
|
|
483
1429
|
end
|