sound_util 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
 - data/.rspec +3 -0
 - data/.rubocop.yml +95 -0
 - data/AGENTS.md +12 -0
 - data/CHANGELOG.md +17 -0
 - data/LICENSE.txt +21 -0
 - data/README.md +158 -0
 - data/Rakefile +10 -0
 - data/exe/sound_util +7 -0
 - data/lib/sound_util/cli.rb +55 -0
 - data/lib/sound_util/codec/wav.rb +279 -0
 - data/lib/sound_util/codec.rb +106 -0
 - data/lib/sound_util/filter/_mixin.rb +26 -0
 - data/lib/sound_util/filter/combine.rb +42 -0
 - data/lib/sound_util/filter/fade.rb +47 -0
 - data/lib/sound_util/filter/gain.rb +19 -0
 - data/lib/sound_util/filter/resample.rb +77 -0
 - data/lib/sound_util/filter.rb +11 -0
 - data/lib/sound_util/generator/combine.rb +75 -0
 - data/lib/sound_util/generator/tone.rb +32 -0
 - data/lib/sound_util/generator.rb +8 -0
 - data/lib/sound_util/magic.rb +40 -0
 - data/lib/sound_util/sink/playback.rb +56 -0
 - data/lib/sound_util/sink/preview.rb +136 -0
 - data/lib/sound_util/sink.rb +8 -0
 - data/lib/sound_util/util.rb +86 -0
 - data/lib/sound_util/version.rb +5 -0
 - data/lib/sound_util/wave/buffer.rb +137 -0
 - data/lib/sound_util/wave.rb +457 -0
 - data/lib/sound_util.rb +16 -0
 - data/sig/sound_util.rbs +2 -0
 - metadata +120 -0
 
| 
         @@ -0,0 +1,106 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module SoundUtil
         
     | 
| 
      
 4 
     | 
    
         
            +
              module Codec
         
     | 
| 
      
 5 
     | 
    
         
            +
                class UnsupportedFormatError < SoundUtil::Error; end
         
     | 
| 
      
 6 
     | 
    
         
            +
             
     | 
| 
      
 7 
     | 
    
         
            +
                @encoders = []
         
     | 
| 
      
 8 
     | 
    
         
            +
                @decoders = []
         
     | 
| 
      
 9 
     | 
    
         
            +
             
     | 
| 
      
 10 
     | 
    
         
            +
                class << self
         
     | 
| 
      
 11 
     | 
    
         
            +
                  attr_reader :encoders, :decoders
         
     | 
| 
      
 12 
     | 
    
         
            +
             
     | 
| 
      
 13 
     | 
    
         
            +
                  def register_encoder(codec_const, *formats)
         
     | 
| 
      
 14 
     | 
    
         
            +
                    encoders << { codec: codec_const, formats: formats.map { |f| f.to_s.downcase } }
         
     | 
| 
      
 15 
     | 
    
         
            +
                  end
         
     | 
| 
      
 16 
     | 
    
         
            +
             
     | 
| 
      
 17 
     | 
    
         
            +
                  def register_decoder(codec_const, *formats)
         
     | 
| 
      
 18 
     | 
    
         
            +
                    decoders << { codec: codec_const, formats: formats.map { |f| f.to_s.downcase } }
         
     | 
| 
      
 19 
     | 
    
         
            +
                  end
         
     | 
| 
      
 20 
     | 
    
         
            +
             
     | 
| 
      
 21 
     | 
    
         
            +
                  def register_codec(codec_const, *formats)
         
     | 
| 
      
 22 
     | 
    
         
            +
                    register_encoder(codec_const, *formats)
         
     | 
| 
      
 23 
     | 
    
         
            +
                    register_decoder(codec_const, *formats)
         
     | 
| 
      
 24 
     | 
    
         
            +
                  end
         
     | 
| 
      
 25 
     | 
    
         
            +
             
     | 
| 
      
 26 
     | 
    
         
            +
                  def supported?(format)
         
     | 
| 
      
 27 
     | 
    
         
            +
                    fmt = format.to_s.downcase
         
     | 
| 
      
 28 
     | 
    
         
            +
                    encoders.any? { |entry| entry[:formats].include?(fmt) && codec_supported?(entry[:codec], fmt) } ||
         
     | 
| 
      
 29 
     | 
    
         
            +
                      decoders.any? { |entry| entry[:formats].include?(fmt) && codec_supported?(entry[:codec], fmt) }
         
     | 
| 
      
 30 
     | 
    
         
            +
                  end
         
     | 
| 
      
 31 
     | 
    
         
            +
             
     | 
| 
      
 32 
     | 
    
         
            +
                  def encode(format, wave, codec: nil, **kwargs)
         
     | 
| 
      
 33 
     | 
    
         
            +
                    codec = find_codec(encoders, format, codec)
         
     | 
| 
      
 34 
     | 
    
         
            +
                    codec.encode(format, wave, **kwargs)
         
     | 
| 
      
 35 
     | 
    
         
            +
                  end
         
     | 
| 
      
 36 
     | 
    
         
            +
             
     | 
| 
      
 37 
     | 
    
         
            +
                  def decode(format, data, codec: nil, **kwargs)
         
     | 
| 
      
 38 
     | 
    
         
            +
                    codec = find_codec(decoders, format, codec)
         
     | 
| 
      
 39 
     | 
    
         
            +
                    codec.decode(format, data, **kwargs)
         
     | 
| 
      
 40 
     | 
    
         
            +
                  end
         
     | 
| 
      
 41 
     | 
    
         
            +
             
     | 
| 
      
 42 
     | 
    
         
            +
                  def encode_io(format, wave, io, codec: nil, **kwargs)
         
     | 
| 
      
 43 
     | 
    
         
            +
                    codec = find_codec(encoders, format, codec)
         
     | 
| 
      
 44 
     | 
    
         
            +
                    if codec.respond_to?(:encode_io)
         
     | 
| 
      
 45 
     | 
    
         
            +
                      codec.encode_io(format, wave, io, **kwargs)
         
     | 
| 
      
 46 
     | 
    
         
            +
                    else
         
     | 
| 
      
 47 
     | 
    
         
            +
                      io << codec.encode(format, wave, **kwargs)
         
     | 
| 
      
 48 
     | 
    
         
            +
                    end
         
     | 
| 
      
 49 
     | 
    
         
            +
                  end
         
     | 
| 
      
 50 
     | 
    
         
            +
             
     | 
| 
      
 51 
     | 
    
         
            +
                  def decode_io(format, io, codec: nil, **kwargs)
         
     | 
| 
      
 52 
     | 
    
         
            +
                    codec = find_codec(decoders, format, codec)
         
     | 
| 
      
 53 
     | 
    
         
            +
                    if codec.respond_to?(:decode_io)
         
     | 
| 
      
 54 
     | 
    
         
            +
                      codec.decode_io(format, io, **kwargs)
         
     | 
| 
      
 55 
     | 
    
         
            +
                    else
         
     | 
| 
      
 56 
     | 
    
         
            +
                      codec.decode(format, io.read, **kwargs)
         
     | 
| 
      
 57 
     | 
    
         
            +
                    end
         
     | 
| 
      
 58 
     | 
    
         
            +
                  end
         
     | 
| 
      
 59 
     | 
    
         
            +
             
     | 
| 
      
 60 
     | 
    
         
            +
                  def detect(data)
         
     | 
| 
      
 61 
     | 
    
         
            +
                    Magic.detect(data)
         
     | 
| 
      
 62 
     | 
    
         
            +
                  end
         
     | 
| 
      
 63 
     | 
    
         
            +
             
     | 
