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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/README.md +204 -22
  4. data/exe/wavify +6 -0
  5. data/lib/wavify/adapters.rb +81 -0
  6. data/lib/wavify/audio.rb +1066 -120
  7. data/lib/wavify/cli.rb +237 -0
  8. data/lib/wavify/codecs/aiff.rb +388 -87
  9. data/lib/wavify/codecs/base.rb +98 -5
  10. data/lib/wavify/codecs/flac.rb +660 -170
  11. data/lib/wavify/codecs/ogg_vorbis.rb +515 -155
  12. data/lib/wavify/codecs/raw.rb +235 -45
  13. data/lib/wavify/codecs/registry.rb +272 -10
  14. data/lib/wavify/codecs/wav.rb +453 -74
  15. data/lib/wavify/core/duration.rb +54 -2
  16. data/lib/wavify/core/format.rb +96 -11
  17. data/lib/wavify/core/sample_buffer.rb +648 -60
  18. data/lib/wavify/core/stream.rb +590 -34
  19. data/lib/wavify/dsl.rb +712 -100
  20. data/lib/wavify/dsp/automation.rb +109 -0
  21. data/lib/wavify/dsp/effects/auto_pan.rb +71 -0
  22. data/lib/wavify/dsp/effects/bitcrusher.rb +74 -0
  23. data/lib/wavify/dsp/effects/chorus.rb +15 -4
  24. data/lib/wavify/dsp/effects/compressor.rb +133 -23
  25. data/lib/wavify/dsp/effects/delay.rb +46 -1
  26. data/lib/wavify/dsp/effects/distortion.rb +3 -2
  27. data/lib/wavify/dsp/effects/effect_base.rb +69 -2
  28. data/lib/wavify/dsp/effects/effect_chain.rb +94 -0
  29. data/lib/wavify/dsp/effects/envelope_controlled_effect.rb +93 -0
  30. data/lib/wavify/dsp/effects/eq.rb +102 -0
  31. data/lib/wavify/dsp/effects/expander.rb +90 -0
  32. data/lib/wavify/dsp/effects/flanger.rb +112 -0
  33. data/lib/wavify/dsp/effects/limiter.rb +259 -0
  34. data/lib/wavify/dsp/effects/mastering_chain.rb +31 -0
  35. data/lib/wavify/dsp/effects/noise_gate.rb +72 -0
  36. data/lib/wavify/dsp/effects/phaser.rb +112 -0
  37. data/lib/wavify/dsp/effects/podcast_chain.rb +32 -0
  38. data/lib/wavify/dsp/effects/reverb.rb +100 -14
  39. data/lib/wavify/dsp/effects/soft_limiter.rb +55 -0
  40. data/lib/wavify/dsp/effects/stereo_widener.rb +73 -0
  41. data/lib/wavify/dsp/effects/tremolo.rb +66 -0
  42. data/lib/wavify/dsp/effects/vibrato.rb +102 -0
  43. data/lib/wavify/dsp/effects.rb +106 -0
  44. data/lib/wavify/dsp/envelope.rb +90 -19
  45. data/lib/wavify/dsp/filter.rb +149 -8
  46. data/lib/wavify/dsp/headroom.rb +57 -0
  47. data/lib/wavify/dsp/lfo.rb +65 -0
  48. data/lib/wavify/dsp/loudness_meter.rb +281 -0
  49. data/lib/wavify/dsp/oscillator.rb +261 -22
  50. data/lib/wavify/dsp/processor.rb +90 -0
  51. data/lib/wavify/errors.rb +2 -2
  52. data/lib/wavify/sequencer/engine.rb +405 -89
  53. data/lib/wavify/sequencer/note_sequence.rb +45 -7
  54. data/lib/wavify/sequencer/pattern.rb +121 -12
  55. data/lib/wavify/sequencer/track.rb +149 -10
  56. data/lib/wavify/version.rb +1 -1
  57. data/lib/wavify.rb +18 -0
  58. data/sig/audio.rbs +86 -0
  59. data/sig/codecs.rbs +77 -0
  60. data/sig/core.rbs +108 -0
  61. data/sig/dsl.rbs +32 -0
  62. data/sig/dsp.rbs +87 -0
  63. data/sig/effects.rbs +129 -0
  64. data/sig/public_api.yml +111 -0
  65. data/sig/sequencer.rbs +126 -0
  66. data/sig/stream.rbs +30 -0
  67. data/sig/wavify.rbs +24 -0
  68. metadata +44 -58
  69. data/.serena/.gitignore +0 -1
  70. data/.serena/memories/project_overview.md +0 -5
  71. data/.serena/memories/style_and_completion.md +0 -5
  72. data/.serena/memories/suggested_commands.md +0 -11
  73. data/.serena/project.yml +0 -126
  74. data/.simplecov +0 -18
  75. data/.yardopts +0 -4
  76. data/Rakefile +0 -190
  77. data/benchmarks/README.md +0 -46
  78. data/benchmarks/benchmark_helper.rb +0 -112
  79. data/benchmarks/dsp_effects_benchmark.rb +0 -46
  80. data/benchmarks/flac_benchmark.rb +0 -74
  81. data/benchmarks/streaming_memory_benchmark.rb +0 -94
  82. data/benchmarks/wav_io_benchmark.rb +0 -110
  83. data/examples/audio_processing.rb +0 -73
  84. data/examples/cinematic_transition.rb +0 -118
  85. data/examples/drum_machine.rb +0 -74
  86. data/examples/format_convert.rb +0 -81
  87. data/examples/hybrid_arrangement.rb +0 -165
  88. data/examples/streaming_master_chain.rb +0 -129
  89. data/examples/synth_pad.rb +0 -42
  90. data/tools/fixture_writer.rb +0 -85
@@ -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 :format, :chunk_size
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 `#call`, `#process`, or `#apply`.
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
- @codec.stream_read(@source, chunk_size: @chunk_size, **@codec_read_options) do |chunk|
52
- @format ||= chunk.format
53
- yield apply_pipeline(chunk)
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
- output_codec = detect_output_codec(path_or_io)
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
- output_codec.stream_write(path_or_io, format: target_format) do |writer|
69
- each_chunk do |chunk|
70
- output_chunk = target_format ? chunk.convert(target_format) : chunk
71
- writer.call(output_chunk)
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
- def apply_pipeline(chunk)
81
- @pipeline.reduce(chunk) do |current, processor|
82
- result = if processor.respond_to?(:call)
83
- processor.call(current)
84
- elsif processor.respond_to?(:process)
85
- processor.process(current)
86
- else
87
- processor.apply(current)
88
- end
89
-
90
- if result.is_a?(Audio)
91
- result.buffer
92
- elsif result.is_a?(SampleBuffer)
93
- result
94
- else
95
- raise ProcessingError, "stream processor must return Core::SampleBuffer or Audio"
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
- def detect_output_codec(path_or_io)
101
- return @codec unless path_or_io.is_a?(String)
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
- Codecs::Registry.detect(path_or_io)
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
- return {} if codec_read_options.nil?
123
- raise InvalidParameterError, "codec_read_options must be a Hash" unless codec_read_options.is_a?(Hash)
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
- codec_read_options.dup
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