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
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
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
|
+
[](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
data/exe/sound_util
ADDED
|
@@ -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
|