| 
      
 64 
     | 
    
         
            +
                  def detect_io(io)
         
     | 
| 
      
 65 
     | 
    
         
            +
                    Magic.detect_io(io).first
         
     | 
| 
      
 66 
     | 
    
         
            +
                  end
         
     | 
| 
      
 67 
     | 
    
         
            +
             
     | 
| 
      
 68 
     | 
    
         
            +
                  private
         
     | 
| 
      
 69 
     | 
    
         
            +
             
     | 
| 
      
 70 
     | 
    
         
            +
                  def find_codec(list, format, preferred = nil)
         
     | 
| 
      
 71 
     | 
    
         
            +
                    fmt = format.to_s.downcase
         
     | 
| 
      
 72 
     | 
    
         
            +
                    if preferred
         
     | 
| 
      
 73 
     | 
    
         
            +
                      record = list.find { |entry| entry[:formats].include?(fmt) && entry[:codec].to_s == preferred.to_s }
         
     | 
| 
      
 74 
     | 
    
         
            +
                      raise UnsupportedFormatError, "unsupported format #{format}" unless record
         
     | 
| 
      
 75 
     | 
    
         
            +
             
     | 
| 
      
 76 
     | 
    
         
            +
                      codec = const_get(record[:codec])
         
     | 
| 
      
 77 
     | 
    
         
            +
                      if codec.respond_to?(:supported?) && !codec.supported?(fmt.to_sym)
         
     | 
| 
      
 78 
     | 
    
         
            +
                        raise UnsupportedFormatError, "unsupported format #{format}"
         
     | 
| 
      
 79 
     | 
    
         
            +
                      end
         
     | 
| 
      
 80 
     | 
    
         
            +
             
     | 
| 
      
 81 
     | 
    
         
            +
                      return codec
         
     | 
| 
      
 82 
     | 
    
         
            +
                    end
         
     | 
| 
      
 83 
     | 
    
         
            +
             
     | 
| 
      
 84 
     | 
    
         
            +
                    list.each do |entry|
         
     | 
| 
      
 85 
     | 
    
         
            +
                      next unless entry[:formats].include?(fmt)
         
     | 
| 
      
 86 
     | 
    
         
            +
             
     | 
| 
      
 87 
     | 
    
         
            +
                      codec = const_get(entry[:codec])
         
     | 
| 
      
 88 
     | 
    
         
            +
                      next if codec.respond_to?(:supported?) && !codec.supported?(fmt.to_sym)
         
     | 
| 
      
 89 
     | 
    
         
            +
             
     | 
| 
      
 90 
     | 
    
         
            +
                      return codec
         
     | 
| 
      
 91 
     | 
    
         
            +
                    end
         
     | 
| 
      
 92 
     | 
    
         
            +
             
     | 
| 
      
 93 
     | 
    
         
            +
                    raise UnsupportedFormatError, "unsupported format #{format}"
         
     | 
| 
      
 94 
     | 
    
         
            +
                  end
         
     | 
| 
      
 95 
     | 
    
         
            +
             
     | 
| 
      
 96 
     | 
    
         
            +
                  def codec_supported?(codec_const, fmt)
         
     | 
| 
      
 97 
     | 
    
         
            +
                    codec = const_get(codec_const)
         
     | 
| 
      
 98 
     | 
    
         
            +
                    !codec.respond_to?(:supported?) || codec.supported?(fmt.to_sym)
         
     | 
| 
      
 99 
     | 
    
         
            +
                  end
         
     | 
| 
      
 100 
     | 
    
         
            +
                end
         
     | 
| 
      
 101 
     | 
    
         
            +
             
     | 
| 
      
 102 
     | 
    
         
            +
                autoload :Wav, "sound_util/codec/wav"
         
     | 
| 
      
 103 
     | 
    
         
            +
             
     | 
| 
      
 104 
     | 
    
         
            +
                register_codec :Wav, :wav
         
     | 
| 
      
 105 
     | 
    
         
            +
              end
         
     | 
| 
      
 106 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,26 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module SoundUtil
         
     | 
| 
      
 4 
     | 
    
         
            +
              module Filter
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Mixin
         
     | 
| 
      
 6 
     | 
    
         
            +
                  def define_immutable_version(*names)
         
     | 
| 
      
 7 
     | 
    
         
            +
                    names.each do |name|
         
     | 
| 
      
 8 
     | 
    
         
            +
                      define_method(name) do |*args, **kwargs, &block|
         
     | 
| 
      
 9 
     | 
    
         
            +
                        dup.tap { |wave| wave.public_send("#{name}!", *args, **kwargs, &block) }
         
     | 
| 
      
 10 
     | 
    
         
            +
                      end
         
     | 
| 
      
 11 
     | 
    
         
            +
                    end
         
     | 
| 
      
 12 
     | 
    
         
            +
                  end
         
     | 
| 
      
 13 
     | 
    
         
            +
             
     | 
| 
      
 14 
     | 
    
         
            +
                  def define_mutable_version(*names)
         
     | 
| 
      
 15 
     | 
    
         
            +
                    names.each do |name|
         
     | 
| 
      
 16 
     | 
    
         
            +
                      define_method("#{name}!") do |*args, **kwargs, &block|
         
     | 
| 
      
 17 
     | 
    
         
            +
                        initialize_from_buffer(public_send(name, *args, **kwargs, &block).buffer)
         
     | 
| 
      
 18 
     | 
    
         
            +
                        self
         
     | 
| 
      
 19 
     | 
    
         
            +
                      end
         
     | 
| 
      
 20 
     | 
    
         
            +
                    end
         
     | 
| 
      
 21 
     | 
    
         
            +
                  end
         
     | 
| 
      
 22 
     | 
    
         
            +
             
     | 
| 
      
 23 
     | 
    
         
            +
                  module_function :define_immutable_version, :define_mutable_version
         
     | 
| 
      
 24 
     | 
    
         
            +
                end
         
     | 
| 
      
 25 
     | 
    
         
            +
              end
         
     | 
| 
      
 26 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,42 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module SoundUtil
         
     | 
| 
      
 4 
     | 
    
         
            +
              module Filter
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Combine
         
     | 
| 
      
 6 
     | 
    
         
            +
                  def append(other_wave)
         
     | 
| 
      
 7 
     | 
    
         
            +
                    self.class.generate_appended_wave(left: self, right: other_wave)
         
     | 
| 
      
 8 
     | 
    
         
            +
                  end
         
     | 
| 
      
 9 
     | 
    
         
            +
             
     | 
| 
      
 10 
     | 
    
         
            +
                  def append!(other_wave)
         
     | 
| 
      
 11 
     | 
    
         
            +
                    wave = append(other_wave)
         
     | 
| 
      
 12 
     | 
    
         
            +
                    initialize_from_buffer(wave.buffer)
         
     | 
| 
      
 13 
     | 
    
         
            +
                    self
         
     | 
| 
      
 14 
     | 
    
         
            +
                  end
         
     | 
| 
      
 15 
     | 
    
         
            +
             
     | 
| 
      
 16 
     | 
    
         
            +
                  def mix(other_wave)
         
     | 
| 
      
 17 
     | 
    
         
            +
                    self.class.generate_mixed_wave(left: self, right: other_wave)
         
     | 
| 
      
 18 
     | 
    
         
            +
                  end
         
     | 
| 
      
 19 
     | 
    
         
            +
             
     | 
| 
      
 20 
     | 
    
         
            +
                  def mix!(other_wave)
         
     | 
| 
      
 21 
     | 
    
         
            +
                    wave = mix(other_wave)
         
     | 
