deftones 0.1.0 → 1.0.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -6
  3. data/README.md +5 -0
  4. data/Rakefile +50 -1
  5. data/lib/deftones/analysis/meter.rb +22 -2
  6. data/lib/deftones/component/channel.rb +1 -1
  7. data/lib/deftones/component/compressor.rb +127 -22
  8. data/lib/deftones/component/filter.rb +29 -19
  9. data/lib/deftones/component/merge.rb +14 -0
  10. data/lib/deftones/component/multiband_compressor.rb +1 -1
  11. data/lib/deftones/component/one_pole_filter.rb +10 -3
  12. data/lib/deftones/component/panner.rb +25 -2
  13. data/lib/deftones/component/panner3d.rb +0 -10
  14. data/lib/deftones/component/split.rb +14 -0
  15. data/lib/deftones/context.rb +90 -9
  16. data/lib/deftones/core/audio_block.rb +64 -5
  17. data/lib/deftones/core/audio_node.rb +98 -8
  18. data/lib/deftones/core/gain.rb +0 -8
  19. data/lib/deftones/core/instrument.rb +52 -10
  20. data/lib/deftones/core/param.rb +51 -1
  21. data/lib/deftones/core/signal.rb +79 -28
  22. data/lib/deftones/core/source.rb +71 -11
  23. data/lib/deftones/destination.rb +41 -17
  24. data/lib/deftones/draw.rb +6 -10
  25. data/lib/deftones/dsp/biquad.rb +9 -4
  26. data/lib/deftones/dsp/delay_line.rb +2 -2
  27. data/lib/deftones/dsp/helpers.rb +7 -0
  28. data/lib/deftones/effect/bit_crusher.rb +10 -2
  29. data/lib/deftones/effect/chebyshev.rb +7 -3
  30. data/lib/deftones/effect/distortion.rb +5 -3
  31. data/lib/deftones/effect/feedback_delay.rb +2 -1
  32. data/lib/deftones/effect/oversampling.rb +43 -0
  33. data/lib/deftones/effect/phaser.rb +2 -1
  34. data/lib/deftones/effect/pitch_shift.rb +1 -2
  35. data/lib/deftones/effect/reverb.rb +73 -5
  36. data/lib/deftones/event/callback_behavior.rb +7 -3
  37. data/lib/deftones/event/loop.rb +7 -2
  38. data/lib/deftones/event/part.rb +18 -3
  39. data/lib/deftones/event/pattern.rb +51 -6
  40. data/lib/deftones/event/sequence.rb +19 -5
  41. data/lib/deftones/event/tone_event.rb +7 -2
  42. data/lib/deftones/event/transport.rb +243 -21
  43. data/lib/deftones/instrument/poly_synth.rb +81 -15
  44. data/lib/deftones/instrument/sampler.rb +53 -10
  45. data/lib/deftones/io/buffer.rb +376 -55
  46. data/lib/deftones/io/buffers.rb +28 -4
  47. data/lib/deftones/io/recorder.rb +2 -1
  48. data/lib/deftones/music/frequency.rb +13 -8
  49. data/lib/deftones/music/midi.rb +132 -9
  50. data/lib/deftones/music/note.rb +13 -3
  51. data/lib/deftones/music/time.rb +42 -4
  52. data/lib/deftones/offline_context.rb +194 -17
  53. data/lib/deftones/portaudio_support.rb +68 -9
  54. data/lib/deftones/source/fat_oscillator.rb +28 -9
  55. data/lib/deftones/source/grain_player.rb +49 -2
  56. data/lib/deftones/source/noise.rb +42 -10
  57. data/lib/deftones/source/omni_oscillator.rb +1 -2
  58. data/lib/deftones/source/oscillator.rb +83 -19
  59. data/lib/deftones/source/player.rb +24 -6
  60. data/lib/deftones/source/players.rb +39 -6
  61. data/lib/deftones/source/tone_buffer_source.rb +12 -6
  62. data/lib/deftones/source/tone_oscillator_node.rb +4 -3
  63. data/lib/deftones/source/user_media.rb +83 -10
  64. data/lib/deftones/version.rb +1 -1
  65. data/lib/deftones.rb +108 -31
  66. metadata +3 -44
@@ -2,16 +2,26 @@
2
2
 
3
3
  require "open3"
4
4
  require "tempfile"
5
+ require "timeout"
5
6
 
