Class: Wavify::Audio

Inherits:
Object
  • Object
show all
Defined in:
lib/wavify/audio.rb,
sig/audio.rbs

Overview

High-level immutable audio object backed by a Core::SampleBuffer.

Most processing methods return a new instance and expose ! variants for in-place replacement of the internal buffer.

Constant Summary collapse

MIX_STRATEGIES =

Supported policies for summing multiple sources.

%i[none clip normalize headroom soft_limit].freeze
MIX_ALIGNMENTS =

Supported timeline alignment modes for sources of different lengths.

%i[start center end].freeze
NORMALIZE_MODES =

Supported normalization reference measurements.

%i[peak rms lufs].freeze
FADE_CURVES =

Supported fade interpolation curves.

%i[linear exp log].freeze
SOFT_LIMIT_THRESHOLD =

Knee threshold used by the built-in soft-limit mix policy.

0.8
MAX_REPEAT_FRAMES =

Maximum number of frames allowed by eager repeat.

50_000_000
MAX_REPEAT_BYTES =

Maximum estimated allocation allowed by eager repeat.

512 * 1024 * 1024

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(buffer) ⇒ Audio

Returns a new instance of Audio.

Parameters:

Raises:



248
249
250
251
252
# File 'lib/wavify/audio.rb', line 248

def initialize(buffer)
  raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)

  @buffer = buffer
end

Instance Attribute Details

#bufferCore::SampleBuffer (readonly)

Returns the value of attribute buffer.

Returns:



11
12
13
# File 'lib/wavify/audio.rb', line 11

def buffer
  @buffer
end

Class Method Details

.infoHash[Symbol, untyped]

Parameters:

  • path_or_io (String, IO)
  • format: (Core::Format, nil)
  • codec_options: (Hash[Symbol, untyped], nil)
  • strict: (Boolean)
  • filename: (String, nil)

Returns:

  • (Hash[Symbol, untyped])


7
# File 'sig/audio.rbs', line 7

def self.info: (String | IO path_or_io, ?format: Core::Format?, ?codec_options: Hash[Symbol, untyped]?, ?strict: bool, ?filename: String?) -> Hash[Symbol, untyped]

.metadata(path_or_io, format: nil, codec_options: nil, strict: false, filename: nil) ⇒ Hash

Reads audio metadata without decoding the full payload when the codec supports it.

Parameters:

  • path_or_io (String, IO)
  • format (Core::Format, nil) (defaults to: nil)

    required for raw PCM/float input

  • codec_options (Hash) (defaults to: nil)

    codec-specific options forwarded to .metadata

  • strict (Boolean) (defaults to: false)

    verify extension and magic-byte codec agreement

  • filename (String, nil) (defaults to: nil)

    optional filename hint for IO inputs

  • format: (Core::Format, nil) (defaults to: nil)
  • codec_options: (Hash[Symbol, untyped], nil) (defaults to: nil)
  • strict: (Boolean) (defaults to: false)
  • filename: (String, nil) (defaults to: nil)

Returns:

  • (Hash)

Raises:



51
52
53
54
55
56
57
58
59
60
61
# File 'lib/wavify/audio.rb', line 51

def self.(path_or_io, format: nil, codec_options: nil, strict: false, filename: nil)
  codec = Codecs::Registry.detect_for_read(path_or_io, strict: strict, filename: filename)
  options = normalize_codec_options!(codec_options)
  return codec.(path_or_io, format: format, **options) if codec == Codecs::Raw

   = codec.(path_or_io, **options)
  return  unless format
  raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)

  (, format)
end

.mix(*audios, strategy: :clip, gains: nil, align: :start, format: nil, work_format: nil, headroom_smoothing: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS) ⇒ Audio

Mixes multiple audio objects using a selectable clipping policy.

Parameters:

  • audios (Array<Audio>)
  • strategy (Symbol) (defaults to: :clip)

    :none, :clip, :normalize, :headroom, or :soft_limit; :none preserves above-full-scale sums only when the output format is float

  • gains (Array<Numeric>, nil) (defaults to: nil)

    optional per-source gain in dB

  • align (Symbol) (defaults to: :start)

    :start, :center, or :end

  • format (Core::Format, nil) (defaults to: nil)

    output format, defaulting to the first source format

  • work_format (Core::Format, nil) (defaults to: nil)

    intermediate channel/sample-rate format; samples are processed as float

  • headroom_smoothing (Numeric) (defaults to: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS)

    seconds of gain smoothing around peaks

  • strategy: (Symbol) (defaults to: :clip)
  • gains: (Array[Numeric], nil) (defaults to: nil)
  • align: (Symbol) (defaults to: :start)
  • format: (Core::Format, nil) (defaults to: nil)
  • work_format: (Core::Format, nil) (defaults to: nil)
  • headroom_smoothing: (Numeric) (defaults to: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS)

Returns:

Raises:



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/wavify/audio.rb', line 145