| 
      
 22 
     | 
    
         
            +
                    initialize_from_buffer(wave.buffer)
         
     | 
| 
      
 23 
     | 
    
         
            +
                    self
         
     | 
| 
      
 24 
     | 
    
         
            +
                  end
         
     | 
| 
      
 25 
     | 
    
         
            +
             
     | 
| 
      
 26 
     | 
    
         
            +
                  def stack_channels(other_wave)
         
     | 
| 
      
 27 
     | 
    
         
            +
                    self.class.generate_stacked_wave(primary: self, secondary: other_wave)
         
     | 
| 
      
 28 
     | 
    
         
            +
                  end
         
     | 
| 
      
 29 
     | 
    
         
            +
             
     | 
| 
      
 30 
     | 
    
         
            +
                  def stack_channels!(other_wave)
         
     | 
| 
      
 31 
     | 
    
         
            +
                    wave = stack_channels(other_wave)
         
     | 
| 
      
 32 
     | 
    
         
            +
                    initialize_from_buffer(wave.buffer)
         
     | 
| 
      
 33 
     | 
    
         
            +
                    self
         
     | 
| 
      
 34 
     | 
    
         
            +
                  end
         
     | 
| 
      
 35 
     | 
    
         
            +
             
     | 
| 
      
 36 
     | 
    
         
            +
                  alias + append
         
     | 
| 
      
 37 
     | 
    
         
            +
                  alias << append!
         
     | 
| 
      
 38 
     | 
    
         
            +
                  alias | mix
         
     | 
| 
      
 39 
     | 
    
         
            +
                  alias & stack_channels
         
     | 
| 
      
 40 
     | 
    
         
            +
                end
         
     | 
| 
      
 41 
     | 
    
         
            +
              end
         
     | 
| 
      
 42 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,47 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module SoundUtil
         
     | 
| 
      
 4 
     | 
    
         
            +
              module Filter
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Fade
         
     | 
| 
      
 6 
     | 
    
         
            +
                  extend Filter::Mixin
         
     | 
| 
      
 7 
     | 
    
         
            +
             
     | 
| 
      
 8 
     | 
    
         
            +
                  define_immutable_version :fade_in, :fade_out
         
     | 
| 
      
 9 
     | 
    
         
            +
             
     | 
| 
      
 10 
     | 
    
         
            +
                  def fade_in!(seconds: duration)
         
     | 
| 
      
 11 
     | 
    
         
            +
                    apply_fade!(seconds, :in)
         
     | 
| 
      
 12 
     | 
    
         
            +
                  end
         
     | 
| 
      
 13 
     | 
    
         
            +
             
     | 
| 
      
 14 
     | 
    
         
            +
                  def fade_out!(seconds: duration)
         
     | 
| 
      
 15 
     | 
    
         
            +
                    apply_fade!(seconds, :out)
         
     | 
| 
      
 16 
     | 
    
         
            +
                  end
         
     | 
| 
      
 17 
     | 
    
         
            +
             
     | 
| 
      
 18 
     | 
    
         
            +
                  private
         
     | 
| 
      
 19 
     | 
    
         
            +
             
     | 
| 
      
 20 
     | 
    
         
            +
                  def apply_fade!(seconds, direction)
         
     | 
| 
      
 21 
     | 
    
         
            +
                    fade_frames = (seconds * sample_rate).to_i
         
     | 
| 
      
 22 
     | 
    
         
            +
                    fade_frames = [[fade_frames, 1].max, frames].min
         
     | 
| 
      
 23 
     | 
    
         
            +
             
     | 
| 
      
 24 
     | 
    
         
            +
                    mutate_frames! do |frame_idx, samples|
         
     | 
| 
      
 25 
     | 
    
         
            +
                      factor = fade_factor(frame_idx, fade_frames, direction)
         
     | 
| 
      
 26 
     | 
    
         
            +
                      samples.map { |sample| encode_value(sample_to_float(sample) * factor) }
         
     | 
| 
      
 27 
     | 
    
         
            +
                    end
         
     | 
| 
      
 28 
     | 
    
         
            +
                  end
         
     | 
| 
      
 29 
     | 
    
         
            +
             
     | 
| 
      
 30 
     | 
    
         
            +
                  def fade_factor(frame_idx, fade_frames, direction)
         
     | 
| 
      
 31 
     | 
    
         
            +
                    case direction
         
     | 
| 
      
 32 
     | 
    
         
            +
                    when :in
         
     | 
| 
      
 33 
     | 
    
         
            +
                      return 1.0 if frame_idx >= fade_frames
         
     | 
| 
      
 34 
     | 
    
         
            +
             
     | 
| 
      
 35 
     | 
    
         
            +
                      (frame_idx + 1).to_f / fade_frames
         
     | 
| 
      
 36 
     | 
    
         
            +
                    when :out
         
     | 
| 
      
 37 
     | 
    
         
            +
                      remaining = frames - frame_idx
         
     | 
| 
      
 38 
     | 
    
         
            +
                      return 1.0 if remaining > fade_frames
         
     | 
| 
      
 39 
     | 
    
         
            +
             
     | 
| 
      
 40 
     | 
    
         
            +
                      [(remaining - 1), 0].max.to_f / fade_frames
         
     | 
| 
      
 41 
     | 
    
         
            +
                    else
         
     | 
| 
      
 42 
     | 
    
         
            +
                      1.0
         
     | 
| 
      
 43 
     | 
    
         
            +
                    end
         
     | 
| 
      
 44 
     | 
    
         
            +
                  end
         
     | 
| 
      
 45 
     | 
    
         
            +
                end
         
     | 
| 
      
 46 
     | 
    
         
            +
              end
         
     | 
| 
      
 47 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,19 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module SoundUtil
         
     | 
| 
      
 4 
     | 
    
         
            +
              module Filter
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Gain
         
     | 
| 
      
 6 
     | 
    
         
            +
                  extend Filter::Mixin
         
     | 
| 
      
 7 
     | 
    
         
            +
             
     | 
| 
      
 8 
     | 
    
         
            +
                  define_immutable_version :gain
         
     | 
| 
      
 9 
     | 
    
         
            +
             
     | 
| 
      
 10 
     | 
    
         
            +
                  def gain!(factor)
         
     | 
| 
      
 11 
     | 
    
         
            +
                    mutate_frames! do |_frame_idx, samples|
         
     | 
| 
      
 12 
     | 
    
         
            +
                      samples.map { |sample| encode_value(sample_to_float(sample) * factor) }
         
     | 
| 
      
 13 
     | 
    
         
            +
                    end
         
     | 
| 
      
 14 
     | 
    
         
            +
                  end
         
     | 
| 
      
 15 
     | 
    
         
            +
             
     | 
| 
      
 16 
     | 
    
         
            +
                  alias * gain
         
     | 
| 
      
 17 
     | 
    
         
            +
                end
         
     | 
| 
      
 18 
     | 
    
         
            +
              end
         
     | 
| 
      
 19 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,77 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module SoundUtil
         
     | 
| 
      
 4 
     | 
    
         
            +
              module Filter
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Resample
         
     | 
| 
      
 6 
     | 
    
         
            +
                  extend Filter::Mixin
         
     | 
| 
      
 7 
     | 
    
         
            +
             
     | 
| 
      
 8 
     | 
    
         
            +
                  define_immutable_version :resample
         
     | 
| 
      
 9 
     | 
    
         
            +
             
     | 
| 
      
 10 
     | 
    
         
            +
                  def resample!(new_sample_rate, frames: nil, method: :linear)
         
     | 
| 
      
 11 
     | 
    
         
            +
                    target_rate = Integer(new_sample_rate)
         
     | 