6
7
  module Deftones
7
8
  module IO
8
9
  class Buffer
9
10
  include Enumerable
10
11
 
11
- attr_reader :samples, :channels, :sample_rate
12
+ attr_reader :samples, :channels, :sample_rate, :interpolation
12
13
 
13
14
  COMPRESSED_EXTENSIONS = %w[.mp3 .ogg .oga].freeze
14
15
  SAVEABLE_FORMATS = %i[wav mp3 ogg].freeze
16
+ DEFAULT_CODEC_TIMEOUT = 30.0
17
+ INTERPOLATION_MODES = %i[linear nearest cubic sinc_lite].freeze
18
+ SINC_LITE_RADIUS = 8
19
+ WAV_BIT_DEPTHS = [16, 24, 32].freeze
20
+ Statistics = Struct.new(:peak, :rms, :clip_count, keyword_init: true)
21
+
22
+ class << self
23
+ attr_accessor :codec_backend, :codec_timeout
24
+ end
15
25
 
16
26
  def self.interleave(mono_samples, channels)
17
27
  return mono_samples.dup if channels == 1
@@ -19,12 +29,12 @@ module Deftones
19
29
  mono_samples.flat_map { |sample| Array.new(channels, sample) }
20
30
  end
21
31
 
22
- def self.from_mono(samples, channels: 1, sample_rate: Context::DEFAULT_SAMPLE_RATE)
32
+ def self.from_mono(samples, channels: 1, sample_rate: Context::DEFAULT_SAMPLE_RATE, interpolation: :linear)
23
33
  interleaved = channels == 1 ? samples : interleave(samples, channels)
24
- new(interleaved, channels: channels, sample_rate: sample_rate)
34
+ new(interleaved, channels: channels, sample_rate: sample_rate, interpolation: interpolation)
25
35
  end
26
36
 
27
- def self.from_array(samples, sample_rate: Context::DEFAULT_SAMPLE_RATE, channels: nil)
37
+ def self.from_array(samples, sample_rate: Context::DEFAULT_SAMPLE_RATE, channels: nil, interpolation: :linear)
28
38
  if samples.first.is_a?(Array)
29
39
  channel_count = channels || samples.length
30
40
  frame_count = samples.map(&:length).max || 0
@@ -37,9 +47,9 @@ module Deftones
37
47
  end
38
48
  end
39
49
 
40
- new(interleaved, channels: channel_count, sample_rate: sample_rate)
50
+ new(interleaved, channels: channel_count, sample_rate: sample_rate, interpolation: interpolation)
41
51
  else
42
- from_mono(samples, channels: channels || 1, sample_rate: sample_rate)
52
+ from_mono(samples, channels: channels || 1, sample_rate: sample_rate, interpolation: interpolation)
43
53
  end
44
54
  end
45
55
 
@@ -51,24 +61,40 @@ module Deftones
51
61
  true
52
62
  end
53
63
 
64
+ def self.compressed_audio_available?
65
+ return true if codec_backend&.respond_to?(:decode)
66
+ return true if codec_backend&.respond_to?(:encode)
67
+
68
+ send(:executable_available?, "ffmpeg") || send(:executable_available?, "afconvert")
69
+ end
70
+
54
71
  class << self
55
72
  alias fromArray from_array
56
73
  alias fromUrl from_url
74
+ alias compressedAudioAvailable compressed_audio_available?
57
75
  end
58
76
 
59
- def self.load(path)
60
- extension = File.extname(path).downcase
61
- return load_wav(path) if extension == ".wav"
62
- return load_compressed(path, extension) if COMPRESSED_EXTENSIONS.include?(extension)
77
+ def self.load(source, sample_rate: nil, channels: nil)
78
+ return load_io(source) if source.respond_to?(:read) && !source.is_a?(String)
79
+
80
+ validate_path_string!(source, role: "audio source")
81
+ extension = File.extname(source).downcase
82
+ return load_wav(source) if extension == ".wav"
83
+ return load_compressed(source, extension, sample_rate: sample_rate, channels: channels) if COMPRESSED_EXTENSIONS.include?(extension)
63
84
 
64
- raise ArgumentError, "Unsupported audio format: #{extension}"
85
+ raise Deftones::UnsupportedAudioFormatError, "Unsupported audio format: #{extension}"
65
86
  end
66
87
 