def self.mix(*audios, strategy: :clip, gains: nil, align: :start, format: nil, work_format: nil,
             headroom_smoothing: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS)
  raise InvalidParameterError, "at least one Audio is required" if audios.empty?
  raise InvalidParameterError, "all arguments must be Audio instances" unless audios.all? { |audio| audio.is_a?(self) }

  mix_strategy = normalize_mix_strategy!(strategy)
  mix_gains = normalize_mix_gains!(gains, audios.length)
  mix_alignment = normalize_mix_alignment!(align)
  target_format = format || audios.first.format
  raise InvalidParameterError, "format must be Core::Format" unless target_format.is_a?(Core::Format)
  if work_format && !work_format.is_a?(Core::Format)
    raise InvalidParameterError, "work_format must be Core::Format"
  end
  if !format && !work_format && audios.map { |audio| audio.format.sample_rate }.uniq.length > 1
    raise InvalidParameterError, "different sample rates require an explicit format or work_format"
  end

  workspace_format = work_format || target_format.with(sample_format: :float, bit_depth: 32)
  workspace_format = workspace_format.with(sample_format: :float, bit_depth: 32)
  converted = audios.map { |audio| audio.buffer.convert(workspace_format) }
  max_frames = converted.map(&:sample_frame_count).max || 0
  channels = workspace_format.channels
  mixed = Array.new(max_frames * channels, 0.0)

  converted.each_with_index do |buffer, audio_index|
    gain_factor = db_to_amplitude(mix_gains.fetch(audio_index))
    sample_offset = mix_alignment_offset(mix_alignment, max_frames, buffer.sample_frame_count) * channels
    buffer.samples.each_with_index do |sample, index|
      target_index = sample_offset + index
      mixed[target_index] += sample * gain_factor
    end
  end

  apply_mix_strategy!(mixed, mix_strategy, format: workspace_format, headroom_smoothing: headroom_smoothing)
  new(Core::SampleBuffer.new(mixed, workspace_format).convert(target_format))
end

.read(path_or_io, format: nil, codec_options: nil, strict: false, filename: nil) ⇒ Audio

Reads audio from a file path using codec auto-detection.

Parameters:

  • path_or_io (String, IO)
  • format (Core::Format, nil) (defaults to: nil)

    optional target format to convert into

  • codec_options (Hash) (defaults to: nil)

    codec-specific options forwarded to .read

  • strict (Boolean) (defaults to: false)

    verify extension and magic-byte codec agreement

  • filename (String, nil) (defaults to: nil)

    optional filename hint for IO inputs

  • format: (Core::Format, nil) (defaults to: nil)
  • codec_options: (Hash[Symbol, untyped], nil) (defaults to: nil)
  • strict: (Boolean) (defaults to: false)
  • filename: (String, nil) (defaults to: nil)

Returns:



36
37
38
39
40
41
# File 'lib/wavify/audio.rb', line 36

def self.read(path_or_io, format: nil, codec_options: nil, strict: false, filename: nil)
  codec = Codecs::Registry.detect_for_read(path_or_io, strict: strict, filename: filename)
  options = normalize_codec_options!(codec_options)

  new(codec.read(path_or_io, format: format, **options))
end

.silence(duration_seconds, format:) ⇒ Audio

Builds silent audio in the requested format.

Parameters:

Returns:

Raises:



234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/wavify/audio.rb', line 234

def self.silence(duration_seconds, format:)
  seconds = duration_seconds.is_a?(Core::Duration) ? duration_seconds.total_seconds : duration_seconds
  unless seconds.is_a?(Numeric) && seconds.respond_to?(:finite?) && seconds.finite? && seconds >= 0
    raise InvalidParameterError, "duration_seconds must be a non-negative finite Numeric or Core::Duration: #{duration_seconds.inspect}"
  end
  raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)

  frame_count = (seconds.to_f * format.sample_rate).round
  default_sample = format.sample_format == :float ? 0.0 : 0
  samples = Array.new(frame_count * format.channels, default_sample)
  new(Core::SampleBuffer.new(samples, format))
end

.stream(path_or_io, chunk_size: 4096, format: nil, codec_options: nil, strict: false, filename: nil) {|stream| ... } ⇒ Core::Stream

Creates a streaming processing pipeline for an input path/IO.

Parameters:

  • path_or_io (String, IO)
  • chunk_size (Integer) (defaults to: 4096)

    chunk size in frames

  • format (Core::Format, nil) (defaults to: nil)

    optional source format override

  • codec_options (Hash) (defaults to: nil)

    codec-specific options forwarded to .stream_read

  • strict (Boolean) (defaults to: false)

    verify extension and magic-byte codec agreement

  • filename (String, nil) (defaults to: nil)

    optional filename hint for IO inputs

  • chunk_size: (Integer) (defaults to: 4096)
  • format: (Core::Format, nil) (defaults to: nil)
  • codec_options: (Hash[Symbol, untyped], nil) (defaults to: nil)
  • strict: (Boolean) (defaults to: false)
  • filename: (String, nil) (defaults to: nil)

Yields:

Returns:



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/wavify/audio.rb', line 191

def self.stream(path_or_io, chunk_size: 4096, format: nil, codec_options: nil, strict: false, filename: nil)
  codec = Codecs::Registry.detect_for_read(path_or_io, strict: strict, filename: filename)
  options = normalize_codec_options!(codec_options)
  source_format = format
  if codec == Codecs::Raw && !source_format.is_a?(Core::Format)
    raise InvalidFormatError, "format is required for raw stream input"
  end
  options = options.merge(format: source_format) if codec == Codecs::Raw

  stream = Core::Stream.new(
    path_or_io,
    codec: codec,
    format: source_format,
    chunk_size: chunk_size,
    codec_read_options: options
  )
  return stream unless block_given?

  yield stream
  stream
end

.tone(frequency:, duration:, format:, waveform: :sine, **oscillator_options) ⇒ Audio

Generates a tone using the built-in oscillator.

Parameters:

  • frequency (Numeric)

    oscillator frequency in Hz

  • duration (Numeric)

    duration in seconds

  • format (Core::Format)

    output format

  • waveform (Symbol) (defaults to: :sine)

    :sine, :square, :triangle, :sawtooth, :pulse, :white_noise

  • frequency: (Numeric)
  • duration: (Numeric, Core::Duration)
  • format: (Core::Format)
  • waveform: (Symbol) (defaults to: :sine)
  • oscillator_options (Object)

Returns:



220
221
222
223
224
225
226
227
# File 'lib/wavify/audio.rb', line 220

def self.tone(frequency:, duration:, format:, waveform: :sine, **oscillator_options)
  oscillator = DSP::Oscillator.new(
    waveform: waveform,
    frequency: frequency,
    **oscillator_options
  )
  new(oscillator.generate(duration, format: format))
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Value equality based on the underlying immutable sample buffer.