| 
      
 12 
     | 
    
         
            +
                    raise ArgumentError, "new sample rate must be positive" unless target_rate.positive?
         
     | 
| 
      
 13 
     | 
    
         
            +
             
     | 
| 
      
 14 
     | 
    
         
            +
                    target_frames = frames ? Integer(frames) : calculate_target_frames(target_rate)
         
     | 
| 
      
 15 
     | 
    
         
            +
                    raise ArgumentError, "target frames must be positive" unless target_frames.positive?
         
     | 
| 
      
 16 
     | 
    
         
            +
             
     | 
| 
      
 17 
     | 
    
         
            +
                    return self if target_rate == sample_rate && target_frames == self.frames
         
     | 
| 
      
 18 
     | 
    
         
            +
             
     | 
| 
      
 19 
     | 
    
         
            +
                    case method
         
     | 
| 
      
 20 
     | 
    
         
            +
                    when :linear
         
     | 
| 
      
 21 
     | 
    
         
            +
                      perform_linear_resample!(target_rate, target_frames)
         
     | 
| 
      
 22 
     | 
    
         
            +
                    else
         
     | 
| 
      
 23 
     | 
    
         
            +
                      raise ArgumentError, "unsupported resample method: #{method.inspect}"
         
     | 
| 
      
 24 
     | 
    
         
            +
                    end
         
     | 
| 
      
 25 
     | 
    
         
            +
             
     | 
| 
      
 26 
     | 
    
         
            +
                    self
         
     | 
| 
      
 27 
     | 
    
         
            +
                  end
         
     | 
| 
      
 28 
     | 
    
         
            +
             
     | 
| 
      
 29 
     | 
    
         
            +
                  private
         
     | 
| 
      
 30 
     | 
    
         
            +
             
     | 
| 
      
 31 
     | 
    
         
            +
                  def calculate_target_frames(new_sample_rate)
         
     | 
| 
      
 32 
     | 
    
         
            +
                    frames = (duration * new_sample_rate).round
         
     | 
| 
      
 33 
     | 
    
         
            +
                    frames = 1 if frames.zero?
         
     | 
| 
      
 34 
     | 
    
         
            +
                    frames
         
     | 
| 
      
 35 
     | 
    
         
            +
                  end
         
     | 
| 
      
 36 
     | 
    
         
            +
             
     | 
| 
      
 37 
     | 
    
         
            +
                  def perform_linear_resample!(target_rate, target_frames)
         
     | 
| 
      
 38 
     | 
    
         
            +
                    if frames.zero?
         
     | 
| 
      
 39 
     | 
    
         
            +
                      initialize_from_buffer(Util.build_buffer(self, channels: channels, frames: target_frames, sample_rate: target_rate))
         
     | 
| 
      
 40 
     | 
    
         
            +
                      @sample_rate = target_rate
         
     | 
| 
      
 41 
     | 
    
         
            +
                      @frames = target_frames
         
     | 
| 
      
 42 
     | 
    
         
            +
                      return
         
     | 
| 
      
 43 
     | 
    
         
            +
                    end
         
     | 
| 
      
 44 
     | 
    
         
            +
             
     | 
| 
      
 45 
     | 
    
         
            +
                    ratio = sample_rate.to_f / target_rate
         
     | 
| 
      
 46 
     | 
    
         
            +
                    new_buffer = Util.build_buffer(self, channels: channels, frames: target_frames, sample_rate: target_rate)
         
     | 
| 
      
 47 
     | 
    
         
            +
             
     | 
| 
      
 48 
     | 
    
         
            +
                    target_frames.times do |frame_idx|
         
     | 
| 
      
 49 
     | 
    
         
            +
                      source_position = frame_idx * ratio
         
     | 
| 
      
 50 
     | 
    
         
            +
                      left_idx = source_position.floor
         
     | 
| 
      
 51 
     | 
    
         
            +
                      right_idx = [left_idx + 1, frames - 1].min
         
     | 
| 
      
 52 
     | 
    
         
            +
                      t = source_position - left_idx
         
     | 
| 
      
 53 
     | 
    
         
            +
             
     | 
| 
      
 54 
     | 
    
         
            +
                      left_frame = buffer.read_frame(left_idx)
         
     | 
| 
      
 55 
     | 
    
         
            +
                      right_frame = buffer.read_frame(right_idx)
         
     | 
| 
      
 56 
     | 
    
         
            +
             
     | 
| 
      
 57 
     | 
    
         
            +
                      samples = Array.new(channels) do |channel_idx|
         
     | 
| 
      
 58 
     | 
    
         
            +
                        left = sample_to_float(left_frame[channel_idx])
         
     | 
| 
      
 59 
     | 
    
         
            +
                        right = sample_to_float(right_frame[channel_idx])
         
     | 
| 
      
 60 
     | 
    
         
            +
                        value = if left_idx == right_idx
         
     | 
| 
      
 61 
     | 
    
         
            +
                                  left
         
     | 
| 
      
 62 
     | 
    
         
            +
                                else
         
     | 
| 
      
 63 
     | 
    
         
            +
                                  left + (right - left) * t
         
     | 
| 
      
 64 
     | 
    
         
            +
                                end
         
     | 
| 
      
 65 
     | 
    
         
            +
                        encode_value(value)
         
     | 
| 
      
 66 
     | 
    
         
            +
                      end
         
     | 
| 
      
 67 
     | 
    
         
            +
             
     | 
| 
      
 68 
     | 
    
         
            +
                      new_buffer.write_frame(frame_idx, samples)
         
     | 
| 
      
 69 
     | 
    
         
            +
                    end
         
     | 
| 
      
 70 
     | 
    
         
            +
             
     | 
| 
      
 71 
     | 
    
         
            +
                    initialize_from_buffer(new_buffer)
         
     | 
| 
      
 72 
     | 
    
         
            +
                    @sample_rate = target_rate
         
     | 
| 
      
 73 
     | 
    
         
            +
                    @frames = target_frames
         
     | 
| 
      
 74 
     | 
    
         
            +
                  end
         
     | 
| 
      
 75 
     | 
    
         
            +
                end
         
     | 
| 
      
 76 
     | 
    
         
            +
              end
         
     | 
| 
      
 77 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,11 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module SoundUtil
         
     | 
| 
      
 4 
     | 
    
         
            +
              module Filter
         
     | 
| 
      
 5 
     | 
    
         
            +
                autoload :Mixin, "sound_util/filter/_mixin"
         
     | 
| 
      
 6 
     | 
    
         
            +
                autoload :Gain, "sound_util/filter/gain"
         
     | 
| 
      
 7 
     | 
    
         
            +
                autoload :Fade, "sound_util/filter/fade"
         
     | 
| 
      
 8 
     | 
    
         
            +
                autoload :Combine, "sound_util/filter/combine"
         
     | 
| 
      
 9 
     | 
    
         
            +
                autoload :Resample, "sound_util/filter/resample"
         
     | 
| 
      
 10 
     | 
    
         
            +
              end
         
     | 
| 
      
 11 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,75 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module SoundUtil
         
     | 
| 
      
 4 
     | 
    
         
            +
              module Generator
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Combine
         
     | 
| 
      
 6 
     | 
    
         
            +
                  def generate_appended_wave(left:, right:)
         
     | 
| 
      
 7 
     | 
    
         
            +
                    Util.ensure_same_kind!(left, right)
         
     | 
| 
      
 8 
     | 
    
         
            +
                    Util.assert_dimensions!(right, channels: left.channels)
         
     | 
| 
      
 9 
     | 
    
         
            +
             
     | 