67
- def initialize(samples, channels:, sample_rate:)
88
+ def initialize(samples, channels:, sample_rate:, interpolation: :linear)
68
89
  @samples = samples.map(&:to_f)
69
90
  @channels = channels
70
91
  @sample_rate = sample_rate
92
+ @interpolation = normalize_interpolation(interpolation)
71
93
  @disposed = false
94
+ @mono_cache = nil
95
+ @peak_cache = nil
96
+ @rms_cache = nil
97
+ @statistics_cache = {}
72
98
  end
73
99
 
74
100
  def each(&block)
@@ -103,21 +129,42 @@ module Deftones
103
129
 
104
130
  def mono
105
131
  return @samples if @channels == 1
132
+ return @mono_cache if @mono_cache
106
133
 
107
- Array.new(frames) do |frame|
134
+ @mono_cache = Array.new(frames) do |frame|
108
135
  offset = frame * @channels
109
136
  @samples[offset, @channels].sum / @channels.to_f
110
137
  end
111
138
  end
112
139
 
113
140
  def peak
114
- @samples.map(&:abs).max || 0.0
141
+ @peak_cache ||= @samples.map(&:abs).max || 0.0
115
142
  end
116
143
 
117
144
  def rms
118
145
  return 0.0 if @samples.empty?
119
146
 
120
- Math.sqrt(@samples.sum { |sample| sample * sample } / @samples.length)
147
+ @rms_cache ||= Math.sqrt(@samples.sum { |sample| sample * sample } / @samples.length)
148
+ end
149
+
150
+ def integrated_lufs
151
+ return -Float::INFINITY if rms <= 0.0
152
+
153
+ (20.0 * Math.log10(rms)) - 0.691
154
+ end
155
+
156
+ def clip_count(threshold = 1.0)
157
+ limit = threshold.to_f.abs
158
+ @samples.count { |sample| sample.abs >= limit }
159
+ end
160
+
161
+ def statistics(clip_threshold: 1.0)
162
+ threshold = clip_threshold.to_f.abs
163
+ @statistics_cache[threshold] ||= Statistics.new(
164
+ peak: peak,
165
+ rms: rms,
166
+ clip_count: clip_count(threshold)
167
+ )
121
168
  end
122
169
 
123
170
  def [](frame_index, channel = nil)
@@ -146,43 +193,172 @@ module Deftones
146
193
  Array.new(@channels) { |channel_index| get_channel_data(channel_index) }
147
194
  end
148
195
 
149
- def sample_at(frame_position, channel = 0)
196
+ def interpolation=(value)
197
+ @interpolation = normalize_interpolation(value)
198
+ end
199
+
200
+ def sample_at(frame_position, channel = 0, interpolation: @interpolation)
150
201
  return 0.0 if @samples.empty?
151
202
 
152
203
  clamped_position = Deftones::DSP::Helpers.clamp(frame_position.to_f, 0.0, [frames - 1, 0].max)
204
+ channel_index = [channel, @channels - 1].min
205
+ case normalize_interpolation(interpolation)
206
+ when :nearest
207
+ self[clamped_position.round, channel_index]
208
+ when :cubic
209
+ cubic_sample_at(clamped_position, channel_index)
210
+ when :sinc_lite
211
+ sinc_lite_sample_at(clamped_position, channel_index)
212
+ else
213
+ linear_sample_at(clamped_position, channel_index)
214
+ end
215
+ end
216
+
217
+ def sample_at_nearest(frame_position, channel = 0)
218
+ sample_at(frame_position, channel, interpolation: :nearest)
219
+ end
220
+
221
+ def sample_at_cubic(frame_position, channel = 0)
222
+ sample_at(frame_position, channel, interpolation: :cubic)
223
+ end
224
+
225
+ def sample_at_sinc_lite(frame_position, channel = 0)
226
+ sample_at(frame_position, channel, interpolation: :sinc_lite)
227
+ end
228
+
229
+ alias sampleAt sample_at
230
+ alias sampleAtNearest sample_at_nearest
231
+ alias sampleAtCubic sample_at_cubic
232
+ alias sampleAtSincLite sample_at_sinc_lite
233
+
234
+ def resample(target_sample_rate, interpolation: @interpolation)
235
+ normalized_sample_rate = target_sample_rate.to_f
236
+ raise ArgumentError, "sample rate must be positive" unless normalized_sample_rate.positive? && normalized_sample_rate.finite?
237
+ return new_like(@samples) if normalized_sample_rate == @sample_rate.to_f
238
+
239
+ target_frames = (frames * (normalized_sample_rate / @sample_rate.to_f)).round
240
+ channel_data = Array.new(@channels) do |channel_index|
241
+ Array.new(target_frames) do |frame_index|
242
+ source_position = frame_index * (@sample_rate.to_f / normalized_sample_rate)
243
+ sample_at(source_position, channel_index, interpolation: interpolation)
244
+ end
245
+ end
246
+ self.class.from_array(
247
+ channel_data,
248
+ sample_rate: normalized_sample_rate.round,
249
+ channels: @channels,
250
+ interpolation: normalize_interpolation(interpolation)
251
+ )
252
+ end
253
+
254
+ alias resampleTo resample
255
+
256
+ def linear_sample_at(clamped_position, channel)
153
257
  lower = clamped_position.floor