Parameters:

  • other (Object)

Returns:

  • (Boolean)


255
256
257
# File 'lib/wavify/audio.rb', line 255

def ==(other)
  other.is_a?(Audio) && @buffer == other.buffer
end

#apply(effect) ⇒ Audio

Applies an effect/processor object to the audio buffer.

Accepted interfaces: #process, #call, or #apply.

Parameters:

  • effect (Object)

Returns:



785
786
787
788
789
# File 'lib/wavify/audio.rb', line 785

def apply(effect)
  processed = DSP::Processor.render(effect, @buffer)

  self.class.new(processed)
end

#apply!(effect) ⇒ Audio

In-place variant of #apply.

Parameters:

  • effect (Object)

Returns:



795
796
797
798
# File 'lib/wavify/audio.rb', line 795

def apply!(effect)
  replace_buffer!(apply(effect).buffer)
  self
end

#balance(position) ⇒ Audio

Adjusts the relative level of existing stereo channels without moving content between them.

Parameters:

  • position (Numeric)

Returns:

Raises:



745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
# File 'lib/wavify/audio.rb', line 745

def balance(position)
  validate_pan_position!(position)
  raise InvalidParameterError, "balance requires stereo input" unless channels == 2

  transform_samples do |samples, _format|
    left_gain = position.positive? ? Math.cos(position * Math::PI / 2.0) : 1.0
    right_gain = position.negative? ? Math.cos(position.abs * Math::PI / 2.0) : 1.0
    samples.each_slice(2).with_index do |(left, right), frame_index|
      base = frame_index * 2
      samples[base] = left * left_gain
      samples[base + 1] = right * right_gain
    end
    samples
  end
end

#bit_depthInteger

Returns:

  • (Integer)


310
311
312
# File 'lib/wavify/audio.rb', line 310

def bit_depth
  format.bit_depth
end

#channelsInteger

Returns:

  • (Integer)


300
301
302
# File 'lib/wavify/audio.rb', line 300

def channels
  format.channels
end

#clipped?(consecutive_frames: 2) ⇒ Boolean

Detects likely digital clipping by looking for consecutive full-scale samples on the same channel. A single legal full-scale PCM sample is not sufficient evidence that the source was clipped.

Parameters:

  • consecutive_frames (Integer) (defaults to: 2)

    full-scale frames required per channel

  • consecutive_frames: (Integer) (defaults to: 2)

Returns:

  • (Boolean)


935
936
937
938
939
940
941
942
# File 'lib/wavify/audio.rb', line 935

def clipped?(consecutive_frames: 2)
  unless consecutive_frames.is_a?(Integer) && consecutive_frames.positive?
    raise InvalidParameterError, "consecutive_frames must be a positive Integer"
  end

  float_buffer = @buffer.convert(float_work_format(@buffer.format))
  clipped_samples?(float_buffer.samples, float_buffer.format.channels, consecutive_frames: consecutive_frames)
end

#concat(other) ⇒ Audio Also known as: append, +

Concatenates another audio clip after this one.

Parameters:

Returns:



411
412
413
414
# File 'lib/wavify/audio.rb', line 411

def concat(other)
  validate_audio!(other, :other)
  self.class.new(@buffer.concat(other.buffer))
end

#convert(new_format, dither: false, dither_seed: nil, resampler: :linear) ⇒ Audio

Converts to a new format/channels.

Parameters:

  • new_format (Core::Format)
  • dither (Boolean) (defaults to: false)

    add TPDF dither when converting to PCM

  • dither_seed (Integer, nil) (defaults to: nil)

    deterministic seed for dither noise

  • dither: (Boolean) (defaults to: false)
  • dither_seed: (Integer, nil) (defaults to: nil)
  • resampler: (Symbol) (defaults to: :linear)

Returns:



345
346
347
# File 'lib/wavify/audio.rb', line 345

def convert(new_format, dither: false, dither_seed: nil, resampler: :linear)
  self.class.new(@buffer.convert(new_format, dither: dither, dither_seed: dither_seed, resampler: resampler))
end

#crop(start:, duration:) ⇒ Audio

Crops audio from a start time for a duration.

Parameters:

Returns:



401
402
403
404
405
# File 'lib/wavify/audio.rb', line 401

def crop(start:, duration:)
  start_frame = coerce_time_to_frame(start, upper_bound: @buffer.sample_frame_count)
  frame_length = coerce_duration_to_frame(duration)
  self.class.new(@buffer.slice(start_frame, [frame_length, @buffer.sample_frame_count - start_frame].min))
end

#crossfade(other, duration:) ⇒ Audio

Crossfades this audio into another clip.

Parameters:

Returns:

Raises:



468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
# File 'lib/wavify/audio.rb', line 468

def crossfade(other, duration:)
  validate_audio!(other, :other)
  rhs = other.convert(format)
  overlap_frames = coerce_duration_to_frame(duration)
  max_overlap = [sample_frame_count, rhs.sample_frame_count].min
  raise InvalidParameterError, "duration is longer than one of the clips" if overlap_frames > max_overlap

  seconds = overlap_frames.to_f / sample_rate
  left_head = self.class.new(@buffer.slice(0, sample_frame_count - overlap_frames))
  left_tail = self.class.new(@buffer.slice(sample_frame_count - overlap_frames, overlap_frames)).fade_out(seconds)
  right_head = self.class.new(rhs.buffer.slice(0, overlap_frames)).fade_in(seconds)
  right_tail = self.class.new(rhs.buffer.slice(overlap_frames, rhs.sample_frame_count - overlap_frames))
  middle = self.class.mix(left_tail, right_head, strategy: :clip)

  left_head.concat(middle).concat(right_tail)
end

#dc_offsetFloat

Returns average sample offset in normalized float space.

Returns:

  • (Float)

    average sample offset in normalized float space