| 
      
 10 
     | 
    
         
            +
                    buffer = build_appended_buffer(left, right)
         
     | 
| 
      
 11 
     | 
    
         
            +
                    Util.build_wave_from_buffer(left, buffer)
         
     | 
| 
      
 12 
     | 
    
         
            +
                  end
         
     | 
| 
      
 13 
     | 
    
         
            +
             
     | 
| 
      
 14 
     | 
    
         
            +
                  def generate_mixed_wave(left:, right:)
         
     | 
| 
      
 15 
     | 
    
         
            +
                    Util.ensure_same_kind!(left, right)
         
     | 
| 
      
 16 
     | 
    
         
            +
                    Util.assert_dimensions!(right, channels: left.channels)
         
     | 
| 
      
 17 
     | 
    
         
            +
             
     | 
| 
      
 18 
     | 
    
         
            +
                    frames = [left.frames, right.frames].max
         
     | 
| 
      
 19 
     | 
    
         
            +
                    buffer = Util.build_buffer(left, channels: left.channels, frames: frames)
         
     | 
| 
      
 20 
     | 
    
         
            +
             
     | 
| 
      
 21 
     | 
    
         
            +
                    info = left.format_info
         
     | 
| 
      
 22 
     | 
    
         
            +
                    zero = Util.zero_frame(left.channels)
         
     | 
| 
      
 23 
     | 
    
         
            +
             
     | 
| 
      
 24 
     | 
    
         
            +
                    frames.times do |frame_idx|
         
     | 
| 
      
 25 
     | 
    
         
            +
                      left_frame = frame_idx < left.frames ? left.buffer.read_frame(frame_idx) : zero
         
     | 
| 
      
 26 
     | 
    
         
            +
                      right_frame = frame_idx < right.frames ? right.buffer.read_frame(frame_idx) : zero
         
     | 
| 
      
 27 
     | 
    
         
            +
             
     | 
| 
      
 28 
     | 
    
         
            +
                      samples = Array.new(buffer.channels) do |channel_idx|
         
     | 
| 
      
 29 
     | 
    
         
            +
                        mix_sample(left_frame[channel_idx], right_frame[channel_idx], info)
         
     | 
| 
      
 30 
     | 
    
         
            +
                      end
         
     | 
| 
      
 31 
     | 
    
         
            +
             
     | 
| 
      
 32 
     | 
    
         
            +
                      buffer.write_frame(frame_idx, samples)
         
     | 
| 
      
 33 
     | 
    
         
            +
                    end
         
     | 
| 
      
 34 
     | 
    
         
            +
             
     | 
| 
      
 35 
     | 
    
         
            +
                    Util.build_wave_from_buffer(left, buffer)
         
     | 
| 
      
 36 
     | 
    
         
            +
                  end
         
     | 
| 
      
 37 
     | 
    
         
            +
             
     | 
| 
      
 38 
     | 
    
         
            +
                  def generate_stacked_wave(primary:, secondary:)
         
     | 
| 
      
 39 
     | 
    
         
            +
                    Util.ensure_same_kind!(primary, secondary)
         
     | 
| 
      
 40 
     | 
    
         
            +
             
     | 
| 
      
 41 
     | 
    
         
            +
                    frames = [primary.frames, secondary.frames].max
         
     | 
| 
      
 42 
     | 
    
         
            +
                    total_channels = primary.channels + secondary.channels
         
     | 
| 
      
 43 
     | 
    
         
            +
             
     | 
| 
      
 44 
     | 
    
         
            +
                    buffer = Util.build_buffer(primary, channels: total_channels, frames: frames)
         
     | 
| 
      
 45 
     | 
    
         
            +
             
     | 
| 
      
 46 
     | 
    
         
            +
                    primary_zero = Util.zero_frame(primary.channels)
         
     | 
| 
      
 47 
     | 
    
         
            +
                    secondary_zero = Util.zero_frame(secondary.channels)
         
     | 
| 
      
 48 
     | 
    
         
            +
             
     | 
| 
      
 49 
     | 
    
         
            +
                    frames.times do |frame_idx|
         
     | 
| 
      
 50 
     | 
    
         
            +
                      primary_frame = frame_idx < primary.frames ? primary.buffer.read_frame(frame_idx) : primary_zero
         
     | 
| 
      
 51 
     | 
    
         
            +
                      secondary_frame = frame_idx < secondary.frames ? secondary.buffer.read_frame(frame_idx) : secondary_zero
         
     | 
| 
      
 52 
     | 
    
         
            +
             
     | 
| 
      
 53 
     | 
    
         
            +
                      buffer.write_frame(frame_idx, primary_frame + secondary_frame)
         
     | 
| 
      
 54 
     | 
    
         
            +
                    end
         
     | 
| 
      
 55 
     | 
    
         
            +
             
     | 
| 
      
 56 
     | 
    
         
            +
                    Util.build_wave_from_buffer(primary, buffer)
         
     | 
| 
      
 57 
     | 
    
         
            +
                  end
         
     | 
| 
      
 58 
     | 
    
         
            +
             
     | 
| 
      
 59 
     | 
    
         
            +
                  private
         
     | 
| 
      
 60 
     | 
    
         
            +
             
     | 
| 
      
 61 
     | 
    
         
            +
                  def build_appended_buffer(left, right)
         
     | 
| 
      
 62 
     | 
    
         
            +
                    buffer = Util.build_buffer(left, channels: left.channels, frames: left.frames + right.frames)
         
     | 
| 
      
 63 
     | 
    
         
            +
             
     | 
| 
      
 64 
     | 
    
         
            +
                    destination = buffer.io_buffer
         
     | 
| 
      
 65 
     | 
    
         
            +
                    destination.copy(left.buffer.io_buffer, 0)
         
     | 
| 
      
 66 
     | 
    
         
            +
                    destination.copy(right.buffer.io_buffer, left.buffer.size)
         
     | 
| 
      
 67 
     | 
    
         
            +
                    buffer
         
     | 
| 
      
 68 
     | 
    
         
            +
                  end
         
     | 
| 
      
 69 
     | 
    
         
            +
             
     | 
| 
      
 70 
     | 
    
         
            +
                  def mix_sample(first, second, info)
         
     | 
| 
      
 71 
     | 
    
         
            +
                    (first + second).clamp(info[:min], info[:max])
         
     | 
| 
      
 72 
     | 
    
         
            +
                  end
         
     | 
| 
      
 73 
     | 
    
         
            +
                end
         
     | 
| 
      
 74 
     | 
    
         
            +
              end
         
     | 
| 
      
 75 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,32 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module SoundUtil
         
     | 
| 
      
 4 
     | 
    
         
            +
              module Generator
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Tone
         
     | 
| 
      
 6 
     | 
    
         
            +
                  DEFAULTS = {
         
     | 
| 
      
 7 
     | 
    
         
            +
                    sample_rate: 44_100,
         
     | 
| 
      
 8 
     | 
    
         
            +
                    channels: 1,
         
     | 
| 
      
 9 
     | 
    
         
            +
                    amplitude: 1.0,
         
     | 
| 
      
 10 
     | 
    
         
            +
                    phase: 0.0,
         
     | 
| 
      
 11 
     | 
    
         
            +
                    format: :s16le
         
     | 
| 
      
 12 
     | 
    
         
            +
                  }.freeze
         
     | 
| 
      
 13 
     | 
    
         
            +
             
     | 
| 
      
 14 
     | 
    
         
            +
                  def sine(duration_seconds:, frequency:, **options)
         
     | 
| 
      
 15 
     | 
    
         
            +
                    opts = DEFAULTS.merge(options)
         
     | 