154
258
  upper = [lower + 1, frames - 1].min
155
259
  fraction = clamped_position - lower
156
- lower_sample = self[lower, [channel, @channels - 1].min]
157
- upper_sample = self[upper, [channel, @channels - 1].min]
260
+ lower_sample = self[lower, channel]
261
+ upper_sample = self[upper, channel]
158
262
  Deftones::DSP::Helpers.lerp(lower_sample, upper_sample, fraction)
159
263
  end
160
264
 
265
+ def cubic_sample_at(clamped_position, channel)
266
+ base = clamped_position.floor
267
+ fraction = clamped_position - base
268
+ p0 = self[[base - 1, 0].max, channel]
269
+ p1 = self[base, channel]
270
+ p2 = self[[base + 1, frames - 1].min, channel]
271
+ p3 = self[[base + 2, frames - 1].min, channel]
272
+ a0 = (-0.5 * p0) + (1.5 * p1) - (1.5 * p2) + (0.5 * p3)
273
+ a1 = p0 - (2.5 * p1) + (2.0 * p2) - (0.5 * p3)
274
+ a2 = (-0.5 * p0) + (0.5 * p2)
275
+ a3 = p1
276
+ (((a0 * fraction) + a1) * fraction * fraction) + (a2 * fraction) + a3
277
+ end
278
+
279
+ def sinc_lite_sample_at(clamped_position, channel)
280
+ center = clamped_position.floor
281
+ weighted_sum = 0.0
282
+ weight_total = 0.0
283
+
284
+ ((center - SINC_LITE_RADIUS + 1)..(center + SINC_LITE_RADIUS)).each do |index|
285
+ next if index.negative? || index >= frames
286
+
287
+ distance = clamped_position - index
288
+ weight = sinc(distance) * hann_window(distance / SINC_LITE_RADIUS)
289
+ weighted_sum += self[index, channel] * weight
290
+ weight_total += weight
291
+ end
292
+
293
+ return linear_sample_at(clamped_position, channel) if weight_total.abs < 1.0e-12
294
+
295
+ weighted_sum / weight_total
296
+ end
297
+
298
+ def sinc(value)
299
+ return 1.0 if value.abs < 1.0e-12
300
+
301
+ x = Math::PI * value
302
+ Math.sin(x) / x
303
+ end
304
+
305
+ def hann_window(normalized_distance)
306
+ distance = normalized_distance.abs
307
+ return 0.0 if distance >= 1.0
308
+
309
+ 0.5 * (1.0 + Math.cos(Math::PI * distance))
310
+ end
311
+
161
312
  def slice(start_frame, length)
162
313
  frame_count = [length.to_i, 0].max
163
- offset = start_frame.to_i * @channels
314
+ first_frame = [start_frame.to_i, 0].max
315
+ offset = first_frame * @channels
164
316
  subset = @samples.slice(offset, frame_count * @channels) || []
165
- self.class.new(subset, channels: @channels, sample_rate: @sample_rate)
317
+ new_like(subset)
318
+ end
319
+
320
+ def slice_seconds(start_time, duration)
321
+ start_frame = (Deftones::Music::Time.parse(start_time) * @sample_rate).floor
322
+ frame_count = (Deftones::Music::Time.parse(duration) * @sample_rate).ceil
323
+ slice(start_frame, frame_count)
166
324
  end
167
325
 
168
326
  def reverse
169
327
  reversed_frames = each_frame.to_a.reverse.flatten