945
946
947
948
949
950
# File 'lib/wavify/audio.rb', line 945

def dc_offset
  float_buffer = @buffer.convert(float_work_format(@buffer.format))
  return 0.0 if float_buffer.samples.empty?

  float_buffer.samples.sum / float_buffer.samples.length.to_f
end

#dc_offsetsArray[Float]

Returns the mean offset for each channel.

Returns:

  • (Array[Float])


953
954
955
956
957
958
959
960
961
# File 'lib/wavify/audio.rb', line 953

def dc_offsets
  float_buffer = @buffer.convert(float_work_format(@buffer.format))
  channels = float_buffer.format.channels
  return Array.new(channels, 0.0) if float_buffer.sample_frame_count.zero?

  sums = Array.new(channels, 0.0)
  float_buffer.each_frame_sample { |sample, _frame, channel| sums[channel] += sample }
  sums.map { |sum| sum / float_buffer.sample_frame_count.to_f }
end

#durationCore::Duration

Returns:



290
291
292
# File 'lib/wavify/audio.rb', line 290

def duration
  @buffer.duration
end

#each_frameAudio #each_frameEnumerator[Array[Numeric], Audio]

Enumerates sample frames.

Overloads:

  • #each_frameAudio

    Returns:

  • #each_frameEnumerator[Array[Numeric], Audio]

    Returns:

    • (Enumerator[Array[Numeric], Audio])

Yields:

  • (frame, frame_index)

Yield Parameters:

  • frame (Array<Numeric>)
  • frame_index (Integer)

Returns:



330
331
332
333
334
335
336
337
# File 'lib/wavify/audio.rb', line 330

def each_frame
  return enum_for(:each_frame) unless block_given?

  @buffer.frame_view.each.with_index do |frame, frame_index|
    yield frame, frame_index
  end
  self
end

#fade(seconds, type:, curve: :linear) ⇒ Audio

Applies a fade in either direction.

Parameters:

  • seconds (Numeric, Core::Duration)
  • type (Symbol)

    :in or :out

  • curve (Symbol) (defaults to: :linear)

    :linear, :exp, or :log

  • type: (Symbol)
  • curve: (Symbol) (defaults to: :linear)

Returns:



710
711
712
# File 'lib/wavify/audio.rb', line 710

def fade(seconds, type:, curve: :linear)
  apply_fade(seconds: seconds, mode: type, curve: curve)
end

#fade_in(seconds, curve: :linear) ⇒ Audio

Applies a fade-in.

Parameters:

  • seconds (Numeric, Core::Duration)
  • curve (Symbol) (defaults to: :linear)

    :linear, :exp, or :log

  • curve: (Symbol) (defaults to: :linear)

Returns:



671
672
673
# File 'lib/wavify/audio.rb', line 671

def fade_in(seconds, curve: :linear)
  apply_fade(seconds: seconds, mode: :in, curve: curve)
end

#fade_in!(seconds, curve: :linear) ⇒ Audio

In-place variant of #fade_in.

Parameters:

  • seconds (Numeric, Core::Duration)
  • curve (Symbol) (defaults to: :linear)
  • curve: (Symbol) (defaults to: :linear)

Returns:



680
681
682
683
# File 'lib/wavify/audio.rb', line 680

def fade_in!(seconds, curve: :linear)
  replace_buffer!(fade_in(seconds, curve: curve).buffer)
  self
end

#fade_out(seconds, curve: :linear) ⇒ Audio

Applies a fade-out.

Parameters:

  • seconds (Numeric, Core::Duration)
  • curve (Symbol) (defaults to: :linear)

    :linear, :exp, or :log

  • curve: (Symbol) (defaults to: :linear)

Returns:



690
691
692
# File 'lib/wavify/audio.rb', line 690

def fade_out(seconds, curve: :linear)
  apply_fade(seconds: seconds, mode: :out, curve: curve)
end

#fade_out!(seconds, curve: :linear) ⇒ Audio

In-place variant of #fade_out.

Parameters:

  • seconds (Numeric, Core::Duration)
  • curve (Symbol) (defaults to: :linear)
  • curve: (Symbol) (defaults to: :linear)

Returns:



699
700
701
702
# File 'lib/wavify/audio.rb', line 699

def fade_out!(seconds, curve: :linear)
  replace_buffer!(fade_out(seconds, curve: curve).buffer)
  self
end

#formatCore::Format

Returns:



285
286
287
# File 'lib/wavify/audio.rb', line 285

def format
  @buffer.format
end

#framesArray<Array<Numeric>>

Returns sample frames.

Returns:

  • (Array<Array<Numeric>>)

    sample frames



320
321
322
# File 'lib/wavify/audio.rb', line 320

def frames
  @buffer.frame_view.to_a
end

#gain(db) ⇒ Audio

Applies linear gain in decibels.

Parameters:

  • db (Numeric)

Returns:



577
578
579
580
581
582
583
# File 'lib/wavify/audio.rb', line 577

def gain(db)
  db = validate_finite_numeric!(db, :db)
  factor = 10.0**(db / 20.0)
  transform_samples do |samples, _format|
    samples.map! { |sample| sample * factor }
  end
end

#gain!(db) ⇒ Audio

In-place variant of #gain.

Parameters:

  • db (Numeric)

Returns:



589
590
591
592
# File 'lib/wavify/audio.rb', line 589

def gain!(db)
  replace_buffer!(gain(db).buffer)
  self
end

#hashInteger

Returns:

  • (Integer)


261
262
263
# File 'lib/wavify/audio.rb', line 261

def hash
  @buffer.hash
end

#insert_silence(at:, duration:) ⇒ Audio

Inserts silence at a time offset.

Parameters:

Returns:



506
507
508
509
# File 'lib/wavify/audio.rb', line 506

def insert_silence(at:, duration:)
  left, right = split(at: at)
  left.concat(self.class.silence(coerce_seconds(duration), format: format)).concat(right)
