awaaz 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.
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Awaaz
4
+ module Decoders
5
+ ##
6
+ # The WavefileDecoder is responsible for decoding `.wav` audio files
7
+ # into raw PCM data that can be processed by the Awaaz audio pipeline.
8
+ #
9
+ # This decoder supports multiple decoding strategies:
10
+ #
11
+ # 1. **Soundread** — a Ruby-level `.wav` file reader.
12
+ # 2. **Shell-based decoding** — for raw audio extraction.
13
+ #
14
+ # The decoder will choose a decoding method based on availability
15
+ # of decoders and the configured options.
16
+ #
17
+ # @see Awaaz::Decoders::BaseDecoder
18
+ # @see Awaaz::Utils::ViaShell
19
+ #
20
+ class WavefileDecoder < BaseDecoder
21
+ include Utils::ViaShell
22
+
23
+ # Sets the available decoding options for this decoder.
24
+ # Defaults to the base options plus the `:soundread` option.
25
+ #
26
+ # @!scope class
27
+ set_available_options default_available_options + [:soundread]
28
+
29
+ ##
30
+ # Loads and decodes a `.wav` file into raw PCM data.
31
+ #
32
+ # @raise [AgumentError]
33
+ # if the file does not have a `.wav` extension.
34
+ #
35
+ # @return [Array<(Numo::NArray, Integer, Integer)>]
36
+ # Returns an array containing:
37
+ # - samples: A [Numo::NArray] of decoded audio samples
38
+ # - sample_rate: Integer sample rate (Hz)
39
+ # - channels: Integer number of audio channels
40
+ #
41
+ # @note The decoding method is chosen dynamically:
42
+ # - If there are no available decoders, or if `:soundread` is enabled,
43
+ # it will use the {#soundread} method.
44
+ # - Otherwise, it will use shell based decoding.
45
+ #
46
+ def load
47
+ output_data = if no_decoders? || soundread?
48
+ soundread
49
+ else
50
+ shell_load sox_options: { raw: true }
51
+ end
52
+
53
+ process(*output_data)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # The Awaaz module serves as the top-level namespace for all components
5
+ # of the Awaaz gem, which provides audio decoding, resampling, and analysis tools.
6
+ #
7
+ # This file defines the core exception classes used throughout the library.
8
+ #
9
+ # @see Awaaz::Config for configuration handling
10
+ # @see Awaaz::Decoders for decoder implementations
11
+ #
12
+ module Awaaz
13
+ ##
14
+ # Raised when no suitable audio decoder is found on the system.
15
+ #
16
+ # This error is typically raised when attempting to decode an audio file
17
+ # but none of the configured or available decoders (e.g., `mpg123`, `ffmpeg`, `sox`)
18
+ # are detected or usable.
19
+ #
20
+ # @example
21
+ # raise Awaaz::DecoderNotFound, "No decoders available"
22
+ #
23
+ class DecoderNotFound < ArgumentError; end
24
+
25
+ ##
26
+ # Raised when an error occurs during the resampling process.
27
+ #
28
+ # This error generally indicates an issue with the resampling library
29
+ # or invalid audio data being passed for resampling.
30
+ #
31
+ # @example
32
+ # raise Awaaz::ResamplingError, "Invalid resampling ratio"
33
+ #
34
+ class ResamplingError < StandardError; end
35
+
36
+ ##
37
+ # Raised when the {https://github.com/beetbox/audioread audioread} backend
38
+ # encounters an error while decoding audio files.
39
+ #
40
+ # This can happen when the file format is unsupported or if
41
+ # the decoding process fails unexpectedly.
42
+ #
43
+ # @example
44
+ # raise Awaaz::AudioreadError, "Failed to read audio file"
45
+ #
46
+ class AudioreadError < StandardError; end
47
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "soundfile"
4
+ require_relative "samplerate"
5
+
6
+ module Awaaz
7
+ # The Extensions namespace contains low-level bindings and helper classes
8
+ # for audio file reading and resampling.
9
+ #
10
+ # @note These extensions are generally implemented using {FFI}
11
+ # and provide direct access to C libraries like `libsndfile` and `libsamplerate`.
12
+ #
13
+ # @see Extensions::Soundfile
14
+ # @see Extensions::Samplerate
15
+ module Extensions
16
+ end
17
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Awaaz
4
+ ##
5
+ # The Extensions module is a namespace for FFI-based bindings to external libraries.
6
+ module Extensions
7
+ ##
8
+ # The Samplerate module provides Ruby bindings to the `libsamplerate` C library
9
+ # using the FFI (Foreign Function Interface).
10
+ #
11
+ # This module enables high-quality audio resampling directly from Ruby.
12
+ #
13
+ # @see https://libsndfile.github.io/libsamplerate/api.html Official libsamplerate API documentation
14
+ module Samplerate
15
+ extend FFI::Library
16
+
17
+ # Load the libsamplerate shared library
18
+ ffi_lib "samplerate"
19
+
20
+ # rubocop:disable Naming/ClassAndModuleCamelCase
21
+
22
+ ##
23
+ # Structure representing the parameters for a sample rate conversion operation.
24
+ #
25
+ # Mirrors the C struct `SRC_DATA` from libsamplerate.
26
+ #
27
+ # @!attribute [rw] data_in
28
+ # @return [FFI::Pointer] Pointer to the input audio buffer.
29
+ # @!attribute [rw] data_out
30
+ # @return [FFI::Pointer] Pointer to the output audio buffer.
31
+ # @!attribute [rw] input_frames
32
+ # @return [Integer] Number of input frames.
33
+ # @!attribute [rw] output_frames
34
+ # @return [Integer] Number of output frames allocated.
35
+ # @!attribute [rw] input_frames_used
36
+ # @return [Integer] Number of input frames actually used.
37
+ # @!attribute [rw] output_frames_gen
38
+ # @return [Integer] Number of output frames generated.
39
+ # @!attribute [rw] end_of_input
40
+ # @return [Integer] Flag (0 or 1) indicating whether this is the last block of input data.
41
+ # @!attribute [rw] src_ratio
42
+ # @return [Float] Conversion ratio (output_sample_rate / input_sample_rate).
43
+ class SRC_DATA < FFI::Struct
44
+ layout :data_in, :pointer,
45
+ :data_out, :pointer,
46
+ :input_frames, :long,
47
+ :output_frames, :long,
48
+ :input_frames_used, :long,
49
+ :output_frames_gen, :long,
50
+ :end_of_input, :int,
51
+ :src_ratio, :double
52
+ end
53
+
54
+ # rubocop:enable Naming/ClassAndModuleCamelCase
55
+
56
+ ##
57
+ # Performs a simple sample rate conversion.
58
+ #
59
+ attach_function :src_simple, [SRC_DATA.by_ref, :int, :int], :int
60
+
61
+ ##
62
+ # Converts an error code to a human-readable error message.
63
+ #
64
+ # @return [String] Human-readable error message.
65
+ attach_function :src_strerror, [:int], :string
66
+
67
+ # --- Converter type constants ---
68
+
69
+ # Best quality sinc-based sample rate converter.
70
+ SRC_SINC_BEST_QUALITY = 0
71
+
72
+ # Medium quality sinc-based converter.
73
+ SRC_SINC_MEDIUM_QUALITY = 1
74
+
75
+ # Fastest sinc-based converter (lower quality).
76
+ SRC_SINC_FASTEST = 2
77
+
78
+ # Zero-order hold converter (lowest quality, fastest).
79
+ SRC_ZERO_ORDER_HOLD = 3
80
+
81
+ # Linear interpolation converter (low quality, very fast).
82
+ SRC_LINEAR = 4
83
+
84
+ class << self
85
+ ##
86
+ # Maps a symbolic or numeric option to a libsamplerate converter type constant.
87
+ #
88
+ # @param option [Integer, Symbol] Converter type, either as an integer constant
89
+ # or a symbol (:sinc_best_quality, :linear, etc.).
90
+ # @return [Integer] The corresponding converter type constant.
91
+ # @raise [ArgumentError] If the option is not recognized.
92
+ #
93
+ # @example Using symbols
94
+ # Extensions::Samplerate.resample_option(:sinc_best_quality)
95
+ # # => 0
96
+ #
97
+ # @example Using integers
98
+ # Extensions::Samplerate.resample_option(:linear)
99
+ # # => 4
100
+ def resample_option(option)
101
+ case option
102
+ when 0, :sinc_best_quality then SRC_SINC_BEST_QUALITY
103
+ when 1, :sinc_medium_quality then SRC_SINC_MEDIUM_QUALITY
104
+ when 2, :sinc_fastest then SRC_SINC_FASTEST
105
+ when 3, :zero_order_hold then SRC_ZERO_ORDER_HOLD
106
+ when 4, :linear then SRC_LINEAR
107
+ else
108
+ raise ArgumentError, "Not found"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Awaaz
4
+ ##
5
+ # The Extensions module is a namespace for FFI-based bindings to external libraries.
6
+ module Extensions
7
+ ##
8
+ # The Soundfile module provides Ruby bindings to the `libsndfile` C library
9
+ # using the FFI (Foreign Function Interface).
10
+ #
11
+ # It allows reading audio file metadata and sample data directly from Ruby.
12
+ #
13
+ # @see http://www.mega-nerd.com/libsndfile/ Official libsndfile documentation
14
+ module Soundfile
15
+ extend FFI::Library
16
+
17
+ # Load the libsndfile shared library
18
+ ffi_lib "sndfile"
19
+
20
+ ##
21
+ # Open mode for reading sound files.
22
+ # @return [Integer] Bitmask flag for read mode.
23
+ SFM_READ = 0x10
24
+
25
+ # rubocop:disable Naming/ClassAndModuleCamelCase
26
+
27
+ ##
28
+ # Structure containing metadata about an audio file.
29
+ #
30
+ # Mirrors the C struct `SF_INFO` from libsndfile.
31
+ #
32
+ # @!attribute [rw] frames
33
+ # @return [Integer] Total number of frames in the file.
34
+ # @!attribute [rw] samplerate
35
+ # @return [Integer] Sample rate of the audio file (Hz).
36
+ # @!attribute [rw] channels
37
+ # @return [Integer] Number of audio channels.
38
+ # @!attribute [rw] format
39
+ # @return [Integer] Format identifier of the audio file.
40
+ # @!attribute [rw] sections
41
+ # @return [Integer] Number of sections in the file.
42
+ # @!attribute [rw] seekable
43
+ # @return [Integer] Whether the file is seekable (1) or not (0).
44
+ class SF_INFO < FFI::Struct
45
+ layout :frames, :long_long,
46
+ :samplerate, :int,
47
+ :channels, :int,
48
+ :format, :int,
49
+ :sections, :int,
50
+ :seekable, :int
51
+ end
52
+
53
+ # rubocop:enable Naming/ClassAndModuleCamelCase
54
+
55
+ ##
56
+ # Opens an audio file and returns a pointer to the file handle.
57
+ #
58
+ # @see http://www.mega-nerd.com/libsndfile/api.html#open
59
+ attach_function :sf_open, %i[string int pointer], :pointer
60
+
61
+ ##
62
+ # Reads floating-point audio frames from an open file.
63
+ #
64
+ # @return [Integer] Number of frames actually read.
65
+ attach_function :sf_readf_float, %i[pointer pointer long_long], :long_long
66
+
67
+ ##
68
+ # Closes an open audio file.
69
+ #
70
+ # @return [Integer] Zero on success, non-zero on error.
71
+ attach_function :sf_close, [:pointer], :int
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Awaaz
4
+ module Utils
5
+ ##
6
+ # Resample utilities for audio data represented as Numo::NArray.
7
+ # Wraps the `libsamplerate` bindings provided by {Extensions::Samplerate}.
8
+ #
9
+ # @note This module is intended for internal use, but `read_and_resample_numo`
10
+ # is public for advanced users who need manual resampling.
11
+ module Resample
12
+ class << self
13
+ ##
14
+ # Resamples a Numo::SFloat array of audio samples from one sample rate to another.
15
+ #
16
+ # @param input_samples [Numo::SFloat] The audio samples to resample.
17
+ # @param input_rate [Integer] The original sample rate (Hz).
18
+ # @param output_rate [Integer] The desired sample rate (Hz).
19
+ # @param sampling_option [Symbol, Integer] The resampling quality option.
20
+ # Can be one of:
21
+ # * `:sinc_best_quality` (0)
22
+ # * `:sinc_medium_quality` (1)
23
+ # * `:sinc_fastest` (2)
24
+ # * `:zero_order_hold` (3)
25
+ # * `:linear` (4)
26
+ #
27
+ # @return [Numo::SFloat] The resampled audio data.
28
+ #
29
+ # @raise [ArgumentError] If inputs are invalid or ratio is out of range.
30
+ # @raise [Awaaz::ResampleError] If `libsamplerate` returns an error.
31
+ #
32
+ # @example Resample 44.1kHz mono audio to 48kHz
33
+ # samples = Numo::SFloat.new(44100).rand
34
+ # new_samples = Awaaz::Utils::Resample.read_and_resample_numo(samples, 44100, 48000)
35
+ def read_and_resample_numo(input_samples, input_rate, output_rate, sampling_option: :sinc_best_quality)
36
+ validate_inputs(input_samples, input_rate, output_rate)
37
+
38
+ ratio = calculate_ratio(input_rate, output_rate)
39
+ input_ptr, output_ptr, input_frames, output_frames = prepare_memory(input_samples, ratio)
40
+
41
+ data = build_src_data(input_ptr, output_ptr, input_frames, output_frames, ratio)
42
+ perform_resampling(data, sampling_option)
43
+
44
+ convert_to_numo(output_ptr, data[:output_frames_gen])
45
+ end
46
+
47
+ private
48
+
49
+ ##
50
+ # Validates that the provided inputs are of the correct type and configuration.
51
+ #
52
+ # @param samples [Numo::NArray] The input samples.
53
+ # @param input_rate [Integer]
54
+ # @param output_rate [Integer]
55
+ #
56
+ # @raise [ArgumentError] If samples are not a Numo::SFloat array.
57
+ def validate_inputs(samples, input_rate, output_rate)
58
+ return if input_rate != output_rate && samples.is_a?(Numo::NArray)
59
+
60
+ raise ArgumentError, "Input must be a Numo::SFloat array" unless samples.is_a?(Numo::NArray)
61
+ end
62
+
63
+ ##
64
+ # Calculates and validates the resampling ratio.
65
+ #
66
+ # @param input_rate [Integer]
67
+ # @param output_rate [Integer]
68
+ #
69
+ # @return [Float] The ratio of output_rate to input_rate.
70
+ # @raise [ArgumentError] If ratio is outside the allowed range.
71
+ def calculate_ratio(input_rate, output_rate)
72
+ ratio = output_rate / input_rate.to_f
73
+ raise ArgumentError, "Bad ratio" if ratio < 1.0 / 256 || ratio > 256
74
+
75
+ ratio
76
+ end
77
+
78
+ ##
79
+ # Allocates and prepares FFI memory for the input and output buffers.
80
+ #
81
+ # @param input_samples [Numo::NArray]
82
+ # @param ratio [Float] The resampling ratio.
83
+ #
84
+ # @return [Array<FFI::MemoryPointer, FFI::MemoryPointer, Integer, Integer>]
85
+ def prepare_memory(input_samples, ratio)
86
+ input_frames = input_samples.size
87
+ output_frames = (input_frames * ratio).to_i
88
+
89
+ input_ptr = FFI::MemoryPointer.new(:float, input_frames)
90
+ input_ptr.write_bytes(input_samples.to_string)
91
+
92
+ output_ptr = FFI::MemoryPointer.new(:float, output_frames)
93
+
94
+ [input_ptr, output_ptr, input_frames, output_frames]
95
+ end
96
+
97
+ ##
98
+ # Builds the {Extensions::Samplerate::SRC_DATA} struct for `libsamplerate`.
99
+ #
100
+ # @param input_ptr [FFI::MemoryPointer]
101
+ # @param output_ptr [FFI::MemoryPointer]
102
+ # @param input_frames [Integer]
103
+ # @param output_frames [Integer]
104
+ # @param ratio [Float]
105
+ #
106
+ # @return [Extensions::Samplerate::SRC_DATA]
107
+ def build_src_data(input_ptr, output_ptr, input_frames, output_frames, ratio)
108
+ Extensions::Samplerate::SRC_DATA.new.tap do |data|
109
+ data[:data_in] = input_ptr
110
+ data[:data_out] = output_ptr
111
+ data[:input_frames] = input_frames
112
+ data[:output_frames] = output_frames
113
+ data[:end_of_input] = 1
114
+ data[:src_ratio] = ratio
115
+ end
116
+ end
117
+
118
+ ##
119
+ # Performs the resampling using `libsamplerate`.
120
+ #
121
+ # @param data [Extensions::Samplerate::SRC_DATA]
122
+ # @param sampling_option [Symbol, Integer]
123
+ #
124
+ # @raise [Awaaz::ResampleError] If resampling fails.
125
+ def perform_resampling(data, sampling_option)
126
+ err = Extensions::Samplerate.src_simple(data, Extensions::Samplerate.resample_option(sampling_option), 1)
127
+ raise Awaaz::ResampleError, "Resampling failed: #{Extensions::Samplerate.src_strerror(err)}" if err != 0
128
+ end
129
+
130
+ ##
131
+ # Converts the output FFI pointer back into a Numo::SFloat array.
132
+ #
133
+ # @param output_ptr [FFI::MemoryPointer]
134
+ # @param size [Integer] Number of frames generated.
135
+ #
136
+ # @return [Numo::SFloat]
137
+ def convert_to_numo(output_ptr, size)
138
+ Numo::SFloat.cast(output_ptr.read_array_of_float(size))
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Awaaz
4
+ module Utils
5
+ ##
6
+ # A utility class to construct shell commands in a safe and composable way.
7
+ #
8
+ # This class supports chaining methods to add arguments, flags, and options,
9
+ # and finally output the full shell command string.
10
+ #
11
+ # @example Build a simple FFmpeg command
12
+ # cmd = ShellCommandBuilder.new(:ffmpeg)
13
+ # .add_flag("-nostdin")
14
+ # .add_option("-i", "input.mp3")
15
+ # .add_option("-ar", 44100)
16
+ # .command
17
+ # # => "ffmpeg -nostdin -i input.mp3 -ar 44100"
18
+ #
19
+ class ShellCommandBuilder
20
+ ##
21
+ # Initializes a new shell command builder.
22
+ #
23
+ # @param base [String, Symbol, nil] The base command (e.g., `:ffmpeg` or `"ls"`).
24
+ #
25
+ def initialize(base = nil)
26
+ @args = [base.to_s]
27
+ end
28
+
29
+ ##
30
+ # Adds a positional argument to the command.
31
+ #
32
+ # @param arg [String, Symbol, Numeric] The argument to add.
33
+ # @return [ShellCommandBuilder] self, for chaining.
34
+ #
35
+ def add_arg(arg)
36
+ @args << arg.to_s.strip
37
+ self
38
+ end
39
+
40
+ ##
41
+ # Alias for {#add_arg}, used for flags.
42
+ #
43
+ # @see #add_arg
44
+ alias add_flag add_arg
45
+
46
+ ##
47
+ # Adds an option with a value to the command.
48
+ #
49
+ # @param option [String, Symbol] The option flag (e.g., `-i`).
50
+ # @param value [String, Symbol, Numeric] The value for the option.
51
+ # @param with_colon [Boolean] Whether to separate the option and value with a colon (`:`)
52
+ # instead of a space.
53
+ # @return [ShellCommandBuilder] self, for chaining.
54
+ #
55
+ # @example Space-separated
56
+ # builder.add_option("-i", "file.mp3")
57
+ # # => "-i file.mp3"
58
+ #
59
+ # @example Colon-separated
60
+ # builder.add_option("--volume", 10, with_colon: true)
61
+ # # => "--volume:10"
62
+ #
63
+ def add_option(option, value, with_colon: false)
64
+ @args << "#{option}#{with_colon ? ":" : " "}#{value}"
65
+ self
66
+ end
67
+
68
+ ##
69
+ # Adds multiple arguments or options at once.
70
+ #
71
+ # @param args_array [Array<Array>] An array of argument definitions.
72
+ # Each element should be `[type, args]` where:
73
+ # - `type` is `:arg` or `:flag` for positional arguments/flags
74
+ # - otherwise, treated as an option for {#add_option}
75
+ #
76
+ # @return [ShellCommandBuilder] self, for chaining.
77
+ #
78
+ # @example Adding multiple
79
+ # builder.add_multiple_args([
80
+ # [:flag, ["-q"]],
81
+ # [:option, ["-i", "file.mp3"]],
82
+ # [:arg, ["extra"]]
83
+ # ])
84
+ #
85
+ def add_multiple_args(args_array)
86
+ args_array.each do |shell_args|
87
+ arg_type, args = shell_args
88
+ arg_type = arg_type.to_sym
89
+ %i[arg flag].include?(arg_type) ? add_arg(*args) : add_option(*args)
90
+ end
91
+ self
92
+ end
93
+
94
+ ##
95
+ # Builds and returns the complete shell command string.
96
+ #
97
+ # @return [String] The constructed command.
98
+ #
99
+ def command
100
+ @args.join(" ")
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Awaaz
4
+ module Utils
5
+ ##
6
+ # SoundConfig holds and validates configuration options used for audio processing.
7
+ #
8
+ # It ensures that only valid options are passed in and provides convenience
9
+ # accessors for common audio processing settings such as sample rate, channel count,
10
+ # amplification factor, and decoder preferences.
11
+ #
12
+ # @example Creating a SoundConfig with valid options
13
+ # valid_keys = [:sample_rate, :mono, :amplification_factor, :decoder]
14
+ # config = Awaaz::Utils::SoundConfig.new(valid_keys, sample_rate: 44100, mono: true)
15
+ #
16
+ class SoundConfig
17
+ ##
18
+ # Initializes a new SoundConfig instance.
19
+ #
20
+ # @param valid_options [Array<Symbol,String>] The list of allowed option keys.
21
+ # @param options [Hash] The configuration options to store.
22
+ # @option options [Integer] :sample_rate The audio sample rate in Hz.
23
+ # @option options [Boolean] :mono Whether to process audio in mono (true) or stereo (false).
24
+ # @option options [Boolean] :soundread Whether to use soundread for processing.
25
+ # @option options [Integer] :amplification_factor The amplification factor (default: 32768).
26
+ # @option options [Symbol,String] :decoder The preferred audio decoder.
27
+ #
28
+ # @raise [ArgumentError] If any provided option key is not in +valid_options+.
29
+ #
30
+ def initialize(valid_options, **options)
31
+ @options = options
32
+ @valid_options = valid_options
33
+
34
+ prepare
35
+ end
36
+
37
+ ##
38
+ # The sample rate for audio processing.
39
+ #
40
+ # @return [Integer] The sample rate in Hz (default: 22050).
41
+ #
42
+ def sample_rate
43
+ from_options(:sample_rate) || 22_050
44
+ end
45
+
46
+ ##
47
+ # Whether to process audio in mono.
48
+ #
49
+ # @return [Boolean] +true+ if mono, otherwise +false+.
50
+ #
51
+ def mono
52
+ from_options(:mono) || false
53
+ end
54
+
55
+ ##
56
+ # Convenience method to check if audio is mono.
57
+ #
58
+ # @return [Boolean]
59
+ #
60
+ def mono?
61
+ mono
62
+ end
63
+
64
+ ##
65
+ # Convenience method to check if audio is stereo.
66
+ #
67
+ # @return [Boolean]
68
+ #
69
+ def stereo?
70
+ !mono?
71
+ end
72
+
73
+ ##
74
+ # Whether to use soundread for audio processing.
75
+ #
76
+ # @return [Boolean]
77
+ #
78
+ def soundread?
79
+ from_options(:soundread) == true
80
+ end
81
+
82
+ ##
83
+ # The number of audio channels.
84
+ #
85
+ # @return [Integer] 1 for mono, 2 for stereo.
86
+ #
87
+ def num_channels
88
+ mono? ? 1 : 2
89
+ end
90
+
91
+ ##
92
+ # The amplification factor for audio processing.
93
+ #
94
+ # @return [Integer] Defaults to 32768.
95
+ #
96
+ def amplification_factor
97
+ (from_options(:amplification_factor) || 32_768).to_i
98
+ end
99
+
100
+ ##
101
+ # The preferred audio decoder.
102
+ #
103
+ # @return [Symbol, nil] The decoder symbol if set, otherwise +nil+.
104
+ #
105
+ def decoder_option
106
+ from_options(:decoder)&.to_sym
107
+ end
108
+
109
+ private
110
+
111
+ ##
112
+ # Fetches a value from @options using either symbol or string key.
113
+ #
114
+ # @param key [Symbol,String] The key to look up.
115
+ # @return [Object, nil] The value if present, otherwise +nil+.
116
+ #
117
+ def from_options(key)
118
+ @options[key.to_s] || @options[key.to_sym]
119
+ end
120
+
121
+ ##
122
+ # Validates that all provided options are in the allowed list.
123
+ #
124
+ # @raise [ArgumentError] If any provided key is invalid.
125
+ #
126
+ def prepare
127
+ @options.each_key do |key|
128
+ next if @valid_options.include?(key.to_sym) || @valid_options.include?(key.to_s)
129
+
130
+ raise ArgumentError, "Invalid key passed: #{key}. Possible keys: #{@valid_options.join(",")}"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end