170
- self.class.new(reversed_frames, channels: @channels, sample_rate: @sample_rate)
328
+ new_like(reversed_frames)
171
329
  end
172
330
 
173
331
  def normalize(target_peak = 0.99)
174
- return self.class.new(@samples, channels: @channels, sample_rate: @sample_rate) if peak.zero?
332
+ normalized_target = normalize_level_target(target_peak, "target peak")
333
+ return new_like(@samples) if peak.zero?
334
+
335
+ scale = normalized_target / peak
336
+ new_like(@samples.map { |sample| sample * scale })
337
+ end
338
+
339
+ def normalize_rms(target_rms = 0.2)
340
+ normalized_target = normalize_level_target(target_rms, "target RMS")
341
+ return new_like(@samples) if rms.zero?
175
342
 
176
- scale = target_peak.to_f / peak
177
- self.class.new(@samples.map { |sample| sample * scale }, channels: @channels, sample_rate: @sample_rate)
343
+ scale = normalized_target / rms
344
+ new_like(@samples.map { |sample| sample * scale })
345
+ end
346
+
347
+ def normalize_lufs(target_lufs = -14.0)
348
+ target_gain = 10.0**((target_lufs.to_f + 0.691) / 20.0)
349
+ normalize_rms(target_gain)
178
350
  end
179
351
 
180
352
  def mixdown
181
- self.class.new(mono, channels: 1, sample_rate: @sample_rate)
353
+ self.class.new(mono, channels: 1, sample_rate: @sample_rate, interpolation: @interpolation)
182
354
  end
183
355
 
184
356
  def dispose
185
357
  @samples = []
358
+ @mono_cache = nil
359
+ @peak_cache = nil
360
+ @rms_cache = nil
361
+ @statistics_cache.clear
186
362
  @disposed = true
187
363
  self
188
364
  end
@@ -190,46 +366,100 @@ module Deftones
190
366
  alias numberOfChannels number_of_channels
191
367
  alias getChannelData get_channel_data
192
368
  alias toArray to_array
369
+ alias sliceSeconds slice_seconds
370
+ alias normalizeRms normalize_rms
371
+ alias normalizeLufs normalize_lufs
372
+ alias integratedLufs integrated_lufs
373
+ alias stats statistics
374
+
375
+ def save(target, format: nil, on_format_mismatch: :error, bit_depth: 16, dither: false, dither_rng: nil)
376
+ if target.respond_to?(:write) && !target.is_a?(String)
377
+ return save_io(target, format: format || :wav, bit_depth: bit_depth, dither: dither, dither_rng: dither_rng)
378
+ end
193
379
 
194
- def save(path, format: nil)
195
- resolved_format = self.class.send(:resolve_save_format, path, format)
196
- raise ArgumentError, "Unsupported format: #{resolved_format}" unless SAVEABLE_FORMATS.include?(resolved_format)
380
+ self.class.send(:validate_path_string!, target, role: "audio target")
381
+ resolved_format = self.class.send(:resolve_save_format, target, format, on_format_mismatch: on_format_mismatch)
382
+ raise Deftones::UnsupportedAudioFormatError, "Unsupported format: #{resolved_format}" unless SAVEABLE_FORMATS.include?(resolved_format)
197
383
 
198
384
  case resolved_format
199
385
  when :wav
200
- save_wav(path)
386
+ save_wav(target, bit_depth: bit_depth, dither: dither, dither_rng: dither_rng)
201
387
  when :mp3, :ogg
202
- save_compressed(path, resolved_format)
388
+ save_compressed(target, resolved_format, bit_depth: bit_depth, dither: dither, dither_rng: dither_rng)
203
389
  end
204
- path
390
+ target
205
391
  end
206
392
 
207
393
  private
208
394
 