end

#inspectString

Returns:

  • (String)


994
995
996
# File 'lib/wavify/audio.rb', line 994

def inspect
  "#<#{self.class.name} #{human_sample_rate} #{human_channels} #{format.bit_depth}-bit #{format.sample_format} #{Kernel.format('%.3fs', duration.total_seconds)}>"
end

#lufsFloat

Returns BS.1770 integrated loudness in LUFS.

Returns:

  • (Float)

    BS.1770 integrated loudness in LUFS



888
889
890
891
# File 'lib/wavify/audio.rb', line 888

def lufs
  float_buffer = @buffer.convert(float_work_format(@buffer.format))
  integrated_loudness(float_buffer.samples, float_buffer.format)
end

#map_frames {|frame, frame_index| ... } ⇒ Enumerator, Audio

Maps normalized float frames and returns a new audio object in the original format.

Yields:

  • (frame, frame_index)

Yield Parameters:

  • frame (Array<Float>)
  • frame_index (Integer)

Returns:



822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
# File 'lib/wavify/audio.rb', line 822

def map_frames
  return enum_for(:map_frames) unless block_given?

  transform_samples do |samples, work_format|
    mapped = []
    samples.each_slice(work_format.channels).with_index do |frame, frame_index|
      output = yield(frame.dup, frame_index)
      unless output.is_a?(Array) && output.length == work_format.channels
        raise InvalidParameterError, "map_frames block must return an Array with #{work_format.channels} samples"
      end

      mapped.concat(output.map.with_index do |sample, channel_index|
        validate_mapped_sample!(sample, "frame #{frame_index}, channel #{channel_index}")
      end)
    end
    mapped
  end
end

#map_samples {|sample, sample_index| ... } ⇒ Enumerator, Audio

Maps normalized float samples and returns a new audio object in the original format.

Yields:

  • (sample, sample_index)

Yield Parameters:

  • sample (Float)
  • sample_index (Integer)

Returns:



806
807
808
809
810
811
812
813
814
# File 'lib/wavify/audio.rb', line 806

def map_samples
  return enum_for(:map_samples) unless block_given?

  transform_samples do |samples, _format|
    samples.map!.with_index do |sample, sample_index|
      validate_mapped_sample!(yield(sample, sample_index), "sample #{sample_index}")
    end
  end
end

#normalize(target_db: 0.0, mode: :peak) ⇒ Audio

Scales audio so the peak amplitude reaches the target dBFS.

Parameters:

  • target_db (Numeric) (defaults to: 0.0)
  • target_db: (Numeric) (defaults to: 0.0)
  • mode: (Symbol) (defaults to: :peak)

Returns:



598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
# File 'lib/wavify/audio.rb', line 598

def normalize(target_db: 0.0, mode: :peak)
  normalize_mode = self.class.send(:normalize_mode!, mode)
  unless target_db.is_a?(Numeric) && target_db.respond_to?(:finite?) && target_db.finite?
    raise InvalidParameterError, "target_db must be a finite Numeric"
  end
  if normalize_mode == :peak && target_db.positive?
    raise InvalidParameterError, "peak target_db must be <= 0 dBFS"
  end

  transform_samples do |samples, work_format|
    if normalize_mode == :lufs
      current_lufs = integrated_loudness(samples, work_format)
      next samples unless current_lufs.finite?

      factor = 10.0**((target_db.to_f - current_lufs) / 20.0)
      next samples.map! { |sample| sample * factor }
    end

    current = normalize_reference_amplitude(samples, normalize_mode)
    next samples if current.zero?

    target = 10.0**(target_db.to_f / 20.0)
    factor = target / current
    samples.map! { |sample| sample * factor }
  end
end

#normalize!(target_db: 0.0, mode: :peak) ⇒ Audio

In-place variant of #normalize.

Parameters:

  • target_db (Numeric) (defaults to: 0.0)
  • target_db: (Numeric) (defaults to: 0.0)
  • mode: (Symbol) (defaults to: :peak)

Returns:



629
630
631
632
# File 'lib/wavify/audio.rb', line 629

def normalize!(target_db: 0.0, mode: :peak)
  replace_buffer!(normalize(target_db: target_db, mode: mode).buffer)
  self
end

#overlay(other, at:, strategy: :clip, headroom_smoothing: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS) ⇒ Audio

Overlays another audio clip at a time offset.

Parameters:

  • other (Audio)
  • at (Numeric, Core::Duration)
  • strategy (Symbol) (defaults to: :clip)

    mix clipping policy

  • headroom_smoothing (Numeric) (defaults to: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS)

    seconds of gain smoothing around peaks

  • at: (Numeric, Core::Duration)
  • strategy: (Symbol) (defaults to: :clip)
  • headroom_smoothing: (Numeric) (defaults to: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS)

Returns:



435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
# File 'lib/wavify/audio.rb', line 435

def overlay(other, at:, strategy: :clip, headroom_smoothing: DSP::Headroom::DEFAULT_SMOOTHING_SECONDS)
  validate_audio!(other, :other)
  start_frame = coerce_time_to_frame(at, upper_bound: nil)
  work_format = float_work_format(format)
  base = @buffer.convert(work_format)
  overlay_buffer = other.buffer.convert(work_format)
  channels = work_format.channels
  mixed_length = [base.samples.length, (start_frame * channels) + overlay_buffer.samples.length].max
  mixed = Array.new(mixed_length, 0.0)
  base.samples.each_with_index do |sample, index|
    mixed[index] += sample
  end
  offset = start_frame * channels
  overlay_buffer.samples.each_with_index do |sample, index|
    target_index = offset + index
    mixed[target_index] += sample
  end

  self.class.send(
    :apply_mix_strategy!,
    mixed,
    self.class.send(:normalize_mix_strategy!, strategy),
    format: work_format,
    headroom_smoothing: headroom_smoothing
  )
  self.class.new(Core::SampleBuffer.new(mixed, work_format).convert(format))
