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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fbecd6bac9e54aff63e2d7bd8376bbbbcfa99912e50da7904476a1cd95af3470
4
+ data.tar.gz: bb24ac976ad9e2aef0258c3d490b4b9da7d11cb402cad15f415bf1e59e2d59cd
5
+ SHA512:
6
+ metadata.gz: 305b93d7e7548ec86946914a4859ac83bf92f5ca17c85eb541161366946d491bf5e177a954f370195af5c9cc67592ff4449c416b8522c5fb45ae05a40edd072a
7
+ data.tar.gz: 6251e1a64e7d4f7f44b4bb91e3d45a370399fa5d684519856e1c230a560c7b5a160ceb70791149858fcd46b087ffe59213def3041945dfcdc87aa8ba568ad83d
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,95 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.2
3
+ NewCops: disable
4
+
5
+ Style/StringLiterals:
6
+ EnforcedStyle: double_quotes
7
+ Exclude:
8
+ - 'spec/**/*'
9
+ - 'sound_util.gemspec'
10
+
11
+ Style/StringLiteralsInInterpolation:
12
+ EnforcedStyle: double_quotes
13
+
14
+ Style/FrozenStringLiteralComment:
15
+ Enabled: false
16
+
17
+ Style/Documentation:
18
+ Enabled: false
19
+
20
+ Layout/LineLength:
21
+ Max: 170
22
+
23
+ Metrics/BlockLength:
24
+ Enabled: false
25
+ Metrics/ClassLength:
26
+ Enabled: false
27
+ Metrics/MethodLength:
28
+ Enabled: false
29
+ Metrics/AbcSize:
30
+ Enabled: false
31
+ Metrics/CyclomaticComplexity:
32
+ Enabled: false
33
+ Metrics/PerceivedComplexity:
34
+ Enabled: false
35
+
36
+ Naming/MethodParameterName:
37
+ Enabled: false
38
+
39
+ Lint/SuppressedException:
40
+ Enabled: false
41
+
42
+ Style/MultilineBlockChain:
43
+ Enabled: false
44
+
45
+ Style/PerlBackrefs:
46
+ Enabled: false
47
+
48
+ Style/FormatStringToken:
49
+ Enabled: false
50
+ Style/FormatString:
51
+ Enabled: false
52
+
53
+ Layout/SpaceAfterComma:
54
+ Enabled: false
55
+ Layout/SpaceAroundOperators:
56
+ Enabled: false
57
+ Layout/TrailingWhitespace:
58
+ Enabled: false
59
+
60
+ Style/IfUnlessModifier:
61
+ Enabled: false
62
+ Style/NilComparison:
63
+ Enabled: false
64
+ Style/NumericPredicate:
65
+ Enabled: false
66
+
67
+ Lint/DuplicateMethods:
68
+ Enabled: false
69
+
70
+ Lint/Void:
71
+ Enabled: false
72
+
73
+ Layout/SpaceBeforeBlockBraces:
74
+ Enabled: false
75
+
76
+ Lint/UnusedBlockArgument:
77
+ Enabled: false
78
+
79
+ Naming/AccessorMethodName:
80
+ Enabled: false
81
+
82
+ Style/SingleLineMethods:
83
+ Enabled: false
84
+
85
+ Layout/EmptyLineAfterGuardClause:
86
+ Enabled: false
87
+
88
+ Style/IfInsideElse:
89
+ Enabled: false
90
+
91
+ Style/RedundantBegin:
92
+ Enabled: false
93
+
94
+ Style/SymbolProc:
95
+ Enabled: false
data/AGENTS.md ADDED
@@ -0,0 +1,12 @@
1
+ # Contributor Guidelines
2
+
3
+ - Specs mirror file structure under `lib`. For example, `lib/sound_util/buffer.rb` has a spec at `spec/buffer_spec.rb`.
4
+ - Use `autoload` for loading internal files. Avoid `require` and `require_relative` for internal files.
5
+ - Start every Ruby file with `# frozen_string_literal: true`.
6
+ - Prefer double-quoted strings except in specs and the gemspec.
7
+ - Use RSpec's `should` syntax instead of `expect`.
8
+ - For one-line methods, use the `def name = expression` style.
9
+ - After adding new features or modifying existing ones, update documentation accordingly (README and CHANGELOG).
10
+ - Specs target at least 80% coverage as enforced by SimpleCov.
11
+ - The library aims to remain lightweight and portable.
12
+ - Ensure `rake` tests pass and Rubocop doesn't complain.
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ ## [Unreleased]
2
+
3
+ - Add `SoundUtil::Wave` and `Wave::Buffer` with `IO::Buffer`-backed PCM storage.
4
+ - Introduce generator/filter/sink subsystems mirroring `image_util`: tone generators (`.sine`, `.silence`), gain/fade filters, playback helper (`Wave#play`), and immutable/bang variants.
5
+ - Wave constructor now defaults to mono 44.1 kHz one-second buffers; blocks accept scalar or per-channel samples.
6
+ - Add `Wave#[]`/`Wave#[]=` with float conversions and range-aware slicing/assignment.
7
+ - Add wave-combination helpers (`#+`/`#<<`, `#|`, `#&`) backed by generator-driven filters plus `Wave#channel` extraction.
8
+ - Introduce WAV codec with magic detection, multi-format PCM/float support, and `Wave.from_data`/`from_file`/`#to_string(:wav)` helpers.
9
+ - Adopt ImageUtil v0.5.0 inspectable interface for `SoundUtil::Wave` pretty-printing.
10
+ - Add linear `Wave#resample` filter for sample rate/frame conversion.
11
+ - Add `Wave#preview` sink for ImageUtil-based waveform charts.
12
+ - CLI `generate` command for sine and silence waveforms; emit raw PCM suitable for piping to sound cards.
13
+ - Set up GitHub Actions CI workflow
14
+
15
+ ## [0.1.0] - 2025-09-05
16
+
17
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 sound_util contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # SoundUtil
2
+
3
+ [![CI](https://github.com/rbutils/sound_util/actions/workflows/ci.yml/badge.svg)](https://github.com/rbutils/sound_util/actions/workflows/ci.yml)
4
+
5
+ SoundUtil is a lightweight Ruby library focused on manipulating sound data directly in memory. Its primary goal is to help scripts process and analyze audio buffers using `IO::Buffer`. The API is still evolving and should be considered unstable until version 1.0.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem "sound_util"
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```sh
18
+ bundle install
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Generate a 440 Hz stereo sine wave and stream it to stdout (pipeable into
24
+ `aplay`, `ffplay`, etc.):
25
+
26
+ ```ruby
27
+ require "sound_util"
28
+
29
+ duration = 2.0
30
+ sample_rate = 44_100
31
+ channels = 2
32
+
33
+ wave = SoundUtil::Wave.sine(
34
+ duration_seconds: duration,
35
+ sample_rate: sample_rate,
36
+ channels: channels,
37
+ frequency: 440.0,
38
+ amplitude: 0.6
39
+ )
40
+
41
+ wave.pipe($stdout)
42
+ ```
43
+
44
+ Blocks passed to `SoundUtil::Wave.new` yield the frame index and may return a
45
+ scalar (applied to all channels) or an array of per-channel sample values.
46
+ Floats are treated as `-1.0..1.0` amplitudes and integers are clamped to the
47
+ target PCM range.
48
+
49
+ ### Filters
50
+
51
+ Waves provide mutable/immutable filters similar to `image_util`:
52
+
53
+ ```ruby
54
+ wave = SoundUtil::Wave.sine(duration_seconds: 1, frequency: 220)
55
+
56
+ wave = wave.gain(0.25) # return a quieter copy
57
+ wave.fade_in!(seconds: 0.1) # in-place fade-in over the first 0.1s
58
+ wave.fade_out!(seconds: 0.1) # in-place fade-out over the last 0.1s
59
+ wave = wave.resample(48_000) # adjust sample rate with linear interpolation
60
+ ```
61
+
62
+ `Wave#resample(rate, frames: nil)` performs linear interpolation and updates both sample rate and frame count (pass `frames:` to override duration).
63
+
64
+ ### WAV input/output
65
+
66
+ ```ruby
67
+ wave = SoundUtil::Wave.from_file("input.wav")
68
+ bytes = wave.to_string(:wav) # defaults to source format
69
+
70
+ wave = SoundUtil::Wave.new(frames: 2) { 0.2 }
71
+ wave.to_file("output.wav") # writes RIFF/WAVE by default
72
+
73
+ io = StringIO.new
74
+ wave.to_file(io, :wav, sample_format: :f32le) # encode with custom format
75
+ ```
76
+
77
+ `SoundUtil::Wave.from_data(data, format: :wav)` decodes in-memory WAV blobs,
78
+ while `from_file` accepts paths or IO objects (auto-detecting formats via the
79
+ Magic helper). Encoding supports the common PCM/float variants (`:u8`,
80
+ `:s16le`, `:s24le`, `:s32le`, `:f32le`, `:f64le`). Pass `sample_format:` when
81
+ you need to transcode to a different width before writing.
82
+
83
+ ### Combining waves
84
+
85
+ ```ruby
86
+ intro = SoundUtil::Wave.sine(duration_seconds: 0.5, frequency: 440)
87
+ outro = SoundUtil::Wave.silence(duration_seconds: 0.25)
88
+
89
+ full = intro + outro # append, returns a new wave
90
+ intro << outro # append in place
91
+
92
+ vocals = SoundUtil::Wave.sine(duration_seconds: 0.5, frequency: 220)
93
+ mix = vocals | intro # mix with sample clamping
94
+
95
+ stereo = vocals & intro # stack channels together
96
+ mono = stereo.channel(0) # extract an individual channel
97
+ ```
98
+
99
+ `#+` and `#<<` append clips end-to-end (requiring matching sample rates, formats,
100
+ and channel counts). `#|` mixes two clips frame-by-frame with automatic
101
+ clamping, and `#&` stacks their channels using the longest duration from either
102
+ wave. Use `Wave#channel(index)` to materialize a single channel as its own
103
+ wave.
104
+
105
+ ```ruby
106
+ wave.play # pipes to the default `aplay` command
107
+ wave.play(command: ["ffplay", "-autoexit", "-nodisp", "-f", "s16le", "-ar", wave.sample_rate.to_s, "-ac", wave.channels.to_s, "-"])
108
+ ```
109
+
110
+ `Wave#play` shells out to `aplay` by default. Provide `command:` with a string/array when targeting other tools (e.g. `ffplay`, `afplay`).
111
+
112
+ ### Indexing
113
+
114
+ ```ruby
115
+ wave[0] # => [Float, Float] for all channels in the first frame
116
+ wave[0, 1] # => Float for a specific channel
117
+ wave[100..200] # => new Wave containing that frame range
118
+ wave[0..-1, 0] # => mono Wave extracted from channel 0
119
+
120
+ wave[0] = [0.5, -0.5]
121
+ wave[10..20] = SoundUtil::Wave.sine(duration_seconds: 0.25, frequency: 880)
122
+ ```
123
+
124
+ Values are normalised to the `-1.0..1.0` range when read and clamped when written.
125
+
126
+ ### Preview
127
+
128
+ ```ruby
129
+ SoundUtil::Wave.sine(duration_seconds: 1, frequency: 440).preview
130
+ ```
131
+
132
+ Renders a compact Sixel chart (requires ImageUtil and terminal support). Pass `width:`/`height:` options to adjust the preview
133
+
134
+ Wave instances now implement `inspect_image`, so pretty-printing in IRB (`pp wave`) renders the same preview via ImageUtil's inspectable interface.
135
+
136
+ ### CLI
137
+
138
+ The Thor CLI emits raw PCM suitable for piping into a sound device:
139
+
140
+ ```sh
141
+ bundle exec exe/sound_util generate sine \
142
+ --seconds 2 --rate 44100 --channels 2 --frequency 440 --amplitude 0.6 \
143
+ | aplay -f S16_LE -c 2 -r 44100
144
+ ```
145
+
146
+ Use `--output path.pcm` to write to a file instead of stdout.
147
+
148
+ ## Development
149
+
150
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rake` to run the tests and linter.
151
+
152
+ ## Contributing
153
+
154
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rbutils/sound_util.
155
+
156
+ ## License
157
+
158
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]
data/exe/sound_util ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "sound_util"
6
+
7
+ SoundUtil::CLI.start(ARGV)
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module SoundUtil
6
+ class CLI < Thor
7
+ desc "version", "Display SoundUtil version"
8
+ def version
9
+ puts SoundUtil::VERSION
10
+ end
11
+
12
+ desc "generate TYPE", "Generate PCM audio and write to stdout or file"
13
+ option :seconds, type: :numeric, default: 1.0, aliases: "-s", banner: "SECONDS"
14
+ option :rate, type: :numeric, default: 44_100, aliases: "-r", banner: "HZ"
15
+ option :channels, type: :numeric, default: 2, aliases: "-c", banner: "N"
16
+ option :frequency, type: :numeric, default: 440.0, aliases: "-f", banner: "HZ"
17
+ option :amplitude, type: :numeric, default: 1.0, aliases: "-a"
18
+ option :format, type: :string, default: "s16le"
19
+ option :output, type: :string, aliases: "-o"
20
+ def generate(type)
21
+ wave = case type
22
+ when "sine"
23
+ SoundUtil::Wave.sine(
24
+ duration_seconds: options[:seconds],
25
+ sample_rate: options[:rate],
26
+ channels: options[:channels],
27
+ frequency: options[:frequency],
28
+ amplitude: options[:amplitude],
29
+ format: options[:format]
30
+ )
31
+ when "silence"
32
+ SoundUtil::Wave.silence(
33
+ duration_seconds: options[:seconds],
34
+ sample_rate: options[:rate],
35
+ channels: options[:channels],
36
+ format: options[:format]
37
+ )
38
+ else
39
+ raise ArgumentError, "unsupported generator: #{type}"
40
+ end
41
+
42
+ write_wave(wave)
43
+ end
44
+
45
+ no_commands do
46
+ def write_wave(wave)
47
+ if options[:output]
48
+ File.open(options[:output], "wb") { |io| wave.pipe(io) }
49
+ else
50
+ wave.pipe($stdout)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module SoundUtil
6
+ module Codec
7
+ # rubocop:disable Metrics/ModuleLength
8
+ module Wav
9
+ PCM = 0x0001
10
+ IEEE_FLOAT = 0x0003
11
+ EXTENSIBLE = 0xFFFE
12
+
13
+ SUBFORMAT_PCM = [1, 0, 0, 0, 0, 0, 16, 0, 128, 0, 0, 170, 0, 56, 155, 113].pack("C*").freeze
14
+ SUBFORMAT_IEEE_FLOAT = [3, 0, 0, 0, 0, 0, 16, 0, 128, 0, 0, 170, 0, 56, 155, 113].pack("C*").freeze
15
+
16
+ CHUNK_ALIGN = 2
17
+
18
+ SUPPORTED_FORMATS = {
19
+ u8: { bits_per_sample: 8, audio_format: PCM },
20
+ s16le: { bits_per_sample: 16, audio_format: PCM },
21
+ s24le: { bits_per_sample: 24, audio_format: PCM },
22
+ s32le: { bits_per_sample: 32, audio_format: PCM },
23
+ f32le: { bits_per_sample: 32, audio_format: IEEE_FLOAT },
24
+ f64le: { bits_per_sample: 64, audio_format: IEEE_FLOAT }
25
+ }.freeze
26
+
27
+ module_function
28
+
29
+ def supported?(format)
30
+ format == :wav
31
+ end
32
+
33
+ def decode(_format, data, **_kwargs)
34
+ io = StringIO.new(data)
35
+ decode_io(:wav, io)
36
+ end
37
+
38
+ def decode_io(_format, io, **_kwargs)
39
+ io.binmode if io.respond_to?(:binmode)
40
+
41
+ riff, _total_size, wave_id = read_riff_header(io)
42
+ raise UnsupportedFormatError, "unsupported RIFF type: #{riff}" unless riff == "RIFF"
43
+ raise UnsupportedFormatError, "not a WAVE file" unless wave_id == "WAVE"
44
+
45
+ fmt_chunk = nil
46
+ data_chunk = nil
47
+
48
+ until io.eof?
49
+ id = io.read(4)
50
+ break unless id
51
+
52
+ size = read_uint32(io)
53
+ payload = io.read(size)
54
+ raise UnsupportedFormatError, "unexpected EOF" if payload.nil? || payload.bytesize < size
55
+
56
+ io.read(1) if (size % CHUNK_ALIGN).positive?
57
+
58
+ case id
59
+ when "fmt "
60
+ fmt_chunk = payload
61
+ when "data"
62
+ data_chunk = payload
63
+ break if fmt_chunk
64
+ else
65
+ next
66
+ end
67
+ end
68
+
69
+ raise UnsupportedFormatError, "missing fmt chunk" unless fmt_chunk
70
+ raise UnsupportedFormatError, "missing data chunk" unless data_chunk
71
+
72
+ fmt = parse_fmt_chunk(fmt_chunk)
73
+ ensure_supported_format!(fmt)
74
+
75
+ validate_data_size!(data_chunk.bytesize, fmt)
76
+
77
+ SoundUtil::Wave.from_string(
78
+ data_chunk,
79
+ channels: fmt[:channels],
80
+ sample_rate: fmt[:sample_rate],
81
+ format: fmt[:internal_format]
82
+ )
83
+ end
84
+
85
+ def encode(_format, wave, sample_format: nil, bits_per_sample: nil)
86
+ io = StringIO.new
87
+ encode_io(:wav, wave, io, sample_format: sample_format, bits_per_sample: bits_per_sample)
88
+ io.string
89
+ end
90
+
91
+ def encode_io(_format, wave, io, sample_format: nil, bits_per_sample: nil)
92
+ io.binmode if io.respond_to?(:binmode)
93
+
94
+ format_symbol = determine_target_format(wave, sample_format, bits_per_sample)
95
+ spec = SUPPORTED_FORMATS.fetch(format_symbol)
96
+ bytes_per_sample = SoundUtil::Wave::SUPPORTED_FORMATS.fetch(format_symbol)[:bytes_per_sample]
97
+ block_align = bytes_per_sample * wave.channels
98
+ byte_rate = wave.sample_rate * block_align
99
+ bits = spec[:bits_per_sample]
100
+
101
+ data = export_wave_data(wave, format_symbol)
102
+ data_size = data.bytesize
103
+ data_padding = (data_size % CHUNK_ALIGN).positive? ? "\x00" : ""
104
+ data_chunk = String.new("data", encoding: Encoding::ASCII_8BIT)
105
+ data_chunk << [data_size].pack("V")
106
+ data_chunk << data
107
+ data_chunk << data_padding
108
+
109
+ fmt_body = [
110
+ spec[:audio_format],
111
+ wave.channels,
112
+ wave.sample_rate,
113
+ byte_rate,
114
+ block_align,
115
+ bits
116
+ ].pack("v2V2v2")
117
+
118
+ fmt_chunk = build_chunk("fmt ", fmt_body)
119
+ fact_chunk = spec[:audio_format] == PCM ? nil : build_chunk("fact", [wave.frames].pack("V"))
120
+
121
+ total_size = 4 + fmt_chunk.bytesize + (fact_chunk ? fact_chunk.bytesize : 0) + data_chunk.bytesize
122
+
123
+ io << "RIFF"
124
+ io << [total_size].pack("V")
125
+ io << "WAVE"
126
+ io << fmt_chunk
127
+ io << fact_chunk if fact_chunk
128
+ io << data_chunk
129
+ io
130
+ end
131
+
132
+ def build_chunk(id, payload)
133
+ size = payload.bytesize
134
+ id + [size].pack("V") + payload
135
+ end
136
+ private_class_method :build_chunk
137
+
138
+ def read_riff_header(io)
139
+ chunk_id = io.read(4)
140
+ size = read_uint32(io)
141
+ format = io.read(4)
142
+ [chunk_id, size, format]
143
+ end
144
+
145
+ def read_uint16(io)
146
+ data = io.read(2)
147
+ raise UnsupportedFormatError, "unexpected EOF" unless data && data.bytesize == 2
148
+
149
+ data.unpack1("v")
150
+ end
151
+
152
+ def read_uint32(io)
153
+ data = io.read(4)
154
+ raise UnsupportedFormatError, "unexpected EOF" unless data && data.bytesize == 4
155
+
156
+ data.unpack1("V")
157
+ end
158
+
159
+ def parse_fmt_chunk(data)
160
+ io = StringIO.new(data)
161
+
162
+ audio_format = read_uint16(io)
163
+ channels = read_uint16(io)
164
+ sample_rate = read_uint32(io)
165
+ byte_rate = read_uint32(io)
166
+ block_align = read_uint16(io)
167
+ bits_per_sample = read_uint16(io)
168
+
169
+ resolved_format = audio_format
170
+ valid_bits = bits_per_sample
171
+
172
+ if audio_format == EXTENSIBLE
173
+ cb_size = read_uint16(io)
174
+ raise UnsupportedFormatError, "invalid extensible header" if cb_size.nil? || cb_size < 22
175
+
176
+ valid_bits = read_uint16(io)
177
+ valid_bits = bits_per_sample if valid_bits.zero?
178
+ _channel_mask = read_uint32(io)
179
+ subformat = read_guid(io)
180
+ resolved_format = resolve_subformat(subformat)
181
+ skip = cb_size - 22
182
+ io.read(skip) if skip.positive?
183
+ end
184
+
185
+ {
186
+ audio_format: resolved_format,
187
+ channels: channels,
188
+ sample_rate: sample_rate,
189
+ byte_rate: byte_rate,
190
+ block_align: block_align,
191
+ bits_per_sample: valid_bits
192
+ }
193
+ end
194
+
195
+ def read_guid(io)
196
+ data = io.read(16)
197
+ raise UnsupportedFormatError, "unexpected EOF" unless data && data.bytesize == 16
198
+
199
+ data
200
+ end
201
+
202
+ def resolve_subformat(subformat)
203
+ case subformat
204
+ when SUBFORMAT_PCM
205
+ PCM
206
+ when SUBFORMAT_IEEE_FLOAT
207
+ IEEE_FLOAT
208
+ else
209
+ raise UnsupportedFormatError, "unsupported extensible subformat"
210
+ end
211
+ end
212
+
213
+ def ensure_supported_format!(fmt)
214
+ fmt[:internal_format] = case fmt[:audio_format]
215
+ when PCM
216
+ map_pcm_bits(fmt[:bits_per_sample])
217
+ when IEEE_FLOAT
218
+ map_float_bits(fmt[:bits_per_sample])
219
+ else
220
+ raise UnsupportedFormatError, "unsupported audio format #{fmt[:audio_format]}"
221
+ end
222
+ end
223
+
224
+ def map_pcm_bits(bits)
225
+ case bits
226
+ when 8 then :u8
227
+ when 16 then :s16le
228
+ when 24 then :s24le
229
+ when 32 then :s32le
230
+ else
231
+ raise UnsupportedFormatError, "unsupported PCM bit depth #{bits}"
232
+ end
233
+ end
234
+
235
+ def map_float_bits(bits)
236
+ case bits
237
+ when 32 then :f32le
238
+ when 64 then :f64le
239
+ else
240
+ raise UnsupportedFormatError, "unsupported float bit depth #{bits}"
241
+ end
242
+ end
243
+
244
+ def validate_data_size!(bytesize, fmt)
245
+ expected_stride = fmt[:block_align]
246
+ raise UnsupportedFormatError, "corrupt data chunk" unless (bytesize % expected_stride).zero?
247
+ end
248
+
249
+ def determine_target_format(wave, sample_format, bits)
250
+ return sample_format.to_sym if sample_format
251
+
252
+ if bits
253
+ return map_pcm_bits(bits) if [8, 16, 24, 32].include?(bits) && !%i[f32le f64le].include?(wave.format)
254
+ return map_float_bits(bits) if [32, 64].include?(bits) && %i[f32le f64le].include?(wave.format)
255
+ end
256
+
257
+ wave.format.tap do |fmt|
258
+ raise UnsupportedFormatError, "unsupported wave format #{fmt}" unless SUPPORTED_FORMATS.key?(fmt)
259
+ end
260
+ end
261
+
262
+ def export_wave_data(wave, target_format)
263
+ return wave.buffer.to_s if wave.format == target_format
264
+
265
+ converted = SoundUtil::Wave.new(
266
+ channels: wave.channels,
267
+ sample_rate: wave.sample_rate,
268
+ frames: wave.frames,
269
+ format: target_format
270
+ ) do |frame_idx|
271
+ wave[frame_idx]
272
+ end
273
+
274
+ converted.to_string
275
+ end
276
+ end
277
+ # rubocop:enable Metrics/ModuleLength
278
+ end
279
+ end