209
- def save_wav(path)
395
+ def new_like(samples, channels: @channels, sample_rate: @sample_rate)
396
+ self.class.new(samples, channels: channels, sample_rate: sample_rate, interpolation: @interpolation)
397
+ end
398
+
399
+ def normalize_interpolation(value)
400
+ normalized = value.to_sym
401
+ return normalized if INTERPOLATION_MODES.include?(normalized)
402
+
403
+ raise ArgumentError, "Unsupported interpolation mode: #{value}"
404
+ end
405
+
406
+ def normalize_level_target(value, name)
407
+ normalized = value.to_f
408
+ raise ArgumentError, "#{name} must be finite and non-negative" unless normalized.finite? && !normalized.negative?
409
+
410
+ normalized
411
+ end
412
+
413
+ def save_io(io, format:, bit_depth:, dither:, dither_rng:)
414
+ Tempfile.create(["deftones-buffer-save", ".#{format}"]) do |tempfile|
415
+ tempfile.close
416
+ save(tempfile.path, format: format, bit_depth: bit_depth, dither: dither, dither_rng: dither_rng)
417
+ io.write(File.binread(tempfile.path))
418
+ end
419
+ io
420
+ end
421
+
422
+ def save_wav(path, bit_depth:, dither:, dither_rng:)
423
+ self.class.send(:ensure_wav_backend!)
424
+ normalized_bit_depth = self.class.send(:validate_wav_bit_depth, bit_depth)
425
+ output_samples = dither ? dithered_samples(normalized_bit_depth, dither_rng) : @samples
426
+
210
427
  sample_buffer = Wavify::Core::SampleBuffer.new(
211
- @samples,
428
+ output_samples,
212
429
  self.class.send(:wavify_work_format, @channels, @sample_rate)
213
430
  )
214
431
  Wavify::Codecs::Wav.write(
215
432
  path,
216
433
  sample_buffer,
217
- format: self.class.send(:wavify_wav_format, @channels, @sample_rate)
434
+ format: self.class.send(:wavify_wav_format, @channels, @sample_rate, normalized_bit_depth)
218
435
  )
219
436
  end
220
437
 
221
- def save_compressed(path, format)
438
+ def save_compressed(path, format, bit_depth:, dither:, dither_rng:)
222
439
  backend = self.class.send(:encoder_backend_for, format)
223
- raise ArgumentError, self.class.send(:missing_encoder_message, format) unless backend
440
+ raise Deftones::MissingCodecBackendError, self.class.send(:missing_encoder_message, format) unless backend
224
441
 
225
442
  Tempfile.create(["deftones-buffer-export", ".wav"]) do |tempfile|
226
443
  tempfile.close
227
- save_wav(tempfile.path)
228
- stdout, stderr, status = Open3.capture3(*self.class.send(:encoder_command, backend, tempfile.path, path, format))
444
+ save_wav(tempfile.path, bit_depth: bit_depth, dither: dither, dither_rng: dither_rng)
445
+ if self.class.send(:custom_codec_backend?, backend)
446
+ backend.encode(tempfile.path, path, format: format, sample_rate: @sample_rate, channels: @channels)
447
+ return
448
+ end
449
+
450
+ command = self.class.send(:encoder_command, backend, tempfile.path, path, format, @sample_rate, @channels)
451
+ stdout, stderr, status = self.class.send(:capture_codec_command, *command)
229
452
  return if status.success?
230
453
 
231
- message = [stderr, stdout].map(&:strip).reject(&:empty?).first || "unknown encoder error"
232
- raise ArgumentError, "Failed to encode #{format}: #{message}"
454
+ self.class.send(:raise_codec_command_error, "Failed to encode #{format}", command, stdout, stderr, status)
455
+ end
456
+ end
457
+
458
+ def dithered_samples(bit_depth, rng)
459
+ random = rng || Random
460
+ step = 1.0 / ((2**(bit_depth - 1)) - 1)
461
+ @samples.map do |sample|
462
+ Deftones::DSP::Helpers.clamp(sample + ((random.rand - random.rand) * step), -1.0, 1.0)
233
463
  end
234
464
  end
235
465
 
@@ -237,27 +467,60 @@ module Deftones
237
467
  private
238
468
 
239
469
  def load_wav(path)
470
+ validate_path_string!(path, role: "WAV source")
471
+ ensure_wav_backend!
472
+
240
473
  sample_buffer = Wavify::Codecs::Wav.read(path)
241
474
  float_buffer = sample_buffer.convert(wavify_work_format(sample_buffer.format.channels, sample_buffer.format.sample_rate))
242
475
  new(float_buffer.samples, channels: float_buffer.format.channels, sample_rate: float_buffer.format.sample_rate)
243
- rescue Wavify::Error => error
476
+ rescue StandardError => error
477
+ raise if error.is_a?(Deftones::MissingCodecBackendError)
478
+ raise unless defined?(Wavify::Error) && error.is_a?(Wavify::Error)
479
+
244
480
  raise ArgumentError, "Failed to load WAV: #{error.message}"
245
481
  end