| 
      
 16 
     | 
    
         
            +
                    sample_rate = opts[:sample_rate]
         
     | 
| 
      
 17 
     | 
    
         
            +
                    frames = (duration_seconds * sample_rate).to_i
         
     | 
| 
      
 18 
     | 
    
         
            +
                    new(channels: opts[:channels], sample_rate: sample_rate, frames: frames, format: opts[:format]) do |frame_idx|
         
     | 
| 
      
 19 
     | 
    
         
            +
                      t = frame_idx.to_f / sample_rate
         
     | 
| 
      
 20 
     | 
    
         
            +
                      Math.sin((2.0 * Math::PI * frequency * t) + opts[:phase]) * opts[:amplitude]
         
     | 
| 
      
 21 
     | 
    
         
            +
                    end
         
     | 
| 
      
 22 
     | 
    
         
            +
                  end
         
     | 
| 
      
 23 
     | 
    
         
            +
             
     | 
| 
      
 24 
     | 
    
         
            +
                  def silence(duration_seconds:, **options)
         
     | 
| 
      
 25 
     | 
    
         
            +
                    opts = DEFAULTS.merge(options)
         
     | 
| 
      
 26 
     | 
    
         
            +
                    sample_rate = opts[:sample_rate]
         
     | 
| 
      
 27 
     | 
    
         
            +
                    frames = (duration_seconds * sample_rate).to_i
         
     | 
| 
      
 28 
     | 
    
         
            +
                    new(channels: opts[:channels], sample_rate: sample_rate, frames: frames, format: opts[:format])
         
     | 
| 
      
 29 
     | 
    
         
            +
                  end
         
     | 
| 
      
 30 
     | 
    
         
            +
                end
         
     | 
| 
      
 31 
     | 
    
         
            +
              end
         
     | 
| 
      
 32 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,40 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            require "stringio"
         
     | 
| 
      
 4 
     | 
    
         
            +
             
     | 
| 
      
 5 
     | 
    
         
            +
            module SoundUtil
         
     | 
| 
      
 6 
     | 
    
         
            +
              module Magic
         
     | 
| 
      
 7 
     | 
    
         
            +
                MAGIC_HEADERS = {
         
     | 
| 
      
 8 
     | 
    
         
            +
                  wav: %w[RIFF RF64]
         
     | 
| 
      
 9 
     | 
    
         
            +
                }.freeze
         
     | 
| 
      
 10 
     | 
    
         
            +
             
     | 
| 
      
 11 
     | 
    
         
            +
                module_function
         
     | 
| 
      
 12 
     | 
    
         
            +
             
     | 
| 
      
 13 
     | 
    
         
            +
                def bytes_needed = 12
         
     | 
| 
      
 14 
     | 
    
         
            +
             
     | 
| 
      
 15 
     | 
    
         
            +
                def detect(data)
         
     | 
| 
      
 16 
     | 
    
         
            +
                  return nil unless data && data.bytesize >= bytes_needed
         
     | 
| 
      
 17 
     | 
    
         
            +
             
     | 
| 
      
 18 
     | 
    
         
            +
                  chunk_id = data.byteslice(0, 4)
         
     | 
| 
      
 19 
     | 
    
         
            +
                  format = data.byteslice(8, 4)
         
     | 
| 
      
 20 
     | 
    
         
            +
             
     | 
| 
      
 21 
     | 
    
         
            +
                  return :wav if MAGIC_HEADERS[:wav].include?(chunk_id) && format == "WAVE"
         
     | 
| 
      
 22 
     | 
    
         
            +
             
     | 
| 
      
 23 
     | 
    
         
            +
                  nil
         
     | 
| 
      
 24 
     | 
    
         
            +
                end
         
     | 
| 
      
 25 
     | 
    
         
            +
             
     | 
| 
      
 26 
     | 
    
         
            +
                def detect_io(io)
         
     | 
| 
      
 27 
     | 
    
         
            +
                  pos = io.pos
         
     | 
| 
      
 28 
     | 
    
         
            +
                  data = io.read(bytes_needed)
         
     | 
| 
      
 29 
     | 
    
         
            +
                  io.seek(pos)
         
     | 
| 
      
 30 
     | 
    
         
            +
                  [detect(data), io]
         
     | 
| 
      
 31 
     | 
    
         
            +
                rescue Errno::ESPIPE, IOError
         
     | 
| 
      
 32 
     | 
    
         
            +
                  data = io.read(bytes_needed)
         
     | 
| 
      
 33 
     | 
    
         
            +
                  fmt = detect(data)
         
     | 
| 
      
 34 
     | 
    
         
            +
                  prefix = (data || "").b
         
     | 
| 
      
 35 
     | 
    
         
            +
                  combined = prefix + (io.read || "")
         
     | 
| 
      
 36 
     | 
    
         
            +
                  new_io = StringIO.new(combined)
         
     | 
| 
      
 37 
     | 
    
         
            +
                  [fmt, new_io]
         
     | 
| 
      
 38 
     | 
    
         
            +
                end
         
     | 
| 
      
 39 
     | 
    
         
            +
              end
         
     | 
| 
      
 40 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,56 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module SoundUtil
         
     | 
| 
      
 4 
     | 
    
         
            +
              module Sink
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Playback
         
     | 
| 
      
 6 
     | 
    
         
            +
                  FORMAT_FLAGS = {
         
     | 
| 
      
 7 
     | 
    
         
            +
                    u8: "U8",
         
     | 
| 
      
 8 
     | 
    
         
            +
                    s16le: "S16_LE",
         
     | 
| 
      
 9 
     | 
    
         
            +
                    s24le: "S24_LE",
         
     | 
| 
      
 10 
     | 
    
         
            +
                    s32le: "S32_LE",
         
     | 
| 
      
 11 
     | 
    
         
            +
                    f32le: "FLOAT_LE",
         
     | 
| 
      
 12 
     | 
    
         
            +
                    f64le: "FLOAT64_LE"
         
     | 
| 
      
 13 
     | 
    
         
            +
                  }.freeze
         
     | 
| 
      
 14 
     | 
    
         
            +
             
     | 
| 
      
 15 
     | 
    
         
            +
                  DEFAULT_COMMAND = lambda do |wave|
         
     | 
| 
      
 16 
     | 
    
         
            +
                    flag = FORMAT_FLAGS[wave.format]
         
     | 
| 
      
 17 
     | 
    
         
            +
                    raise SoundUtil::Error, "unsupported playback format: #{wave.format}" unless flag
         
     | 
| 
      
 18 
     | 
    
         
            +
             
     | 
| 
      
 19 
     | 
    
         
            +
                    [
         
     | 
| 
      
 20 
     | 
    
         
            +
                      "aplay",
         
     | 
| 
      
 21 
     | 
    
         
            +
                      "-t", "raw",
         
     | 
| 
      
 22 
     | 
    
         
            +
                      "-f", flag,
         
     | 
| 
      
 23 
     | 
    
         
            +
                      "-c", wave.channels.to_s,
         
     | 
| 
      
 24 
     | 
    
         
            +
                      "-r", wave.sample_rate.to_s,
         
     | 
| 
      
 25 
     | 
    
         
            +
                      "-"
         
     | 
| 
      
 26 
     | 
    
         
            +
                    ]
         
     | 
| 
      
 27 
     | 
    
         
            +
                  end
         
     | 
| 
      
 28 
     | 
    
         
            +
             
     | 
| 
      
 29 
     | 
    
         
            +
                  def play(command: nil, io: nil)
         
     | 
| 
      
 30 
     | 
    
         
            +
                    if io
         
     | 