end

#pad_end(seconds) ⇒ Audio

Adds silence after the audio.

Parameters:

Returns:



497
498
499
# File 'lib/wavify/audio.rb', line 497

def pad_end(seconds)
  concat(self.class.silence(coerce_seconds(seconds), format: format))
end

#pad_start(seconds) ⇒ Audio

Adds silence before the audio.

Parameters:

Returns:



489
490
491
# File 'lib/wavify/audio.rb', line 489

def pad_start(seconds)
  self.class.silence(coerce_seconds(seconds), format: format).concat(self)
end

#pan(position) ⇒ Audio

Constant-power pan for a mono source.

Parameters:

  • position (Numeric)

    -1.0 (left) to 1.0 (right)

Returns:

Raises:



718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
# File 'lib/wavify/audio.rb', line 718

def pan(position)
  validate_pan_position!(position)
  raise InvalidParameterError, "pan requires mono input; use #balance for stereo" unless @buffer.format.channels == 1

  source_format = @buffer.format.with(channels: 2)

  transform_samples(target_format: source_format) do |samples, _format|
    left_gain, right_gain = constant_power_pan_gains(position.to_f)
    samples.each_slice(2).with_index do |(left, right), frame_index|
      base = frame_index * 2
      samples[base] = left * left_gain
      samples[base + 1] = right * right_gain
    end
    samples
  end
end

#pan!(position) ⇒ Audio

In-place variant of #pan.

Parameters:

  • position (Numeric)

Returns:



739
740
741
742
# File 'lib/wavify/audio.rb', line 739

def pan!(position)
  replace_buffer!(pan(position).buffer)
  self
end

#peak_dbfsFloat

Returns peak amplitude in dBFS.

Returns:

  • (Float)

    peak amplitude in dBFS



870
871
872
# File 'lib/wavify/audio.rb', line 870

def peak_dbfs
  sample_peak_dbfs
end

#prepend(other) ⇒ Audio

Prepends another audio clip before this one.

Parameters:

Returns:



423
424
425
426
# File 'lib/wavify/audio.rb', line 423

def prepend(other)
  validate_audio!(other, :other)
  other.concat(self)
end

#remove_dc_offsetAudio

Returns:



964
965
966
967
# File 'lib/wavify/audio.rb', line 964

def remove_dc_offset
  offsets = dc_offsets
  map_samples { |sample, index| sample - offsets.fetch(index % channels) }
end

#repeat(times:) ⇒ Audio

Repeats the audio content.

Parameters:

  • times (Integer)

    repetition count

  • times: (Integer)

Returns:

Raises:



515
516
517
518
519
520
521
522
523
524
525
526
527
528
# File 'lib/wavify/audio.rb', line 515

def repeat(times:)
  raise InvalidParameterError, "times must be a non-negative Integer" unless times.is_a?(Integer) && times >= 0

  return self.class.new(Core::SampleBuffer.new([], @buffer.format)) if times.zero?

  repeated_frames = sample_frame_count * times
  repeated_bytes = repeated_frames * format.block_align
  if repeated_frames > MAX_REPEAT_FRAMES || repeated_bytes > MAX_REPEAT_BYTES
    raise InvalidParameterError,
          "repeat output exceeds limits (#{repeated_frames} frames, #{repeated_bytes} bytes); use #repeat_chunks"
  end

  self.class.new(Core::SampleBuffer.new(@buffer.samples * times, @buffer.format))
end

#repeat!(times:) ⇒ Audio

In-place variant of #repeat.

Parameters:

  • times (Integer)
  • times: (Integer)

Returns:



553
554
555
556
# File 'lib/wavify/audio.rb', line 553

def repeat!(times:)
  replace_buffer!(repeat(times: times).buffer)
  self
end

#repeat_chunks(times:, chunk_frames: 4_096) ⇒ Enumerator[Audio, nil]

Lazily yields repeated audio in bounded chunks.

Parameters:

  • times: (Integer)
  • chunk_frames: (Integer) (defaults to: 4_096)

Returns:

  • (Enumerator[Audio, nil])

Raises:



531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
# File 'lib/wavify/audio.rb', line 531

def repeat_chunks(times:, chunk_frames: 4_096)
  raise InvalidParameterError, "times must be a non-negative Integer" unless times.is_a?(Integer) && times >= 0
  unless chunk_frames.is_a?(Integer) && chunk_frames.positive?
    raise InvalidParameterError, "chunk_frames must be a positive Integer"
  end

  Enumerator.new do |yielder|
    times.times do
      offset = 0
      while offset < sample_frame_count
        length = [chunk_frames, sample_frame_count - offset].min
        yielder << @buffer.slice(offset, length)
        offset += length
      end
    end
  end
end

#resample(sample_rate:, resampler: :linear) ⇒ Audio

Converts to another sample rate.

Parameters:

  • sample_rate (Integer)
  • sample_rate: (Integer)
  • resampler: (Symbol) (defaults to: :linear)

Returns:



353
354
355
# File 'lib/wavify/audio.rb', line 353

def resample(sample_rate:, resampler: :linear)
  convert(format.with(sample_rate: sample_rate), resampler: resampler)
end

#reverseAudio

Reverses sample frame order.

Returns:



561
562
563
# File 'lib/wavify/audio.rb', line 561

def reverse
  self.class.new(@buffer.reverse)
end

#reverse!Audio

In-place variant of #reverse.

Returns:



568
569
570
571
# File 'lib/wavify/audio.rb', line 568

def reverse!
  replace_buffer!(reverse.buffer)
  self
end

#rms_amplitudeFloat

Returns RMS amplitude in float working space.

Returns:

  • (Float)

    0.0..1.0



861
862
863
864
865
866
867
# File 'lib/wavify/audio.rb', line 861

