audiowaveform 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/COPYING +674 -0
- data/Cargo.lock +1108 -0
- data/Cargo.toml +28 -0
- data/README.md +255 -0
- data/bindings/ruby/CHANGELOG.md +29 -0
- data/bindings/ruby/Cargo.lock +621 -0
- data/bindings/ruby/Cargo.toml +8 -0
- data/bindings/ruby/README.md +193 -0
- data/bindings/ruby/ext/audiowaveform/Cargo.toml +20 -0
- data/bindings/ruby/ext/audiowaveform/build.rs +4 -0
- data/bindings/ruby/ext/audiowaveform/extconf.rb +6 -0
- data/bindings/ruby/ext/audiowaveform/src/lib.rs +239 -0
- data/bindings/ruby/lib/audiowaveform/version.rb +5 -0
- data/bindings/ruby/lib/audiowaveform.rb +131 -0
- data/crates/audiowaveform/Cargo.toml +73 -0
- data/crates/audiowaveform/examples/generate_from_pcm.rs +25 -0
- data/crates/audiowaveform/examples/generate_waveform.rs +18 -0
- data/crates/audiowaveform/examples/render_waveform.rs +18 -0
- data/crates/audiowaveform/examples/resample_waveform.rs +19 -0
- data/crates/audiowaveform/src/audio.rs +938 -0
- data/crates/audiowaveform/src/color.rs +201 -0
- data/crates/audiowaveform/src/error.rs +89 -0
- data/crates/audiowaveform/src/format.rs +215 -0
- data/crates/audiowaveform/src/lib.rs +79 -0
- data/crates/audiowaveform/src/render.rs +802 -0
- data/crates/audiowaveform/src/wav.rs +95 -0
- data/crates/audiowaveform/src/waveform.rs +790 -0
- data/crates/audiowaveform/tests/formats.rs +223 -0
- data/crates/audiowaveform/tests/generate.rs +233 -0
- data/crates/audiowaveform/tests/render.rs +263 -0
- data/crates/audiowaveform/tests/support/mod.rs +125 -0
- data/crates/audiowaveform/tests/wav.rs +54 -0
- data/crates/audiowaveform/tests/waveform_io.rs +255 -0
- data/crates/audiowaveform-cli/Cargo.toml +43 -0
- data/crates/audiowaveform-cli/src/main.rs +803 -0
- data/crates/audiowaveform-cli/tests/cli.rs +483 -0
- data/crates/audiowaveform-cli/tests/support/mod.rs +111 -0
- data/sig/audiowaveform.rbs +33 -0
- metadata +100 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# audiowaveform for Ruby
|
|
2
|
+
|
|
3
|
+
The `audiowaveform` gem provides native Ruby bindings for the Rust
|
|
4
|
+
`audiowaveform` library. It generates waveform data in process without invoking
|
|
5
|
+
the command-line program. Audio decoding runs without Ruby's global VM lock, so
|
|
6
|
+
other Ruby threads can continue while a waveform is generated.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Add the gem to your bundle:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem "audiowaveform", "~> 0.1"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Releases include precompiled gems for CRuby 3.2, 3.3, 3.4, and 4.0 on these platforms:
|
|
17
|
+
|
|
18
|
+
| OS | Architectures | Gem platforms |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| Linux (glibc) | x86-64, ARM64 | `x86_64-linux-gnu`, `aarch64-linux-gnu` |
|
|
21
|
+
| Linux (musl/Alpine) | x86-64, ARM64 | `x86_64-linux-musl`, `aarch64-linux-musl` |
|
|
22
|
+
| macOS | Intel, Apple Silicon | `x86_64-darwin`, `arm64-darwin` |
|
|
23
|
+
| Windows (UCRT) | x86-64 | `x64-mingw-ucrt` |
|
|
24
|
+
|
|
25
|
+
Precompiled gems do not require Rust, FFmpeg, or compilation during installation.
|
|
26
|
+
They contain a separate extension for each supported Ruby minor version.
|
|
27
|
+
Other Ruby versions and platforms fall back to the source gem, which needs Rust
|
|
28
|
+
and Ruby development headers. Installing directly from GitHub also builds from
|
|
29
|
+
source. JRuby and TruffleRuby are not supported.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Generate waveform data from an audio file:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
require "audiowaveform"
|
|
37
|
+
|
|
38
|
+
waveform = AudioWaveform.generate(
|
|
39
|
+
"recording.mp3",
|
|
40
|
+
samples_per_pixel: 256,
|
|
41
|
+
split_channels: false
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
waveform.sample_rate # => 44_100
|
|
45
|
+
waveform.samples_per_pixel # => 256
|
|
46
|
+
waveform.channels # => 1
|
|
47
|
+
waveform.length # => number of waveform points per channel
|
|
48
|
+
waveform.data # => interleaved [min, max, ...] samples
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`AudioWaveform.generate` accepts these keyword arguments:
|
|
52
|
+
|
|
53
|
+
| Keyword | Default | Description |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `samples_per_pixel` | `256` | Number of source samples represented by each waveform point. Must be at least 2. |
|
|
56
|
+
| `pixels_per_second` | none | Time-based scale. Cannot be combined with `samples_per_pixel`. |
|
|
57
|
+
| `split_channels` | `false` | Preserve separate audio channels instead of mixing them down. |
|
|
58
|
+
| `amplitude_scale` | none | Non-negative numeric multiplier, or `:auto` to normalize automatically. |
|
|
59
|
+
|
|
60
|
+
The returned `AudioWaveform::Waveform` exposes:
|
|
61
|
+
|
|
62
|
+
| Method | Result |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `sample_rate` | Source sample rate in hertz. |
|
|
65
|
+
| `samples_per_pixel` | Source samples represented by each waveform point. |
|
|
66
|
+
| `channels` | Number of waveform channels. |
|
|
67
|
+
| `storage_bits` / `bits` | Internal sample resolution, either 8 or 16. |
|
|
68
|
+
| `length` / `size` | Number of waveform points per channel. |
|
|
69
|
+
| `empty?` | Whether the waveform contains no points. |
|
|
70
|
+
| `duration` / `duration_seconds` | Approximate duration in seconds. |
|
|
71
|
+
| `data` | Interleaved minimum and maximum sample values. |
|
|
72
|
+
| `point(index, channel: 0)` | `[minimum, maximum]` pair for one point and channel. |
|
|
73
|
+
|
|
74
|
+
Save the generated waveform as binary DAT, JSON, or text:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
waveform.save("recording.dat", bits: 8)
|
|
78
|
+
waveform.save("recording.json", bits: 16)
|
|
79
|
+
waveform.save("recording.waveform", format: :txt)
|
|
80
|
+
|
|
81
|
+
json = waveform.to_json(bits: 8)
|
|
82
|
+
binary = waveform.to_dat(bits: 16)
|
|
83
|
+
text = waveform.to_txt(bits: 8)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`bits` must be 8 or 16. `save` infers the format from a `.dat`, `.json`, or
|
|
87
|
+
`.txt` extension unless `format:` is provided explicitly. Invalid generation
|
|
88
|
+
or serialization options raise `ArgumentError`; an out-of-range `point` raises
|
|
89
|
+
`IndexError`; decoding and file I/O failures raise `AudioWaveform::Error`.
|
|
90
|
+
|
|
91
|
+
Use `pixels_per_second` instead of `samples_per_pixel` when a time-based scale
|
|
92
|
+
is more convenient:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
waveform = AudioWaveform.generate("recording.flac", pixels_per_second: 100)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Amplitude can be scaled with a numeric multiplier or normalized automatically:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
AudioWaveform.generate("quiet.wav", amplitude_scale: 1.5)
|
|
102
|
+
AudioWaveform.generate("quiet.wav", amplitude_scale: :auto)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Both source and precompiled gems enable the Rust library's `all-formats` feature:
|
|
106
|
+
AAC-LC/ADTS, AAC-LC and ALAC in M4A/MP4, MP1/MP2/MP3, WAV/W64 (PCM and ADPCM),
|
|
107
|
+
FLAC, Ogg (Vorbis and FLAC), AIFF, CAF (PCM and ALAC), and supported audio tracks
|
|
108
|
+
in Matroska/WebM. No FFmpeg installation is required for decoding.
|
|
109
|
+
|
|
110
|
+
AAC-LC supports mono and stereo. HE-AAC and Opus remain unsupported, including
|
|
111
|
+
Opus inside Ogg/WebM/MP4. Raw PCM input is not currently exposed by the gem.
|
|
112
|
+
The filename extension identifies the container; an enabled container can still
|
|
113
|
+
contain an unsupported codec. Such files raise `AudioWaveform::Error`.
|
|
114
|
+
AAC/MP4 waveform duration can include encoder delay and padding; gapless
|
|
115
|
+
trimming is not currently supported.
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
From the repository root:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
bundle install
|
|
123
|
+
bundle exec rake
|
|
124
|
+
bundle exec rake build
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Gem packages are written to the repository root's `pkg/` directory. To build a
|
|
128
|
+
native gem for the current Ruby and platform, use `bundle exec rake native gem`.
|
|
129
|
+
For the same multi-version build used in CI, install Docker and run:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
bundle exec rb-sys-dock --platform x86_64-linux --ruby-versions 3.2,3.3,3.4,4.0 --build
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The Linux build targets `x86_64-linux` and `aarch64-linux` produce explicitly
|
|
136
|
+
tagged `-linux-gnu` gems, so RubyGems can distinguish glibc from musl.
|
|
137
|
+
The rb_sys version pinned in the root Gemfile selects the cross-compilation image.
|
|
138
|
+
|
|
139
|
+
## Releasing to RubyGems
|
|
140
|
+
|
|
141
|
+
The gem has its own semantic version, independent of the Rust library. Releases
|
|
142
|
+
use `ruby-vX.Y.Z` tags and [the Ruby Gem Release workflow](../../.github/workflows/ruby-release.yml).
|
|
143
|
+
Each release runs the Ruby tests, builds a source gem and seven platform gems,
|
|
144
|
+
installs and exercises every native gem on all four supported Ruby versions, and
|
|
145
|
+
publishes the validated artifacts to RubyGems and a GitHub release.
|
|
146
|
+
|
|
147
|
+
### One-time setup
|
|
148
|
+
|
|
149
|
+
1. Create a GitHub environment named `release` in `Antti/audiowaveform`, allowing
|
|
150
|
+
deployments only from tags matching `ruby-v*`.
|
|
151
|
+
2. Sign in to the RubyGems account that will own `audiowaveform`, with MFA enabled.
|
|
152
|
+
For the first release, create a **pending trusted publisher** from your profile.
|
|
153
|
+
For an existing gem you own, use its **Trusted publishers** page.
|
|
154
|
+
3. Configure these exact values:
|
|
155
|
+
|
|
156
|
+
| Field | Value |
|
|
157
|
+
| --- | --- |
|
|
158
|
+
| Gem name (pending publisher) | `audiowaveform` |
|
|
159
|
+
| Repository owner | `Antti` |
|
|
160
|
+
| Repository name | `audiowaveform` |
|
|
161
|
+
| Workflow filename | `ruby-release.yml` |
|
|
162
|
+
| Environment | `release` |
|
|
163
|
+
|
|
164
|
+
Leave the optional reusable-workflow repository fields empty: publishing
|
|
165
|
+
happens directly in `ruby-release.yml`. No RubyGems API-key secret is needed.
|
|
166
|
+
Pending publishers expire after 12 hours, so create one shortly before the
|
|
167
|
+
first release; recreate it if it expires before the first successful push.
|
|
168
|
+
See the [RubyGems Trusted Publishing guide](https://guides.rubygems.org/trusted-publishing/).
|
|
169
|
+
|
|
170
|
+
### Each release
|
|
171
|
+
|
|
172
|
+
1. Update `bindings/ruby/lib/audiowaveform/version.rb` and the [Ruby changelog](CHANGELOG.md).
|
|
173
|
+
Refresh the local Bundler lockfile with `bundle install` after changing the version.
|
|
174
|
+
2. Run `bundle exec rake` and `bundle exec rake build`, then commit and merge the
|
|
175
|
+
release preparation. The source gem is `pkg/audiowaveform-X.Y.Z.gem`.
|
|
176
|
+
3. Optionally run **Ruby Gem Release** manually on that commit. Manual runs build
|
|
177
|
+
and test all packages without publishing, including when run against a tag.
|
|
178
|
+
4. Tag the release commit and push the tag to the project repository:
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
git tag -a ruby-vX.Y.Z -m "Release Ruby gem X.Y.Z"
|
|
182
|
+
git push antti ruby-vX.Y.Z
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Replace `X.Y.Z` with the gem version, and `antti` with your remote name if different.
|
|
186
|
+
The workflow rejects a tag that does not match the Ruby version file.
|
|
187
|
+
5. Check that the release workflow succeeds and all eight artifacts are visible
|
|
188
|
+
on RubyGems and the GitHub release.
|
|
189
|
+
|
|
190
|
+
If publishing fails partway through, rerun the failed job using the same artifacts.
|
|
191
|
+
It skips previously uploaded gems only when their SHA-256 checksums match. A
|
|
192
|
+
different artifact under the same version/platform is rejected; release a new
|
|
193
|
+
version instead. Do not move an existing release tag.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "audiowaveform-ruby"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2024"
|
|
5
|
+
authors = ["Andrii Dmytrenko", "BBC Research and Development"]
|
|
6
|
+
license = "GPL-3.0-or-later"
|
|
7
|
+
repository = "https://github.com/Antti/audiowaveform"
|
|
8
|
+
publish = false
|
|
9
|
+
|
|
10
|
+
[lib]
|
|
11
|
+
name = "audiowaveform_ruby"
|
|
12
|
+
crate-type = ["cdylib"]
|
|
13
|
+
|
|
14
|
+
[dependencies]
|
|
15
|
+
audiowaveform-core = { package = "audiowaveform", path = "../../../../crates/audiowaveform", default-features = false, features = ["all-formats"] }
|
|
16
|
+
magnus = { version = "0.8.2", features = ["rb-sys"] }
|
|
17
|
+
rb-sys = { version = "0.9", features = ["stable-api-compiled-fallback", "global-allocator"] }
|
|
18
|
+
|
|
19
|
+
[build-dependencies]
|
|
20
|
+
rb-sys-env = "0.2.2"
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
use std::fs::File;
|
|
2
|
+
use std::io::{BufWriter, Write};
|
|
3
|
+
use std::panic::{AssertUnwindSafe, catch_unwind};
|
|
4
|
+
use std::{ffi::c_void, ptr};
|
|
5
|
+
|
|
6
|
+
use audiowaveform_core::{
|
|
7
|
+
AmplitudeScale, Error as CoreError, GenerateOptions, ScaleSpec, Waveform, WaveformFormat,
|
|
8
|
+
generate_waveform_from_path,
|
|
9
|
+
};
|
|
10
|
+
use magnus::{
|
|
11
|
+
DataTypeFunctions, Error, ExceptionClass, Module, Object, RString, Ruby, TypedData, function,
|
|
12
|
+
method, rb_sys::protect,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
#[derive(TypedData)]
|
|
16
|
+
#[magnus(class = "AudioWaveform::Waveform", free_immediately, size)]
|
|
17
|
+
struct RubyWaveform(Waveform);
|
|
18
|
+
|
|
19
|
+
impl DataTypeFunctions for RubyWaveform {
|
|
20
|
+
fn size(&self) -> usize {
|
|
21
|
+
std::mem::size_of_val(self) + self.0.allocated_bytes()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
struct NoGvlTask<F, T> {
|
|
26
|
+
function: Option<F>,
|
|
27
|
+
result: Option<T>,
|
|
28
|
+
panicked: bool,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
unsafe extern "C" fn call_without_gvl<F, T>(data: *mut c_void) -> *mut c_void
|
|
32
|
+
where
|
|
33
|
+
F: FnOnce() -> T,
|
|
34
|
+
{
|
|
35
|
+
// SAFETY: `data` points to a live `NoGvlTask` for the duration of the
|
|
36
|
+
// synchronous `rb_thread_call_without_gvl` call, and Ruby cannot access it.
|
|
37
|
+
let task = unsafe { &mut *data.cast::<NoGvlTask<F, T>>() };
|
|
38
|
+
let Some(function) = task.function.take() else {
|
|
39
|
+
return ptr::null_mut();
|
|
40
|
+
};
|
|
41
|
+
match catch_unwind(AssertUnwindSafe(function)) {
|
|
42
|
+
Ok(result) => task.result = Some(result),
|
|
43
|
+
Err(_) => task.panicked = true,
|
|
44
|
+
}
|
|
45
|
+
ptr::null_mut()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fn without_gvl<F, T>(ruby: &Ruby, function: F) -> Result<T, Error>
|
|
49
|
+
where
|
|
50
|
+
F: FnOnce() -> T,
|
|
51
|
+
{
|
|
52
|
+
let mut task = NoGvlTask {
|
|
53
|
+
function: Some(function),
|
|
54
|
+
result: None,
|
|
55
|
+
panicked: false,
|
|
56
|
+
};
|
|
57
|
+
// Ruby can raise while checking interrupts before or after the callback.
|
|
58
|
+
// Keep the task outside `protect` so its closure/result is dropped normally
|
|
59
|
+
// even when Ruby exits the protected call with a non-local jump.
|
|
60
|
+
protect(|| {
|
|
61
|
+
// SAFETY: the callback only accesses the live stack-allocated task,
|
|
62
|
+
// does not invoke Ruby methods, and catches Rust panics before they
|
|
63
|
+
// cross the C ABI boundary. rb-sys tracks its allocations for Ruby GC.
|
|
64
|
+
unsafe {
|
|
65
|
+
rb_sys::rb_thread_call_without_gvl(
|
|
66
|
+
Some(call_without_gvl::<F, T>),
|
|
67
|
+
(&mut task as *mut NoGvlTask<F, T>).cast(),
|
|
68
|
+
None,
|
|
69
|
+
ptr::null_mut(),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
rb_sys::Qnil as rb_sys::VALUE
|
|
73
|
+
})?;
|
|
74
|
+
|
|
75
|
+
if task.panicked {
|
|
76
|
+
Err(ruby_error(
|
|
77
|
+
ruby,
|
|
78
|
+
"native waveform operation failed unexpectedly",
|
|
79
|
+
))
|
|
80
|
+
} else {
|
|
81
|
+
task.result
|
|
82
|
+
.ok_or_else(|| ruby_error(ruby, "native waveform operation did not complete"))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
impl RubyWaveform {
|
|
87
|
+
fn sample_rate(&self) -> u32 {
|
|
88
|
+
self.0.sample_rate()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fn samples_per_pixel(&self) -> u32 {
|
|
92
|
+
self.0.samples_per_pixel()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fn channels(&self) -> u16 {
|
|
96
|
+
self.0.channels()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fn storage_bits(&self) -> u8 {
|
|
100
|
+
self.0.storage_bits()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fn length(&self) -> usize {
|
|
104
|
+
self.0.len()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fn empty(&self) -> bool {
|
|
108
|
+
self.0.is_empty()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fn duration(&self) -> f64 {
|
|
112
|
+
self.0.duration_seconds()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn data(&self) -> Vec<i16> {
|
|
116
|
+
self.0.interleaved_samples().to_vec()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fn point(&self, channel: u16, index: usize) -> Option<(i16, i16)> {
|
|
120
|
+
self.0
|
|
121
|
+
.point(channel, index)
|
|
122
|
+
.map(|point| (point.min, point.max))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fn save(
|
|
126
|
+
ruby: &Ruby,
|
|
127
|
+
waveform: &Self,
|
|
128
|
+
path: String,
|
|
129
|
+
format: String,
|
|
130
|
+
bits: u8,
|
|
131
|
+
) -> Result<(), Error> {
|
|
132
|
+
let format = parse_format(ruby, &format)?;
|
|
133
|
+
let result = without_gvl(ruby, || -> Result<(), CoreError> {
|
|
134
|
+
let file = File::create(path)?;
|
|
135
|
+
let mut writer = BufWriter::new(file);
|
|
136
|
+
waveform
|
|
137
|
+
.0
|
|
138
|
+
.write_to_writer(&mut writer, format, Some(bits))?;
|
|
139
|
+
writer.flush()?;
|
|
140
|
+
Ok(())
|
|
141
|
+
})?;
|
|
142
|
+
result.map_err(|error| core_error(ruby, error))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
fn serialize(ruby: &Ruby, waveform: &Self, format: String, bits: u8) -> Result<RString, Error> {
|
|
146
|
+
let format = parse_format(ruby, &format)?;
|
|
147
|
+
let result = without_gvl(ruby, || {
|
|
148
|
+
let mut bytes = Vec::new();
|
|
149
|
+
waveform
|
|
150
|
+
.0
|
|
151
|
+
.write_to_writer(&mut bytes, format, Some(bits))
|
|
152
|
+
.map(|()| bytes)
|
|
153
|
+
})?;
|
|
154
|
+
let bytes = result.map_err(|error| core_error(ruby, error))?;
|
|
155
|
+
Ok(ruby.str_from_slice(&bytes))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fn generate(
|
|
160
|
+
ruby: &Ruby,
|
|
161
|
+
input: String,
|
|
162
|
+
scale_kind: String,
|
|
163
|
+
scale_value: u32,
|
|
164
|
+
split_channels: bool,
|
|
165
|
+
amplitude_kind: String,
|
|
166
|
+
amplitude_value: f64,
|
|
167
|
+
) -> Result<RubyWaveform, Error> {
|
|
168
|
+
let scale = match scale_kind.as_str() {
|
|
169
|
+
"samples_per_pixel" => ScaleSpec::SamplesPerPixel(scale_value),
|
|
170
|
+
"pixels_per_second" => ScaleSpec::PixelsPerSecond(scale_value),
|
|
171
|
+
_ => return Err(argument_error(ruby, "unsupported waveform scale")),
|
|
172
|
+
};
|
|
173
|
+
let amplitude_scale = match amplitude_kind.as_str() {
|
|
174
|
+
"none" => None,
|
|
175
|
+
"auto" => Some(AmplitudeScale::Auto),
|
|
176
|
+
"fixed" => Some(AmplitudeScale::Fixed(amplitude_value)),
|
|
177
|
+
_ => return Err(argument_error(ruby, "unsupported amplitude scale")),
|
|
178
|
+
};
|
|
179
|
+
let options = GenerateOptions {
|
|
180
|
+
scale,
|
|
181
|
+
split_channels,
|
|
182
|
+
amplitude_scale,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
without_gvl(ruby, || generate_waveform_from_path(input, &options))?
|
|
186
|
+
.map(RubyWaveform)
|
|
187
|
+
.map_err(|error| core_error(ruby, error))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
fn parse_format(ruby: &Ruby, format: &str) -> Result<WaveformFormat, Error> {
|
|
191
|
+
format
|
|
192
|
+
.parse()
|
|
193
|
+
.map_err(|error: CoreError| core_error(ruby, error))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
fn core_error(ruby: &Ruby, error: CoreError) -> Error {
|
|
197
|
+
if matches!(error, CoreError::InvalidArgument { .. }) {
|
|
198
|
+
argument_error(ruby, error.to_string())
|
|
199
|
+
} else {
|
|
200
|
+
ruby_error(ruby, error.to_string())
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
fn argument_error(ruby: &Ruby, message: impl AsRef<str>) -> Error {
|
|
205
|
+
Error::new(ruby.exception_arg_error(), message.as_ref().to_owned())
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
fn ruby_error(ruby: &Ruby, message: impl AsRef<str>) -> Error {
|
|
209
|
+
let error_class = ruby
|
|
210
|
+
.eval::<ExceptionClass>("AudioWaveform::Error")
|
|
211
|
+
.unwrap_or_else(|_| ruby.exception_standard_error());
|
|
212
|
+
Error::new(error_class, message.as_ref().to_owned())
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#[magnus::init]
|
|
216
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
217
|
+
let module = ruby.define_module("AudioWaveform")?;
|
|
218
|
+
module.define_error("Error", ruby.exception_standard_error())?;
|
|
219
|
+
|
|
220
|
+
let native = module.define_module("Native")?;
|
|
221
|
+
native.define_singleton_method("generate", function!(generate, 6))?;
|
|
222
|
+
|
|
223
|
+
let waveform = module.define_class("Waveform", ruby.class_object())?;
|
|
224
|
+
waveform.define_method("sample_rate", method!(RubyWaveform::sample_rate, 0))?;
|
|
225
|
+
waveform.define_method(
|
|
226
|
+
"samples_per_pixel",
|
|
227
|
+
method!(RubyWaveform::samples_per_pixel, 0),
|
|
228
|
+
)?;
|
|
229
|
+
waveform.define_method("channels", method!(RubyWaveform::channels, 0))?;
|
|
230
|
+
waveform.define_method("storage_bits", method!(RubyWaveform::storage_bits, 0))?;
|
|
231
|
+
waveform.define_method("length", method!(RubyWaveform::length, 0))?;
|
|
232
|
+
waveform.define_method("empty?", method!(RubyWaveform::empty, 0))?;
|
|
233
|
+
waveform.define_method("duration", method!(RubyWaveform::duration, 0))?;
|
|
234
|
+
waveform.define_method("data", method!(RubyWaveform::data, 0))?;
|
|
235
|
+
waveform.define_private_method("__point", method!(RubyWaveform::point, 2))?;
|
|
236
|
+
waveform.define_private_method("__save", method!(RubyWaveform::save, 3))?;
|
|
237
|
+
waveform.define_private_method("__serialize", method!(RubyWaveform::serialize, 2))?;
|
|
238
|
+
Ok(())
|
|
239
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "audiowaveform/version"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
# Platform gems contain one extension per Ruby minor version. Source builds put
|
|
7
|
+
# the extension directly under audiowaveform/.
|
|
8
|
+
native_extension = File.join(
|
|
9
|
+
__dir__, "audiowaveform", RUBY_VERSION[/\A\d+\.\d+/],
|
|
10
|
+
"audiowaveform_ruby.#{RbConfig::CONFIG.fetch('DLEXT')}"
|
|
11
|
+
)
|
|
12
|
+
if File.file?(native_extension)
|
|
13
|
+
require native_extension
|
|
14
|
+
else
|
|
15
|
+
require "audiowaveform/audiowaveform_ruby"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module AudioWaveform
|
|
19
|
+
class << self
|
|
20
|
+
# Generates waveform data from an audio file.
|
|
21
|
+
def generate(
|
|
22
|
+
input,
|
|
23
|
+
samples_per_pixel: nil,
|
|
24
|
+
pixels_per_second: nil,
|
|
25
|
+
split_channels: false,
|
|
26
|
+
amplitude_scale: nil
|
|
27
|
+
)
|
|
28
|
+
scale_kind, scale_value = resolve_scale(samples_per_pixel, pixels_per_second)
|
|
29
|
+
amplitude_kind, amplitude_value = resolve_amplitude_scale(amplitude_scale)
|
|
30
|
+
|
|
31
|
+
Native.generate(
|
|
32
|
+
File.path(input),
|
|
33
|
+
scale_kind,
|
|
34
|
+
scale_value,
|
|
35
|
+
!!split_channels,
|
|
36
|
+
amplitude_kind,
|
|
37
|
+
amplitude_value
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def resolve_scale(samples_per_pixel, pixels_per_second)
|
|
44
|
+
if samples_per_pixel && pixels_per_second
|
|
45
|
+
raise ArgumentError, "samples_per_pixel and pixels_per_second are mutually exclusive"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if pixels_per_second
|
|
49
|
+
["pixels_per_second", positive_integer(pixels_per_second, :pixels_per_second)]
|
|
50
|
+
else
|
|
51
|
+
value = samples_per_pixel || 256
|
|
52
|
+
["samples_per_pixel", positive_integer(value, :samples_per_pixel, minimum: 2)]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resolve_amplitude_scale(value)
|
|
57
|
+
return ["none", 0.0] if value.nil?
|
|
58
|
+
return ["auto", 0.0] if value == :auto || value == "auto"
|
|
59
|
+
|
|
60
|
+
numeric = Float(value)
|
|
61
|
+
unless numeric.finite? && numeric >= 0.0
|
|
62
|
+
raise ArgumentError, "amplitude_scale must be a finite non-negative number or :auto"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
["fixed", numeric]
|
|
66
|
+
rescue TypeError, ArgumentError
|
|
67
|
+
raise ArgumentError, "amplitude_scale must be a finite non-negative number or :auto"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def positive_integer(value, name, minimum: 1)
|
|
71
|
+
unless value.is_a?(Integer) && value >= minimum
|
|
72
|
+
raise ArgumentError, "#{name} must be an integer greater than or equal to #{minimum}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class Waveform
|
|
80
|
+
private_class_method :new
|
|
81
|
+
|
|
82
|
+
alias size length
|
|
83
|
+
alias bits storage_bits
|
|
84
|
+
alias duration_seconds duration
|
|
85
|
+
|
|
86
|
+
# Returns the [minimum, maximum] pair at +index+ for +channel+.
|
|
87
|
+
def point(index, channel: 0)
|
|
88
|
+
unless index.is_a?(Integer) && index.between?(0, length - 1) &&
|
|
89
|
+
channel.is_a?(Integer) && channel.between?(0, channels - 1)
|
|
90
|
+
raise IndexError, "waveform point is outside the available channel or index range"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
value = __point(channel, index)
|
|
94
|
+
return value if value
|
|
95
|
+
|
|
96
|
+
raise IndexError, "waveform point is outside the available channel or index range"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Writes waveform data to +path+ and returns self.
|
|
100
|
+
def save(path, format: nil, bits: storage_bits)
|
|
101
|
+
resolved_format = format || File.extname(File.path(path)).delete_prefix(".")
|
|
102
|
+
__save(File.path(path), resolved_format.to_s, validate_bits(bits))
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Returns binary DAT waveform data.
|
|
107
|
+
def to_dat(bits: storage_bits)
|
|
108
|
+
__serialize("dat", validate_bits(bits))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns JSON waveform data.
|
|
112
|
+
def to_json(*, bits: storage_bits)
|
|
113
|
+
__serialize("json", validate_bits(bits)).force_encoding(Encoding::UTF_8)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns plain-text waveform data.
|
|
117
|
+
def to_txt(bits: storage_bits)
|
|
118
|
+
__serialize("txt", validate_bits(bits)).force_encoding(Encoding::UTF_8)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def validate_bits(bits)
|
|
124
|
+
return bits if bits == 8 || bits == 16
|
|
125
|
+
|
|
126
|
+
raise ArgumentError, "bits must be either 8 or 16"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private_constant :Native
|
|
131
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "audiowaveform"
|
|
3
|
+
version.workspace = true
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
license.workspace = true
|
|
6
|
+
repository.workspace = true
|
|
7
|
+
authors.workspace = true
|
|
8
|
+
description = "Audio waveform generation, serialization, rendering, and transcoding library"
|
|
9
|
+
|
|
10
|
+
[[example]]
|
|
11
|
+
name = "generate_waveform"
|
|
12
|
+
required-features = ["format-mp3"]
|
|
13
|
+
|
|
14
|
+
[[example]]
|
|
15
|
+
name = "render_waveform"
|
|
16
|
+
required-features = ["render"]
|
|
17
|
+
|
|
18
|
+
[[example]]
|
|
19
|
+
name = "generate_from_pcm"
|
|
20
|
+
|
|
21
|
+
[[example]]
|
|
22
|
+
name = "resample_waveform"
|
|
23
|
+
|
|
24
|
+
[[test]]
|
|
25
|
+
name = "generate"
|
|
26
|
+
required-features = ["decode"]
|
|
27
|
+
|
|
28
|
+
[[test]]
|
|
29
|
+
name = "render"
|
|
30
|
+
required-features = ["render"]
|
|
31
|
+
|
|
32
|
+
[[test]]
|
|
33
|
+
name = "wav"
|
|
34
|
+
required-features = ["wav-output"]
|
|
35
|
+
|
|
36
|
+
[features]
|
|
37
|
+
default = []
|
|
38
|
+
decode = ["dep:symphonia"]
|
|
39
|
+
all-formats = ["format-aac", "format-aiff", "format-caf", "format-flac", "format-m4a", "format-mkv", "format-mp1", "format-mp2", "format-mp3", "format-ogg", "format-wav"]
|
|
40
|
+
format-aac = ["decode", "symphonia/aac"]
|
|
41
|
+
format-aiff = ["decode", "symphonia/aiff", "symphonia/pcm"]
|
|
42
|
+
format-caf = ["decode", "symphonia/caf", "symphonia/alac", "symphonia/pcm"]
|
|
43
|
+
format-flac = ["decode", "symphonia/flac"]
|
|
44
|
+
format-m4a = ["decode", "symphonia/isomp4", "symphonia/aac", "symphonia/alac", "symphonia/mp3", "symphonia/pcm"]
|
|
45
|
+
format-mp4 = ["format-m4a"]
|
|
46
|
+
format-mkv = ["decode", "symphonia/mkv", "symphonia/aac", "symphonia/flac", "symphonia/mpa", "symphonia/pcm", "symphonia/vorbis"]
|
|
47
|
+
format-webm = ["format-mkv"]
|
|
48
|
+
format-mp1 = ["decode", "symphonia/mp1"]
|
|
49
|
+
format-mp2 = ["decode", "symphonia/mp2"]
|
|
50
|
+
format-mp3 = ["decode", "symphonia/mp3"]
|
|
51
|
+
format-ogg = ["decode", "symphonia/ogg", "symphonia/vorbis", "symphonia/flac"]
|
|
52
|
+
format-wav = ["decode", "symphonia/wav", "symphonia/pcm", "symphonia/adpcm"]
|
|
53
|
+
render = ["dep:image", "dep:png"]
|
|
54
|
+
wav-output = ["dep:hound"]
|
|
55
|
+
|
|
56
|
+
[package.metadata.docs.rs]
|
|
57
|
+
all-features = true
|
|
58
|
+
|
|
59
|
+
[dependencies]
|
|
60
|
+
byteorder.workspace = true
|
|
61
|
+
hound = { workspace = true, optional = true }
|
|
62
|
+
image = { workspace = true, optional = true }
|
|
63
|
+
png = { workspace = true, optional = true }
|
|
64
|
+
serde.workspace = true
|
|
65
|
+
serde_json.workspace = true
|
|
66
|
+
symphonia = { workspace = true, optional = true }
|
|
67
|
+
thiserror.workspace = true
|
|
68
|
+
|
|
69
|
+
[dev-dependencies]
|
|
70
|
+
assert_cmd.workspace = true
|
|
71
|
+
image.workspace = true
|
|
72
|
+
predicates.workspace = true
|
|
73
|
+
tempfile.workspace = true
|