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/core/stream.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
3
5
|
module Wavify
|
|
4
6
|
module Core
|
|
5
7
|
# Lazy streaming pipeline for chunk-based audio processing.
|
|
@@ -8,7 +10,7 @@ module Wavify
|
|
|
8
10
|
class Stream
|
|
9
11
|
include Enumerable
|
|
10
12
|
|
|
11
|
-
attr_reader :
|
|
13
|
+
attr_reader :chunk_size
|
|
12
14
|
|
|
13
15
|
# @param source [String, IO] input path or IO
|
|
14
16
|
# @param codec [Class] codec class implementing `stream_read/stream_write`
|
|
@@ -22,24 +24,203 @@ module Wavify
|
|
|
22
24
|
@chunk_size = validate_chunk_size!(chunk_size)
|
|
23
25
|
@codec_read_options = validate_codec_read_options!(codec_read_options)
|
|
24
26
|
@pipeline = []
|
|
27
|
+
@pipeline_names = []
|
|
28
|
+
@tee_targets = []
|
|
29
|
+
@take_duration_seconds = nil
|
|
30
|
+
@drop_duration_seconds = nil
|
|
31
|
+
@enumerated = false
|
|
32
|
+
@source_start_position = source_position(source)
|
|
33
|
+
@stage_input_formats = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns a known source format. Access before enumeration may perform a
|
|
37
|
+
# metadata probe; normal decoding learns the format from the first chunk.
|
|
38
|
+
def format
|
|
39
|
+
return @format if @format
|
|
40
|
+
|
|
41
|
+
position = source_position(@source)
|
|
42
|
+
metadata = @codec.metadata(@source, **metadata_probe_options)
|
|
43
|
+
@format = metadata.fetch(:format)
|
|
44
|
+
ensure
|
|
45
|
+
if position && @source.respond_to?(:seek)
|
|
46
|
+
@source.seek(position, IO::SEEK_SET)
|
|
47
|
+
end
|
|
25
48
|
end
|
|
26
49
|
|
|
27
50
|
# Adds a processor to the stream pipeline.
|
|
28
51
|
#
|
|
29
|
-
# Processors may respond to `#
|
|
52
|
+
# Processors may respond to `#process`, `#call`, or `#apply`.
|
|
53
|
+
# Stateful processors may also expose `#reset` and `#flush`.
|
|
30
54
|
#
|
|
31
55
|
# @param processor [#call, #process, #apply, nil]
|
|
56
|
+
# @param name [String, Symbol, nil] optional display name for inspection
|
|
32
57
|
# @return [Stream] self
|
|
33
|
-
def pipe(processor = nil, &block)
|
|
58
|
+
def pipe(processor = nil, name: nil, &block)
|
|
59
|
+
if processor && block
|
|
60
|
+
raise InvalidParameterError, "pipe accepts either a processor or a block, not both"
|
|
61
|
+
end
|
|
62
|
+
|
|
34
63
|
candidate = processor || block
|
|
35
64
|
unless candidate.respond_to?(:call) || candidate.respond_to?(:process) || candidate.respond_to?(:apply)
|
|
36
65
|
raise InvalidParameterError, "processor must respond to :call, :process, or :apply"
|
|
37
66
|
end
|
|
38
67
|
|
|
39
68
|
@pipeline << candidate
|
|
69
|
+
@pipeline_names << validate_pipeline_name!(name)
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Adds a block processor for chunk-to-chunk transforms.
|
|
74
|
+
#
|
|
75
|
+
# @param name [String, Symbol, nil] optional display name for inspection
|
|
76
|
+
# @return [Stream] self
|
|
77
|
+
def map_chunks(name: nil, &block)
|
|
78
|
+
raise InvalidParameterError, "map_chunks requires a block" unless block
|
|
79
|
+
|
|
80
|
+
pipe(block, name: name || :map_chunks)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Limits the source stream to the first duration after any dropped prefix.
|
|
84
|
+
#
|
|
85
|
+
# @param duration [Numeric, Duration] seconds or duration object
|
|
86
|
+
# @return [Stream] self
|
|
87
|
+
def take_duration(duration)
|
|
88
|
+
@take_duration_seconds = coerce_duration_seconds!(duration, "duration")
|
|
40
89
|
self
|
|
41
90
|
end
|
|
42
91
|
|
|
92
|
+
# Drops an initial duration from the source stream.
|
|
93
|
+
#
|
|
94
|
+
# @param duration [Numeric, Duration] seconds or duration object
|
|
95
|
+
# @return [Stream] self
|
|
96
|
+
def drop_duration(duration)
|
|
97
|
+
@drop_duration_seconds = coerce_duration_seconds!(duration, "duration")
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Installs a passive per-chunk level meter.
|
|
102
|
+
#
|
|
103
|
+
# @yield [stats]
|
|
104
|
+
# @yieldparam stats [Hash]
|
|
105
|
+
# @return [Stream] self
|
|
106
|
+
def meter(&block)
|
|
107
|
+
raise InvalidParameterError, "meter requires a block" unless block
|
|
108
|
+
|
|
109
|
+
pipe(MeterProcessor.new(block), name: :meter)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Installs a passive progress callback based on processed output chunks.
|
|
113
|
+
#
|
|
114
|
+
# @param total_frames [Integer, nil] optional expected output frame count
|
|
115
|
+
# @yield [stats]
|
|
116
|
+
# @yieldparam stats [Hash]
|
|
117
|
+
# @return [Stream] self
|
|
118
|
+
def progress(total_frames: nil, &block)
|
|
119
|
+
raise InvalidParameterError, "progress requires a block" unless block
|
|
120
|
+
if total_frames && (!total_frames.is_a?(Integer) || total_frames.negative?)
|
|
121
|
+
raise InvalidParameterError, "total_frames must be a non-negative Integer"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
pipe(ProgressProcessor.new(block, total_frames: total_frames), name: :progress)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Writes processed chunks to an additional output while the stream is consumed.
|
|
128
|
+
#
|
|
129
|
+
# @param path_or_io [String, IO]
|
|
130
|
+
# @param format [Format, nil] optional output format
|
|
131
|
+
# @param codec_options [Hash, nil] codec-specific options forwarded to `stream_write`
|
|
132
|
+
# @return [Stream] self
|
|
133
|
+
def tee(path_or_io, format: nil, codec: nil, filename: nil, codec_options: nil)
|
|
134
|
+
output_codec = detect_output_codec(path_or_io, codec: codec, filename: filename)
|
|
135
|
+
target_format = resolve_target_format(format, output_codec)
|
|
136
|
+
raise InvalidFormatError, "format is required when teeing stream output" unless target_format.is_a?(Format)
|
|
137
|
+
|
|
138
|
+
@tee_targets << {
|
|
139
|
+
target: path_or_io,
|
|
140
|
+
codec: output_codec,
|
|
141
|
+
format: target_format,
|
|
142
|
+
codec_options: validate_codec_options!(codec_options, "codec_options")
|
|
143
|
+
}
|
|
144
|
+
self
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Materializes the processed stream into an Audio object.
|
|
148
|
+
#
|
|
149
|
+
# @return [Audio]
|
|
150
|
+
def to_audio
|
|
151
|
+
output_format = nil
|
|
152
|
+
samples = []
|
|
153
|
+
each_chunk do |chunk|
|
|
154
|
+
output_format ||= chunk.format
|
|
155
|
+
converted = chunk.format == output_format ? chunk : chunk.convert(output_format)
|
|
156
|
+
samples.concat(converted.samples)
|
|
157
|
+
end
|
|
158
|
+
output_format ||= @last_output_format || @format
|
|
159
|
+
raise InvalidFormatError, "stream format is unknown" unless output_format.is_a?(Format)
|
|
160
|
+
|
|
161
|
+
Audio.new(SampleBuffer.new(samples, output_format))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @return [Array<Object>] registered processors in execution order
|
|
165
|
+
def pipeline
|
|
166
|
+
@pipeline.dup
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @return [Array<Hash>] processor names and objects in execution order
|
|
170
|
+
def pipeline_steps
|
|
171
|
+
@pipeline.map.with_index do |processor, index|
|
|
172
|
+
{
|
|
173
|
+
name: @pipeline_names.fetch(index),
|
|
174
|
+
processor: processor,
|
|
175
|
+
latency: processor_duration(processor, :latency),
|
|
176
|
+
lookahead: processor_duration(processor, :lookahead),
|
|
177
|
+
tail_duration: processor_duration(processor, :tail_duration)
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @return [Float] summed processor latency in seconds
|
|
183
|
+
def latency
|
|
184
|
+
@pipeline.sum { |processor| processor_duration(processor, :latency) }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @return [Float] summed processor lookahead in seconds
|
|
188
|
+
def lookahead
|
|
189
|
+
@pipeline.sum { |processor| processor_duration(processor, :lookahead) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Reads and processes the stream without writing output.
|
|
193
|
+
# User processors and callbacks are still executed and may have side effects.
|
|
194
|
+
#
|
|
195
|
+
# @param format [Format, nil] optional output conversion to validate
|
|
196
|
+
# @return [Hash]
|
|
197
|
+
def dry_run(format: nil)
|
|
198
|
+
raise InvalidFormatError, "format must be Core::Format" if format && !format.is_a?(Format)
|
|
199
|
+
|
|
200
|
+
tee_targets = @tee_targets
|
|
201
|
+
@tee_targets = []
|
|
202
|
+
stats = {
|
|
203
|
+
chunks: 0,
|
|
204
|
+
sample_frame_count: 0,
|
|
205
|
+
format: format,
|
|
206
|
+
pipeline: pipeline_steps,
|
|
207
|
+
latency: latency,
|
|
208
|
+
lookahead: lookahead,
|
|
209
|
+
tail_duration: pipeline_tail_duration
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
each_chunk do |chunk|
|
|
213
|
+
output_chunk = format ? chunk.convert(format) : chunk
|
|
214
|
+
stats[:chunks] += 1
|
|
215
|
+
stats[:sample_frame_count] += output_chunk.sample_frame_count
|
|
216
|
+
stats[:format] ||= output_chunk.format
|
|
217
|
+
end
|
|
218
|
+
stats[:duration] = stats[:format] ? Duration.from_samples(stats[:sample_frame_count], stats[:format].sample_rate) : nil
|
|
219
|
+
stats
|
|
220
|
+
ensure
|
|
221
|
+
@tee_targets = tee_targets if defined?(tee_targets)
|
|
222
|
+
end
|
|
223
|
+
|
|
43
224
|
# Iterates processed chunks.
|
|
44
225
|
#
|
|
45
226
|
# @yield [chunk]
|
|
@@ -48,10 +229,63 @@ module Wavify
|
|
|
48
229
|
def each_chunk
|
|
49
230
|
return enum_for(:each_chunk) unless block_given?
|
|
50
231
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
232
|
+
prepare_source_for_enumeration!
|
|
233
|
+
reset_pipeline!
|
|
234
|
+
@last_output_format = nil
|
|
235
|
+
drop_frames = nil
|
|
236
|
+
take_frames = nil
|
|
237
|
+
input_taken_frames = 0
|
|
238
|
+
output_drop_frames = nil
|
|
239
|
+
output_take_frames = nil
|
|
240
|
+
output_taken_frames = 0
|
|
241
|
+
@stage_input_formats = []
|
|
242
|
+
|
|
243
|
+
with_tee_writers do |tee_writers|
|
|
244
|
+
with_stream_context("stream read", codec: @codec, target: @source) do
|
|
245
|
+
take_complete = Object.new
|
|
246
|
+
catch(take_complete) do
|
|
247
|
+
@codec.stream_read(@source, chunk_size: @chunk_size, **@codec_read_options) do |chunk|
|
|
248
|
+
with_stream_context("stream processing", codec: @codec, target: @source) do
|
|
249
|
+
@format ||= chunk.format
|
|
250
|
+
drop_frames ||= duration_frames(@drop_duration_seconds, chunk.format) || 0
|
|
251
|
+
take_frames = duration_frames(@take_duration_seconds, chunk.format) if take_frames.nil? && @take_duration_seconds
|
|
252
|
+
input_chunk, drop_frames, input_taken_frames = apply_duration_window(
|
|
253
|
+
chunk,
|
|
254
|
+
drop_frames: drop_frames,
|
|
255
|
+
take_frames: take_frames,
|
|
256
|
+
taken_frames: input_taken_frames
|
|
257
|
+
)
|
|
258
|
+
throw(take_complete) if take_frames && input_taken_frames >= take_frames && input_chunk.nil?
|
|
259
|
+
next unless input_chunk
|
|
260
|
+
|
|
261
|
+
output_chunk = apply_pipeline(input_chunk)
|
|
262
|
+
output_drop_frames, output_take_frames, output_taken_frames = emit_output_chunk(
|
|
263
|
+
output_chunk,
|
|
264
|
+
tee_writers: tee_writers,
|
|
265
|
+
drop_frames: output_drop_frames,
|
|
266
|
+
take_frames: output_take_frames,
|
|
267
|
+
taken_frames: output_taken_frames
|
|
268
|
+
) { |windowed| yield windowed }
|
|
269
|
+
throw(take_complete) if take_frames && input_taken_frames >= take_frames
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
with_stream_context("stream flush", codec: @codec, target: @source) do
|
|
276
|
+
flush_pipeline do |chunk|
|
|
277
|
+
output_drop_frames, output_take_frames, output_taken_frames = emit_output_chunk(
|
|
278
|
+
chunk,
|
|
279
|
+
tee_writers: tee_writers,
|
|
280
|
+
drop_frames: output_drop_frames,
|
|
281
|
+
take_frames: output_take_frames,
|
|
282
|
+
taken_frames: output_taken_frames
|
|
283
|
+
) { |windowed| yield windowed }
|
|
284
|
+
end
|
|
285
|
+
end
|
|
54
286
|
end
|
|
287
|
+
rescue UserCodeError => e
|
|
288
|
+
raise e.original
|
|
55
289
|
end
|
|
56
290
|
|
|
57
291
|
alias each each_chunk
|
|
@@ -60,15 +294,27 @@ module Wavify
|
|
|
60
294
|
#
|
|
61
295
|
# @param path_or_io [String, IO]
|
|
62
296
|
# @param format [Format, nil] output format (required for raw output if unknown)
|
|
297
|
+
# @param codec [Symbol, Class, nil] explicit output codec for generic IO targets
|
|
298
|
+
# @param filename [String, nil] filename hint used for codec selection
|
|
299
|
+
# @param codec_options [Hash] codec-specific options forwarded to `stream_write`
|
|
300
|
+
# @param overwrite [Boolean] whether existing path output may be replaced
|
|
63
301
|
# @return [String, IO] the same target argument
|
|
64
|
-
def write_to(path_or_io, format: nil)
|
|
65
|
-
|
|
302
|
+
def write_to(path_or_io, format: nil, codec: nil, filename: nil, codec_options: nil, overwrite: true)
|
|
303
|
+
validate_overwrite!(path_or_io, overwrite)
|
|
304
|
+
output_codec = detect_output_codec(path_or_io, codec: codec, filename: filename)
|
|
66
305
|
target_format = resolve_target_format(format, output_codec)
|
|
306
|
+
options = validate_codec_options!(codec_options, "codec_options")
|
|
67
307
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
308
|
+
with_output_target(path_or_io, overwrite: overwrite) do |target|
|
|
309
|
+
with_stream_context("stream write", codec: output_codec, target: path_or_io) do
|
|
310
|
+
output_codec.stream_write(target, format: target_format, **options) do |writer|
|
|
311
|
+
each_chunk do |chunk|
|
|
312
|
+
output_chunk = target_format ? chunk.convert(target_format) : chunk
|
|
313
|
+
with_stream_context("stream write", codec: output_codec, target: path_or_io) do
|
|
314
|
+
writer.call(output_chunk)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
72
318
|
end
|
|
73
319
|
end
|
|
74
320
|
|
|
@@ -77,30 +323,225 @@ module Wavify
|
|
|
77
323
|
|
|
78
324
|
private
|
|
79
325
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
result
|
|
94
|
-
else
|
|
95
|
-
raise ProcessingError, "stream processor must return Core::SampleBuffer or Audio"
|
|
326
|
+
# Passive stream processor that reports per-chunk audio levels.
|
|
327
|
+
class MeterProcessor
|
|
328
|
+
def initialize(callback)
|
|
329
|
+
@callback = callback
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def process(chunk)
|
|
333
|
+
float_chunk = chunk.convert(chunk.format.with(sample_format: :float, bit_depth: 32))
|
|
334
|
+
peak = 0.0
|
|
335
|
+
square_sum = 0.0
|
|
336
|
+
float_chunk.samples.each do |sample|
|
|
337
|
+
peak = [peak, sample.abs].max
|
|
338
|
+
square_sum += sample * sample
|
|
96
339
|
end
|
|
340
|
+
rms = if float_chunk.samples.empty?
|
|
341
|
+
0.0
|
|
342
|
+
else
|
|
343
|
+
Math.sqrt(square_sum / float_chunk.samples.length)
|
|
344
|
+
end
|
|
345
|
+
@callback.call(
|
|
346
|
+
format: chunk.format,
|
|
347
|
+
sample_frame_count: chunk.sample_frame_count,
|
|
348
|
+
duration: chunk.duration,
|
|
349
|
+
peak_amplitude: peak,
|
|
350
|
+
rms_amplitude: rms,
|
|
351
|
+
peak_dbfs: amplitude_to_dbfs(peak),
|
|
352
|
+
rms_dbfs: amplitude_to_dbfs(rms)
|
|
353
|
+
)
|
|
354
|
+
chunk
|
|
355
|
+
rescue StandardError => e
|
|
356
|
+
raise UserCodeError, e
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
private
|
|
360
|
+
|
|
361
|
+
def amplitude_to_dbfs(amplitude)
|
|
362
|
+
return -Float::INFINITY if amplitude <= 0.0
|
|
363
|
+
|
|
364
|
+
20.0 * Math.log10(amplitude)
|
|
97
365
|
end
|
|
98
366
|
end
|
|
99
367
|
|
|
100
|
-
|
|
101
|
-
|
|
368
|
+
# Passive stream processor that reports cumulative processed frames.
|
|
369
|
+
class ProgressProcessor
|
|
370
|
+
def initialize(callback, total_frames:)
|
|
371
|
+
@callback = callback
|
|
372
|
+
@total_frames = total_frames
|
|
373
|
+
@processed_frames = 0
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def reset
|
|
377
|
+
@processed_frames = 0
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def process(chunk)
|
|
381
|
+
@processed_frames += chunk.sample_frame_count
|
|
382
|
+
stats = {
|
|
383
|
+
format: chunk.format,
|
|
384
|
+
sample_frame_count: @processed_frames,
|
|
385
|
+
duration: Duration.from_samples(@processed_frames, chunk.format.sample_rate)
|
|
386
|
+
}
|
|
387
|
+
stats[:total_frames] = @total_frames if @total_frames
|
|
388
|
+
stats[:progress] = [@processed_frames.to_f / @total_frames, 1.0].min if @total_frames&.positive?
|
|
389
|
+
@callback.call(stats)
|
|
390
|
+
chunk
|
|
391
|
+
rescue StandardError => e
|
|
392
|
+
raise UserCodeError, e
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
class UserCodeError < StandardError
|
|
397
|
+
attr_reader :original
|
|
398
|
+
|
|
399
|
+
def initialize(original)
|
|
400
|
+
@original = original
|
|
401
|
+
super(original.message)
|
|
402
|
+
set_backtrace(original.backtrace)
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def apply_pipeline(chunk, start_index: 0)
|
|
407
|
+
@pipeline.each_with_index.drop(start_index).reduce(chunk) do |current, (processor, index)|
|
|
408
|
+
@stage_input_formats[index] ||= current.format
|
|
409
|
+
coerce_processor_result(invoke_processor(processor, current), "stream processor")
|
|
410
|
+
end
|
|
411
|
+
end
|
|
102
412
|
|
|
103
|
-
|
|
413
|
+
def invoke_processor(processor, chunk)
|
|
414
|
+
DSP::Processor.process(processor, chunk)
|
|
415
|
+
rescue UserCodeError
|
|
416
|
+
raise
|
|
417
|
+
rescue StandardError => e
|
|
418
|
+
raise UserCodeError, e
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def reset_pipeline!
|
|
422
|
+
@pipeline.each { |processor| processor.reset if processor.respond_to?(:reset) }
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def flush_pipeline
|
|
426
|
+
@pipeline.each_with_index do |processor, index|
|
|
427
|
+
flush_processor(processor, index).each do |chunk|
|
|
428
|
+
yield apply_pipeline(chunk, start_index: index + 1)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def flush_processor(processor, index)
|
|
434
|
+
DSP::Processor.flush(processor, format: flush_format(index))
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def flush_format(index)
|
|
438
|
+
@stage_input_formats[index] || @format
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def processor_duration(processor, method_name)
|
|
442
|
+
DSP::Processor.duration(processor, method_name)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def pipeline_tail_duration
|
|
446
|
+
@pipeline.sum { |processor| processor_duration(processor, :tail_duration) }
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def emit_output_chunk(chunk, tee_writers:, drop_frames:, take_frames:, taken_frames:)
|
|
450
|
+
if @take_duration_seconds
|
|
451
|
+
drop_frames ||= duration_frames(latency, chunk.format) || 0
|
|
452
|
+
take_frames ||= duration_frames(@take_duration_seconds, chunk.format)
|
|
453
|
+
else
|
|
454
|
+
drop_frames ||= 0
|
|
455
|
+
end
|
|
456
|
+
windowed, drop_frames, taken_frames = apply_duration_window(
|
|
457
|
+
chunk,
|
|
458
|
+
drop_frames: drop_frames,
|
|
459
|
+
take_frames: take_frames,
|
|
460
|
+
taken_frames: taken_frames
|
|
461
|
+
)
|
|
462
|
+
if windowed
|
|
463
|
+
@last_output_format = windowed.format
|
|
464
|
+
write_tee_chunks(windowed, tee_writers)
|
|
465
|
+
yield windowed
|
|
466
|
+
end
|
|
467
|
+
[drop_frames, take_frames, taken_frames]
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def apply_duration_window(chunk, drop_frames:, take_frames:, taken_frames:)
|
|
471
|
+
start_frame = [drop_frames, chunk.sample_frame_count].min
|
|
472
|
+
drop_frames -= start_frame
|
|
473
|
+
available_frames = chunk.sample_frame_count - start_frame
|
|
474
|
+
return [nil, drop_frames, taken_frames] if available_frames.zero?
|
|
475
|
+
|
|
476
|
+
frame_length = available_frames
|
|
477
|
+
if take_frames
|
|
478
|
+
remaining_frames = take_frames - taken_frames
|
|
479
|
+
return [nil, drop_frames, taken_frames] if remaining_frames <= 0
|
|
480
|
+
|
|
481
|
+
frame_length = [frame_length, remaining_frames].min
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
windowed = chunk.slice(start_frame, frame_length)
|
|
485
|
+
taken_frames += windowed.sample_frame_count
|
|
486
|
+
[windowed, drop_frames, taken_frames]
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def duration_frames(duration_seconds, format)
|
|
490
|
+
return nil unless duration_seconds
|
|
491
|
+
|
|
492
|
+
(duration_seconds * format.sample_rate).round
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def with_tee_writers(targets = @tee_targets)
|
|
496
|
+
fibers = []
|
|
497
|
+
writers = []
|
|
498
|
+
targets.each do |target|
|
|
499
|
+
fiber = Fiber.new do
|
|
500
|
+
with_stream_context("stream tee", codec: target[:codec], target: target[:target]) do
|
|
501
|
+
target[:codec].stream_write(target[:target], format: target[:format], **target[:codec_options]) do |writer|
|
|
502
|
+
Fiber.yield(target.merge(writer: writer))
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
fibers << fiber
|
|
507
|
+
writers << fiber.resume
|
|
508
|
+
end
|
|
509
|
+
result = yield(writers)
|
|
510
|
+
fibers.reverse_each { |fiber| fiber.resume if fiber.alive? }
|
|
511
|
+
result
|
|
512
|
+
rescue StandardError => error
|
|
513
|
+
fibers&.reverse_each do |fiber|
|
|
514
|
+
next unless fiber.alive?
|
|
515
|
+
|
|
516
|
+
begin
|
|
517
|
+
fiber.raise(error)
|
|
518
|
+
rescue StandardError
|
|
519
|
+
nil
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
raise
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def write_tee_chunks(chunk, tee_writers)
|
|
526
|
+
tee_writers.each do |target|
|
|
527
|
+
output_chunk = chunk.format == target[:format] ? chunk : chunk.convert(target[:format])
|
|
528
|
+
target[:writer].call(output_chunk)
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def normalize_processor_results(result, context)
|
|
533
|
+
DSP::Processor.each_buffer(result, context)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def coerce_processor_result(result, context)
|
|
537
|
+
DSP::Processor.coerce_buffer(result, context)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def detect_output_codec(path_or_io, codec:, filename:)
|
|
541
|
+
return Codecs::Registry.resolve(codec) if codec
|
|
542
|
+
return Codecs::Registry.detect_for_write(path_or_io, filename: filename) if path_or_io.is_a?(String) || filename
|
|
543
|
+
|
|
544
|
+
@codec
|
|
104
545
|
end
|
|
105
546
|
|
|
106
547
|
def resolve_target_format(format, output_codec)
|
|
@@ -109,9 +550,18 @@ module Wavify
|
|
|
109
550
|
|
|
110
551
|
raise InvalidFormatError, "format is required when writing raw stream output" if output_codec == Codecs::Raw
|
|
111
552
|
|
|
553
|
+
return self.format if @codec.respond_to?(:metadata)
|
|
554
|
+
|
|
112
555
|
nil
|
|
113
556
|
end
|
|
114
557
|
|
|
558
|
+
|
|
559
|
+
def metadata_probe_options
|
|
560
|
+
return { format: @codec_read_options.fetch(:format) } if @codec == Codecs::Raw
|
|
561
|
+
|
|
562
|
+
{}
|
|
563
|
+
end
|
|
564
|
+
|
|
115
565
|
def validate_chunk_size!(chunk_size)
|
|
116
566
|
raise InvalidParameterError, "chunk_size must be a positive Integer" unless chunk_size.is_a?(Integer) && chunk_size.positive?
|
|
117
567
|
|
|
@@ -119,10 +569,116 @@ module Wavify
|
|
|
119
569
|
end
|
|
120
570
|
|
|
121
571
|
def validate_codec_read_options!(codec_read_options)
|
|
122
|
-
|
|
123
|
-
|
|
572
|
+
validate_codec_options!(codec_read_options, "codec_read_options")
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def validate_codec_options!(codec_options, name)
|
|
576
|
+
return {} if codec_options.nil?
|
|
577
|
+
raise InvalidParameterError, "#{name} must be a Hash" unless codec_options.is_a?(Hash)
|
|
578
|
+
|
|
579
|
+
invalid_keys = codec_options.keys.reject { |key| key.is_a?(Symbol) }
|
|
580
|
+
unless invalid_keys.empty?
|
|
581
|
+
raise InvalidParameterError, "#{name} keys must be Symbols: #{invalid_keys.map(&:inspect).join(', ')}"
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
codec_options.dup
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def validate_overwrite!(path_or_io, overwrite)
|
|
588
|
+
raise InvalidParameterError, "overwrite must be true or false" unless overwrite == true || overwrite == false
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def with_output_target(path_or_io, overwrite:)
|
|
592
|
+
return yield(path_or_io) unless path_or_io.is_a?(String)
|
|
593
|
+
|
|
594
|
+
expanded_path = File.expand_path(path_or_io)
|
|
595
|
+
temporary = Tempfile.new([".wavify-", File.extname(path_or_io)], File.dirname(expanded_path), binmode: true)
|
|
596
|
+
yield temporary
|
|
597
|
+
temporary.flush
|
|
598
|
+
temporary.fsync
|
|
599
|
+
temporary.close
|
|
600
|
+
if overwrite
|
|
601
|
+
File.rename(temporary.path, expanded_path)
|
|
602
|
+
else
|
|
603
|
+
File.link(temporary.path, expanded_path)
|
|
604
|
+
File.unlink(temporary.path)
|
|
605
|
+
end
|
|
606
|
+
rescue Errno::EEXIST
|
|
607
|
+
raise InvalidParameterError, "output file already exists: #{path_or_io}"
|
|
608
|
+
ensure
|
|
609
|
+
temporary&.close!
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def validate_pipeline_name!(name)
|
|
613
|
+
return nil if name.nil?
|
|
614
|
+
return name.to_s if name.is_a?(String) || name.is_a?(Symbol)
|
|
615
|
+
|
|
616
|
+
raise InvalidParameterError, "name must be a String or Symbol"
|
|
617
|
+
end
|
|
124
618
|
|
|
125
|
-
|
|
619
|
+
def coerce_duration_seconds!(duration, name)
|
|
620
|
+
seconds = case duration
|
|
621
|
+
when Duration
|
|
622
|
+
duration.total_seconds
|
|
623
|
+
when Numeric
|
|
624
|
+
duration.to_f
|
|
625
|
+
else
|
|
626
|
+
raise InvalidParameterError, "#{name} must be Numeric or Core::Duration"
|
|
627
|
+
end
|
|
628
|
+
unless seconds.finite? && !seconds.negative?
|
|
629
|
+
raise InvalidParameterError, "#{name} must be a non-negative finite duration"
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
seconds
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def with_stream_context(operation, codec:, target:)
|
|
636
|
+
yield
|
|
637
|
+
rescue UserCodeError, StreamError
|
|
638
|
+
raise
|
|
639
|
+
rescue StandardError => e
|
|
640
|
+
raise StreamError, stream_error_message(operation, codec: codec, target: target, error: e)
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def stream_error_message(operation, codec:, target:, error:)
|
|
644
|
+
"#{operation} failed " \
|
|
645
|
+
"(codec=#{stream_codec_name(codec)}, target=#{stream_target_label(target)}, chunk_size=#{@chunk_size}): " \
|
|
646
|
+
"#{error.class}: #{error.message}"
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def stream_codec_name(codec)
|
|
650
|
+
name = codec.respond_to?(:name) ? codec.name : nil
|
|
651
|
+
return name unless name.nil? || name.empty?
|
|
652
|
+
|
|
653
|
+
codec.to_s
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def stream_target_label(target)
|
|
657
|
+
return target if target.is_a?(String)
|
|
658
|
+
return target.path if target.respond_to?(:path)
|
|
659
|
+
|
|
660
|
+
target.class.name || target.class.to_s
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def prepare_source_for_enumeration!
|
|
664
|
+
if @enumerated && !@source.is_a?(String)
|
|
665
|
+
unless @source_start_position && @source.respond_to?(:seek)
|
|
666
|
+
raise StreamError, "IO stream source cannot be enumerated more than once because it is not rewindable"
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
@source.seek(@source_start_position, IO::SEEK_SET)
|
|
670
|
+
end
|
|
671
|
+
@enumerated = true
|
|
672
|
+
rescue IOError, SystemCallError => e
|
|
673
|
+
raise StreamError, "IO stream source cannot be rewound to its original position: #{e.message}"
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def source_position(source)
|
|
677
|
+
return nil if source.is_a?(String) || !source.respond_to?(:pos)
|
|
678
|
+
|
|
679
|
+
source.pos
|
|
680
|
+
rescue IOError, SystemCallError
|
|
681
|
+
nil
|
|
126
682
|
end
|
|
127
683
|
end
|
|
128
684
|
end
|