def rms_amplitude
  float_buffer = @buffer.convert(float_work_format(@buffer.format))
  return 0.0 if float_buffer.samples.empty?

  square_sum = float_buffer.samples.sum { |sample| sample * sample }
  Math.sqrt(square_sum / float_buffer.samples.length)
end

#rms_dbfsFloat

Returns RMS amplitude in dBFS.

Returns:

  • (Float)

    RMS amplitude in dBFS



883
884
885
# File 'lib/wavify/audio.rb', line 883

def rms_dbfs
  amplitude_to_dbfs(rms_amplitude)
end

#sample_frame_countInteger

Returns frame count.

Returns:

  • (Integer)

    frame count



295
296
297
# File 'lib/wavify/audio.rb', line 295

def sample_frame_count
  @buffer.sample_frame_count
end

#sample_peak_amplitudeFloat Also known as: peak_amplitude

Returns the absolute peak amplitude in float working space.

Returns:

  • (Float)

    0.0..1.0



844
845
846
847
# File 'lib/wavify/audio.rb', line 844

def sample_peak_amplitude
  float_buffer = @buffer.convert(float_work_format(@buffer.format))
  float_buffer.samples.map(&:abs).max || 0.0
end

#sample_peak_dbfsFloat

Returns:

  • (Float)


874
875
876
# File 'lib/wavify/audio.rb', line 874

def sample_peak_dbfs
  amplitude_to_dbfs(sample_peak_amplitude)
end

#sample_rateInteger

Returns:

  • (Integer)


305
306
307
# File 'lib/wavify/audio.rb', line 305

def sample_rate
  format.sample_rate
end

#silent?(threshold: 0.0) ⇒ Boolean

Parameters:

  • threshold (Numeric) (defaults to: 0.0)
  • threshold: (Numeric) (defaults to: 0.0)

Returns:

  • (Boolean)

Raises:



923
924
925
926
927
# File 'lib/wavify/audio.rb', line 923

def silent?(threshold: 0.0)
  raise InvalidParameterError, "threshold must be Numeric in 0.0..1.0" unless threshold.is_a?(Numeric) && threshold.between?(0.0, 1.0)

  peak_amplitude <= threshold
end

#slice(from:, to:) ⇒ Audio

Slices audio between two time offsets.

Parameters:

Returns:

Raises:



388
389
390
391
392
393
394
# File 'lib/wavify/audio.rb', line 388

def slice(from:, to:)
  start_frame = coerce_time_to_frame(from, upper_bound: @buffer.sample_frame_count)
  end_frame = coerce_time_to_frame(to, upper_bound: @buffer.sample_frame_count)
  raise InvalidParameterError, "to must be greater than or equal to from" if end_frame < start_frame

  self.class.new(@buffer.slice(start_frame, end_frame - start_frame))
end

#split(at:) ⇒ Array<Audio>

Splits the audio into two clips at a time offset.

Parameters:

Returns:

  • (Array<Audio>)

    [left, right]



375
376
377
378
379
380
381
# File 'lib/wavify/audio.rb', line 375

def split(at:)
  split_frame = coerce_split_point_to_frame(at)
  left = @buffer.slice(0, split_frame)
  right = @buffer.slice(split_frame, [@buffer.sample_frame_count - split_frame, 0].max)

  [self.class.new(left), self.class.new(right)]
end

#statsHash

Returns basic audio statistics.

Returns:

  • (Hash)

    basic audio statistics



894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
# File 'lib/wavify/audio.rb', line 894

def stats
  float_buffer = @buffer.convert(float_work_format(@buffer.format))
  samples = float_buffer.samples
  accumulated = accumulate_statistics(samples, float_buffer.format.channels, consecutive_frames: 2)
  peak = accumulated.fetch(:peak)
  rms = accumulated.fetch(:rms)
  true_peak = DSP::LoudnessMeter.true_peak(float_buffer, format: float_buffer.format)
  {
    format: format,
    duration: duration,
    sample_frame_count: sample_frame_count,
    peak_amplitude: peak,
    sample_peak_amplitude: peak,
    true_peak_amplitude: true_peak,
    rms_amplitude: rms,
    peak_dbfs: amplitude_to_dbfs(peak),
    sample_peak_dbfs: amplitude_to_dbfs(peak),
    true_peak_dbfs: amplitude_to_dbfs(true_peak),
    rms_dbfs: amplitude_to_dbfs(rms),
    lufs: integrated_loudness(samples, float_buffer.format),
    clipped: accumulated.fetch(:clipped),
    silent: peak.zero?,
    dc_offsets: accumulated.fetch(:dc_offsets),
    zero_crossing_rate: accumulated.fetch(:zero_crossing_rate)
  }
end

#stereo_rotate(position) ⇒ Audio

Rotates a stereo image, transferring energy between left and right.

Parameters:

  • position (Numeric)

Returns:

Raises:



762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
# File 'lib/wavify/audio.rb', line 762

def stereo_rotate(position)
  validate_pan_position!(position)
  raise InvalidParameterError, "stereo_rotate requires stereo input" unless channels == 2

  angle = position * Math::PI / 4.0
  cosine = Math.cos(angle)
  sine = Math.sin(angle)
  transform_samples do |samples, _format|
    samples.each_slice(2).with_index do |(left, right), frame_index|
      base = frame_index * 2
      samples[base] = (left * cosine) - (right * sine)
      samples[base + 1] = (left * sine) + (right * cosine)
    end
    samples
  end
end

#to_monoAudio

Converts to mono by downmixing channels.

Returns:



360
361
362
# File 'lib/wavify/audio.rb', line 360

def to_mono
  convert(format.with(channels: 1))
end

#to_stereoAudio

Converts to stereo by upmixing/downmixing channels.

Returns:



367
368
369
# File 'lib/wavify/audio.rb', line 367