246
482
 
247
- def load_compressed(path, extension)
483
+ def load_io(io)
484
+ extension = io.respond_to?(:path) ? File.extname(io.path).downcase : ".wav"
485
+ extension = ".wav" if extension.empty?
486
+ Tempfile.create(["deftones-buffer-load", extension]) do |tempfile|
487
+ tempfile.binmode
488
+ tempfile.write(io.read)
489
+ tempfile.close
490
+ load(tempfile.path)
491
+ end
492
+ end
493
+
494
+ def load_compressed(path, extension, sample_rate: nil, channels: nil)
495
+ validate_path_string!(path, role: "compressed audio source")
248
496
  backend = decoder_backend_for(extension)
249
- raise ArgumentError, missing_decoder_message(extension) unless backend
497
+ raise Deftones::MissingCodecBackendError, missing_decoder_message(extension) unless backend
250
498
 
251
499
  Tempfile.create(["deftones-buffer", ".wav"]) do |tempfile|
252
500
  tempfile.close
253
- stdout, stderr, status = Open3.capture3(*decoder_command(backend, path, tempfile.path))
501
+ if custom_codec_backend?(backend)
502
+ decode_options = { extension: extension }
503
+ decode_options[:sample_rate] = sample_rate if sample_rate
504
+ decode_options[:channels] = channels if channels
505
+ backend.decode(path, tempfile.path, **decode_options)
506
+ next load_wav(tempfile.path)
507
+ end
508
+
509
+ command = decoder_command(backend, path, tempfile.path, sample_rate: sample_rate, channels: channels)
510
+ stdout, stderr, status = capture_codec_command(*command)
254
511
  next load_wav(tempfile.path) if status.success?
255
512
 
256
- message = [stderr, stdout].map(&:strip).reject(&:empty?).first || "unknown decoder error"
257
- raise ArgumentError, "Failed to decode #{extension}: #{message}"
513
+ raise_codec_command_error("Failed to decode #{extension}", command, stdout, stderr, status)
258
514
  end
259
515
  end
260
516
 
517
+ def ensure_wav_backend!
518
+ return if Deftones.wavify_available?
519
+
520
+ raise Deftones::MissingCodecBackendError,
521
+ "WAV codec backend is unavailable. Install the wavify gem to load or save WAV audio."
522
+ end
523
+
261
524
  def wavify_work_format(channels, sample_rate)
262
525
  Wavify::Core::Format.new(
263
526
  channels: channels,
@@ -267,16 +530,24 @@ module Deftones
267
530
  )
268
531
  end
269
532
 
270
- def wavify_wav_format(channels, sample_rate)
533
+ def wavify_wav_format(channels, sample_rate, bit_depth)
271
534
  Wavify::Core::Format.new(
272
535
  channels: channels,
273
536
  sample_rate: sample_rate,
274
- bit_depth: 16,
537
+ bit_depth: bit_depth,
275
538
  sample_format: :pcm
276
539
  )
277
540
  end
278
541
 
542
+ def validate_wav_bit_depth(bit_depth)
543
+ normalized = bit_depth.to_i
544
+ return normalized if WAV_BIT_DEPTHS.include?(normalized)
545
+
546
+ raise ArgumentError, "Unsupported WAV bit depth: #{bit_depth}"
547
+ end
548
+
279
549
  def decoder_backend_for(extension)
550
+ return codec_backend if codec_backend&.respond_to?(:decode)
280
551
  return :ffmpeg if executable_available?("ffmpeg")
281
552
  return :afconvert if extension == ".mp3" && executable_available?("afconvert")
282
553
 
@@ -284,16 +555,20 @@ module Deftones
284
555
  end
285
556
 
286
557
  def encoder_backend_for(format)
558
+ return codec_backend if codec_backend&.respond_to?(:encode)
287
559
  return :ffmpeg if executable_available?("ffmpeg")
288
560
  return :afconvert if format == :mp3 && executable_available?("afconvert")
289
561
 
290
562
  nil
291
563
  end
292
564
 
293
- def decoder_command(backend, input_path, output_path)
565
+ def decoder_command(backend, input_path, output_path, sample_rate: nil, channels: nil)
294
566
  case backend
295
567
  when :ffmpeg