| 
      
 31 
     | 
    
         
            +
                      pipe(io)
         
     | 
| 
      
 32 
     | 
    
         
            +
                      return self
         
     | 
| 
      
 33 
     | 
    
         
            +
                    end
         
     | 
| 
      
 34 
     | 
    
         
            +
             
     | 
| 
      
 35 
     | 
    
         
            +
                    cmd = build_command(command)
         
     | 
| 
      
 36 
     | 
    
         
            +
                    IO.popen(cmd, "wb") do |handle|
         
     | 
| 
      
 37 
     | 
    
         
            +
                      pipe(handle)
         
     | 
| 
      
 38 
     | 
    
         
            +
                      handle.close_write
         
     | 
| 
      
 39 
     | 
    
         
            +
                      Process.wait(handle.pid) if handle.respond_to?(:pid)
         
     | 
| 
      
 40 
     | 
    
         
            +
                    end
         
     | 
| 
      
 41 
     | 
    
         
            +
                    self
         
     | 
| 
      
 42 
     | 
    
         
            +
                  rescue Errno::ENOENT
         
     | 
| 
      
 43 
     | 
    
         
            +
                    cmd_display = cmd.is_a?(Array) ? cmd.join(" ") : cmd.to_s
         
     | 
| 
      
 44 
     | 
    
         
            +
                    raise SoundUtil::Error, "playback command not found: #{cmd_display}"
         
     | 
| 
      
 45 
     | 
    
         
            +
                  end
         
     | 
| 
      
 46 
     | 
    
         
            +
             
     | 
| 
      
 47 
     | 
    
         
            +
                  private
         
     | 
| 
      
 48 
     | 
    
         
            +
             
     | 
| 
      
 49 
     | 
    
         
            +
                  def build_command(command)
         
     | 
| 
      
 50 
     | 
    
         
            +
                    return command unless command.nil?
         
     | 
| 
      
 51 
     | 
    
         
            +
             
     | 
| 
      
 52 
     | 
    
         
            +
                    DEFAULT_COMMAND.call(self)
         
     | 
| 
      
 53 
     | 
    
         
            +
                  end
         
     | 
| 
      
 54 
     | 
    
         
            +
                end
         
     | 
| 
      
 55 
     | 
    
         
            +
              end
         
     | 
| 
      
 56 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,136 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            require "image_util"
         
     | 
| 
      
 4 
     | 
    
         
            +
             
     | 
| 
      
 5 
     | 
    
         
            +
            module SoundUtil
         
     | 
| 
      
 6 
     | 
    
         
            +
              module Sink
         
     | 
| 
      
 7 
     | 
    
         
            +
                module Preview
         
     | 
| 
      
 8 
     | 
    
         
            +
                  DEFAULT_WIDTH = 600
         
     | 
| 
      
 9 
     | 
    
         
            +
                  DEFAULT_HEIGHT = 28
         
     | 
| 
      
 10 
     | 
    
         
            +
             
     | 
| 
      
 11 
     | 
    
         
            +
                  def preview(io = $stdout, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, caption: nil)
         
     | 
| 
      
 12 
     | 
    
         
            +
                    renderer = PreviewRenderer.new(self, width: width, height: height, caption: caption)
         
     | 
| 
      
 13 
     | 
    
         
            +
                    rendered = renderer.render
         
     | 
| 
      
 14 
     | 
    
         
            +
                    io.puts(rendered || "[wave preview unavailable]")
         
     | 
| 
      
 15 
     | 
    
         
            +
                    self
         
     | 
| 
      
 16 
     | 
    
         
            +
                  rescue LoadError
         
     | 
| 
      
 17 
     | 
    
         
            +
                    io.puts "[wave preview unavailable]"
         
     | 
| 
      
 18 
     | 
    
         
            +
                    self
         
     | 
| 
      
 19 
     | 
    
         
            +
                  end
         
     | 
| 
      
 20 
     | 
    
         
            +
             
     | 
| 
      
 21 
     | 
    
         
            +
                  def preview_image(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, caption: nil)
         
     | 
| 
      
 22 
     | 
    
         
            +
                    PreviewRenderer.new(self, width: width, height: height, caption: caption).image
         
     | 
| 
      
 23 
     | 
    
         
            +
                  end
         
     | 
| 
      
 24 
     | 
    
         
            +
             
     | 
| 
      
 25 
     | 
    
         
            +
                  class PreviewRenderer
         
     | 
| 
      
 26 
     | 
    
         
            +
                    BACKGROUND_COLOR = [12, 12, 18, 255].freeze
         
     | 
| 
      
 27 
     | 
    
         
            +
                    AXIS_COLOR = [60, 60, 80, 255].freeze
         
     | 
| 
      
 28 
     | 
    
         
            +
                    CHANNEL_COLORS = [
         
     | 
| 
      
 29 
     | 
    
         
            +
                      [90, 200, 255, 255],
         
     | 
| 
      
 30 
     | 
    
         
            +
                      [255, 140, 220, 255],
         
     | 
| 
      
 31 
     | 
    
         
            +
                      [180, 255, 140, 255]
         
     | 
| 
      
 32 
     | 
    
         
            +
                    ].freeze
         
     | 
| 
      
 33 
     | 
    
         
            +
                    TEXT_COLOR = [235, 235, 235, 255].freeze
         
     | 
| 
      
 34 
     | 
    
         
            +
             
     | 
| 
      
 35 
     | 
    
         
            +
                    def initialize(wave, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, caption: nil)
         
     | 
| 
      
 36 
     | 
    
         
            +
                      @wave = wave
         
     | 
| 
      
 37 
     | 
    
         
            +
                      @width = [[width, 16].max, 1000].min
         
     | 
| 
      
 38 
     | 
    
         
            +
                      @height = [[height, 16].max, 64].min
         
     | 
| 
      
 39 
     | 
    
         
            +
                      @caption = caption
         
     | 
| 
      
 40 
     | 
    
         
            +
                    end
         
     | 
| 
      
 41 
     | 
    
         
            +
             
     | 
| 
      
 42 
     | 
    
         
            +
                    def render
         
     | 
| 
      
 43 
     | 
    
         
            +
                      ImageUtil::Terminal.output_image($stdin, $stdout, image)
         
     | 
| 
      
 44 
     | 
    
         
            +
                    end
         
     | 
| 
      
 45 
     | 
    
         
            +
             
     | 
| 
      
 46 
     | 
    
         
            +
                    def image
         
     | 
| 
      
 47 
     | 
    
         
            +
                      @image ||= build_image
         
     | 
| 
      
 48 
     | 
    
         
            +
                    end
         
     | 
| 
      
 49 
     | 
    
         
            +
             
     | 
| 
      
 50 
     | 
    
         
            +
                    private
         
     | 
| 
      
 51 
     | 
    
         
            +
             
     | 
| 
      
 52 
     | 
    
         
            +
                    attr_reader :wave, :width, :height, :caption
         
     | 
| 
      
 53 
     | 
    
         
            +
             
     | 
| 
      
 54 
     | 
    
         
            +
                    def build_image
         
     | 
| 
      
 55 
     | 
    
         
            +
                      img = ImageUtil::Image.new(width, height) { BACKGROUND_COLOR }
         
     | 
| 
      
 56 
     | 
    
         
            +
                      draw_axes(img)
         
     | 
| 
      
 57 
     | 
    
         
            +
                      draw_waveform(img)
         
     | 
| 
      
 58 
     | 
    
         
            +
                      draw_caption(img)
         
     | 
| 
      
 59 
     | 
    
         
            +
                      img
         
     | 