def to_stereo
  convert(format.with(channels: 2))
end

#trim(threshold: 0.01) ⇒ Audio

Removes leading and trailing frames below a threshold.

Parameters:

  • threshold (Numeric) (defaults to: 0.01)

    amplitude threshold in 0.0..1.0

  • threshold: (Numeric) (defaults to: 0.01)

Returns:

Raises:



638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
# File 'lib/wavify/audio.rb', line 638

def trim(threshold: 0.01)
  raise InvalidParameterError, "threshold must be Numeric in 0.0..1.0" unless threshold.is_a?(Numeric) && threshold.between?(0.0, 1.0)

  float_buffer = @buffer.convert(float_work_format(@buffer.format))
  channels = float_buffer.format.channels
  first = nil
  last = nil
  float_buffer.samples.each_slice(channels).with_index do |frame, frame_index|
    next unless frame.any? { |sample| sample.abs >= threshold }

    first ||= frame_index
    last = frame_index
  end
  return self.class.new(Core::SampleBuffer.new([], @buffer.format)) unless first

  float_trimmed = float_buffer.slice(first, last - first + 1)
  self.class.new(float_trimmed.convert(@buffer.format))
end

#trim!(threshold: 0.01) ⇒ Audio

In-place variant of #trim.

Parameters:

  • threshold (Numeric) (defaults to: 0.01)
  • threshold: (Numeric) (defaults to: 0.01)

Returns:



661
662
663
664
# File 'lib/wavify/audio.rb', line 661

def trim!(threshold: 0.01)
  replace_buffer!(trim(threshold: threshold).buffer)
  self
end

#true_peak_amplitude(oversampling: DSP::LoudnessMeter::TRUE_PEAK_OVERSAMPLING) ⇒ Float

Returns oversampled inter-sample peak amplitude.

Parameters:

  • oversampling: (Integer) (defaults to: DSP::LoudnessMeter::TRUE_PEAK_OVERSAMPLING)

Returns:

  • (Float)


853
854
855
856
# File 'lib/wavify/audio.rb', line 853

def true_peak_amplitude(oversampling: DSP::LoudnessMeter::TRUE_PEAK_OVERSAMPLING)
  float_buffer = @buffer.convert(float_work_format(@buffer.format))
  DSP::LoudnessMeter.true_peak(float_buffer, format: float_buffer.format, oversampling: oversampling)
end

#true_peak_dbfs(oversampling: DSP::LoudnessMeter::TRUE_PEAK_OVERSAMPLING) ⇒ Float

Parameters:

  • oversampling: (Integer) (defaults to: DSP::LoudnessMeter::TRUE_PEAK_OVERSAMPLING)

Returns:

  • (Float)


878
879
880
# File 'lib/wavify/audio.rb', line 878

def true_peak_dbfs(oversampling: DSP::LoudnessMeter::TRUE_PEAK_OVERSAMPLING)
  amplitude_to_dbfs(true_peak_amplitude(oversampling: oversampling))
end

#with_bit_depth(value, dither: false, dither_seed: nil) ⇒ Audio

Converts to another bit depth while keeping the remaining format fields.

Parameters:

  • value (Integer)
  • dither: (Boolean) (defaults to: false)
  • dither_seed: (Integer, nil) (defaults to: nil)

Returns:



315
316
317
# File 'lib/wavify/audio.rb', line 315

def with_bit_depth(value, dither: false, dither_seed: nil)
  convert(format.with(bit_depth: value), dither: dither, dither_seed: dither_seed)
end

#write(path, format: nil, codec: nil, filename: nil, codec_options: nil, overwrite: true) ⇒ Audio

Writes the audio to a file path using codec auto-detection.

Parameters:

  • path (String, IO)
  • format (Core::Format, nil) (defaults to: nil)

    optional output format

  • codec_options (Hash) (defaults to: nil)

    codec-specific options forwarded to .write

  • codec (Symbol, Class, nil) (defaults to: nil)

    explicit output codec for generic IO targets

  • filename (String, nil) (defaults to: nil)

    filename hint used for codec selection

  • overwrite (Boolean) (defaults to: true)

    whether existing path output may be replaced

  • format: (Core::Format, nil) (defaults to: nil)
  • codec: (Symbol, untyped) (defaults to: nil)
  • filename: (String, nil) (defaults to: nil)
  • codec_options: (Hash[Symbol, untyped], nil) (defaults to: nil)
  • overwrite: (Boolean) (defaults to: true)

Returns:



274
275
276
277
278
279
280
281
282
# File 'lib/wavify/audio.rb', line 274

def write(path, format: nil, codec: nil, filename: nil, codec_options: nil, overwrite: true)
  validate_overwrite!(path, overwrite)
  output_codec = codec ? Codecs::Registry.resolve(codec) : Codecs::Registry.detect_for_write(path, filename: filename)
  options = normalize_codec_options!(codec_options)
  with_output_target(path, overwrite: overwrite) do |target|
    output_codec.write(target, @buffer, format: format || @buffer.format, **options)
  end
  self
end

#zero_crossing_rateFloat

Returns zero-crossing rate per channel stream.

Returns:

  • (Float)

    zero-crossing rate per channel stream



970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
# File 'lib/wavify/audio.rb', line 970

def zero_crossing_rate
  float_buffer = @buffer.convert(float_work_format(@buffer.format))
  channels = float_buffer.format.channels
  return 0.0 if float_buffer.sample_frame_count <= 1

  crossings = 0
  comparisons = 0
  channels.times do |channel|
    previous = nil
    float_buffer.samples.each_slice(channels) do |frame|
      current = frame.fetch(channel)
      if previous
        crossings += 1 if (previous.negative? && current >= 0.0) || (previous >= 0.0 && current.negative?)
        comparisons += 1
      end
      previous = current
    end
  end
  return 0.0 if comparisons.zero?

  crossings.to_f / comparisons
end