296
- ["ffmpeg", "-v", "error", "-y", "-i", input_path, "-f", "wav", output_path]
568
+ command = ["ffmpeg", "-v", "error", "-y", "-i", input_path, "-vn", "-map", "0:a:0", "-acodec", "pcm_f32le"]
569
+ command += ["-ar", sample_rate.to_i.to_s] if sample_rate
570
+ command += ["-ac", channels.to_i.to_s] if channels
571
+ command + ["-f", "wav", output_path]
297
572
  when :afconvert
298
573
  ["afconvert", "-f", "WAVE", "-d", "LEI16", input_path, output_path]
299
574
  else
@@ -301,11 +576,17 @@ module Deftones
301
576
  end
302
577
  end
303
578
 
304
- def encoder_command(backend, input_path, output_path, format)
579
+ def encoder_command(backend, input_path, output_path, format, sample_rate, channels)
305
580
  case backend
306
581
  when :ffmpeg
307
582
  container = format == :ogg ? "ogg" : format.to_s
308
- ["ffmpeg", "-v", "error", "-y", "-i", input_path, "-f", container, output_path]
583
+ sample_format = format == :mp3 ? "s16p" : "s16"
584
+ codec = format == :mp3 ? "libmp3lame" : "flac"
585
+ [
586
+ "ffmpeg", "-v", "error", "-y", "-i", input_path, "-vn", "-map", "0:a:0",
587
+ "-codec:a", codec, "-sample_fmt", sample_format, "-ar", sample_rate.to_s, "-ac", channels.to_s,
588
+ "-f", container, output_path
589
+ ]
309
590
  when :afconvert
310
591
  raise ArgumentError, "afconvert only supports mp3 export" unless format == :mp3
311
592
 
@@ -322,6 +603,30 @@ module Deftones
322
603
  end
323
604
  end
324
605
 
606
+ def capture_codec_command(*command)
607
+ Timeout.timeout(codec_timeout || DEFAULT_CODEC_TIMEOUT) do
608
+ Open3.capture3(*command)
609
+ end
610
+ rescue Timeout::Error
611
+ raise ArgumentError, "Codec command timed out after #{codec_timeout || DEFAULT_CODEC_TIMEOUT} seconds"
612
+ end
613
+
614
+ def custom_codec_backend?(backend)
615
+ !backend.is_a?(Symbol)
616
+ end
617
+
618
+ def raise_codec_command_error(prefix, command, stdout, stderr, status)
619
+ detail = [stderr, stdout].map(&:to_s).map(&:strip).reject(&:empty?).first || "unknown codec error"
620
+ exit_status = status.respond_to?(:exitstatus) && status.exitstatus ? " (exit #{status.exitstatus})" : ""
621
+ raise Deftones::CodecCommandError.new(
622
+ "#{prefix}: #{detail}#{exit_status}",
623
+ command: command,
624
+ stdout: stdout,
625
+ stderr: stderr,
626
+ status: status
627
+ )
628
+ end
629
+
325
630
  def missing_decoder_message(extension)
326
631
  "No decoder available for #{extension}. Install ffmpeg to enable compressed audio loading."
327
632
  end
@@ -330,10 +635,18 @@ module Deftones
330
635
  "No encoder available for #{format}. Install ffmpeg to enable compressed audio export."
331
636
  end
332
637
 
333
- def resolve_save_format(path, format)
334
- return normalize_format(format) if format
335
-
638
+ def resolve_save_format(path, format, on_format_mismatch:)
336
639
  extension = File.extname(path).downcase
640
+ if format
641
+ normalized = normalize_format(format)
642
+ expected_extension = ".#{normalized}"
643
+ if on_format_mismatch == :error && !extension.empty? && extension != expected_extension
644
+ raise Deftones::UnsupportedAudioFormatError,
645
+ "Format #{normalized} does not match file extension #{extension}"
646
+ end
647
+ return normalized
648
+ end
649
+
337
650
  return :mp3 if extension == ".mp3"
338
651
  return :ogg if COMPRESSED_EXTENSIONS.include?(extension)
339
652
 
@@ -346,6 +659,14 @@ module Deftones
346
659
 
347
660
  normalized
348
661
  end
662
+
663
+ def validate_path_string!(path, role:)
664
+ string = path.to_s
665
+ raise ArgumentError, "#{role} path must not be empty" if string.empty?
666
+ raise ArgumentError, "#{role} path contains a null byte" if string.include?("\0")
667
+
668
+ true
669
+ end
349
670
  end
350
671
  end
351
672
  end