| 
      
 60 
     | 
    
         
            +
                    end
         
     | 
| 
      
 61 
     | 
    
         
            +
             
     | 
| 
      
 62 
     | 
    
         
            +
                    def draw_axes(image)
         
     | 
| 
      
 63 
     | 
    
         
            +
                      mid = (height - 1) / 2
         
     | 
| 
      
 64 
     | 
    
         
            +
                      width.times { |x| image[x, mid] = AXIS_COLOR }
         
     | 
| 
      
 65 
     | 
    
         
            +
                      height.times { |y| image[0, y] = AXIS_COLOR }
         
     | 
| 
      
 66 
     | 
    
         
            +
                    end
         
     | 
| 
      
 67 
     | 
    
         
            +
             
     | 
| 
      
 68 
     | 
    
         
            +
                    def draw_waveform(image)
         
     | 
| 
      
 69 
     | 
    
         
            +
                      return if wave.frames.zero?
         
     | 
| 
      
 70 
     | 
    
         
            +
             
     | 
| 
      
 71 
     | 
    
         
            +
                      mid = (height - 1) / 2.0
         
     | 
| 
      
 72 
     | 
    
         
            +
                      scale = (height - 1) / 2.0
         
     | 
| 
      
 73 
     | 
    
         
            +
                      step = [wave.frames.to_f / width, 1.0].max
         
     | 
| 
      
 74 
     | 
    
         
            +
             
     | 
| 
      
 75 
     | 
    
         
            +
                      width.times do |x|
         
     | 
| 
      
 76 
     | 
    
         
            +
                        start_idx = (x * step).floor
         
     | 
| 
      
 77 
     | 
    
         
            +
                        end_idx = [((x + 1) * step).ceil, wave.frames - 1].min
         
     | 
| 
      
 78 
     | 
    
         
            +
                        next if start_idx.negative? || start_idx >= wave.frames
         
     | 
| 
      
 79 
     | 
    
         
            +
             
     | 
| 
      
 80 
     | 
    
         
            +
                        wave.channels.times do |channel_idx|
         
     | 
| 
      
 81 
     | 
    
         
            +
                          min_amp = 1.0
         
     | 
| 
      
 82 
     | 
    
         
            +
                          max_amp = -1.0
         
     | 
| 
      
 83 
     | 
    
         
            +
             
     | 
| 
      
 84 
     | 
    
         
            +
                          start_idx.upto(end_idx) do |frame_idx|
         
     | 
| 
      
 85 
     | 
    
         
            +
                            sample = wave.send(:sample_to_float, wave.buffer.read_frame(frame_idx)[channel_idx])
         
     | 
| 
      
 86 
     | 
    
         
            +
                            min_amp = sample if sample < min_amp
         
     | 
| 
      
 87 
     | 
    
         
            +
                            max_amp = sample if sample > max_amp
         
     | 
| 
      
 88 
     | 
    
         
            +
                          end
         
     | 
| 
      
 89 
     | 
    
         
            +
             
     | 
| 
      
 90 
     | 
    
         
            +
                          top = amplitude_to_y(max_amp, mid, scale)
         
     | 
| 
      
 91 
     | 
    
         
            +
                          bottom = amplitude_to_y(min_amp, mid, scale)
         
     | 
| 
      
 92 
     | 
    
         
            +
                          bottom, top = top, bottom if bottom < top
         
     | 
| 
      
 93 
     | 
    
         
            +
                          color = CHANNEL_COLORS[channel_idx % CHANNEL_COLORS.length]
         
     | 
| 
      
 94 
     | 
    
         
            +
             
     | 
| 
      
 95 
     | 
    
         
            +
                          top.upto(bottom) { |y| image[x, y] = color }
         
     | 
| 
      
 96 
     | 
    
         
            +
             
     | 
| 
      
 97 
     | 
    
         
            +
                          middle_sample = wave.send(:sample_to_float, wave.buffer.read_frame((start_idx + end_idx) / 2)[channel_idx])
         
     | 
| 
      
 98 
     | 
    
         
            +
                          point_y = amplitude_to_y(middle_sample, mid, scale)
         
     | 
| 
      
 99 
     | 
    
         
            +
                          image[x, point_y] = highlight_color(color)
         
     | 
| 
      
 100 
     | 
    
         
            +
                        end
         
     | 
| 
      
 101 
     | 
    
         
            +
                      end
         
     | 
| 
      
 102 
     | 
    
         
            +
                    end
         
     | 
| 
      
 103 
     | 
    
         
            +
             
     | 
| 
      
 104 
     | 
    
         
            +
                    def draw_icon(image)
         
     | 
| 
      
 105 
     | 
    
         
            +
                      base_x = [2, width - 8].min
         
     | 
| 
      
 106 
     | 
    
         
            +
                      base_y = 2
         
     | 
| 
      
 107 
     | 
    
         
            +
                      ICON_COORDS.each do |dx, dy|
         
     | 
| 
      
 108 
     | 
    
         
            +
                        x = base_x + dx
         
     | 
| 
      
 109 
     | 
    
         
            +
                        y = base_y + dy
         
     | 
| 
      
 110 
     | 
    
         
            +
                        next if x >= width || y >= height
         
     | 
| 
      
 111 
     | 
    
         
            +
             
     | 
| 
      
 112 
     | 
    
         
            +
                        image[x, y] = ICON_COLOR
         
     | 
| 
      
 113 
     | 
    
         
            +
                      end
         
     | 
| 
      
 114 
     | 
    
         
            +
                    end
         
     | 
| 
      
 115 
     | 
    
         
            +
             
     | 
| 
      
 116 
     | 
    
         
            +
                    def draw_caption(image)
         
     | 
| 
      
 117 
     | 
    
         
            +
                      text = caption || Kernel.format("%dch %dHz %d frames %.2gs", wave.channels, wave.sample_rate, wave.frames, wave.duration)
         
     | 
| 
      
 118 
     | 
    
         
            +
                      baseline = height - 8
         
     | 
| 
      
 119 
     | 
    
         
            +
                      baseline = [baseline, 1].max
         
     | 
| 
      
 120 
     | 
    
         
            +
                      image.bitmap_text!(text, 2, baseline, color: TEXT_COLOR)
         
     | 
| 
      
 121 
     | 
    
         
            +
                    end
         
     | 
| 
      
 122 
     | 
    
         
            +
             
     | 
| 
      
 123 
     | 
    
         
            +
                    def amplitude_to_y(amplitude, mid, scale)
         
     | 
| 
      
 124 
     | 
    
         
            +
                      y = mid - amplitude * scale
         
     | 
| 
      
 125 
     | 
    
         
            +
                      [[y.round, 0].max, height - 1].min
         
     | 
| 
      
 126 
     | 
    
         
            +
                    end
         
     | 
| 
      
 127 
     | 
    
         
            +
             
     | 
| 
      
 128 
     | 
    
         
            +
                    def highlight_color(color)
         
     | 
| 
      
 129 
     | 
    
         
            +
                      dup_color = color.dup
         
     | 
| 
      
 130 
     | 
    
         
            +
                      3.times { |idx| dup_color[idx] = [[dup_color[idx] + 40, 255].min, 0].max }
         
     | 
| 
      
 131 
     | 
    
         
            +
                      dup_color
         
     | 
| 
      
 132 
     | 
    
         
            +
                    end
         
     | 
| 
      
 133 
     | 
    
         
            +
                  end
         
     | 
| 
      
 134 
     | 
    
         
            +
                end
         
     | 
| 
      
 135 
     | 
    
         
            +
              end
         
     | 
| 
      
 136 
     | 
    
         
            +
            end
         
     |