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,803 @@
|
|
|
1
|
+
use std::fs::File;
|
|
2
|
+
use std::io::{self, Cursor, Read, Write};
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
use std::process::ExitCode;
|
|
5
|
+
use std::str::FromStr;
|
|
6
|
+
|
|
7
|
+
#[cfg(all(feature = "decode", feature = "wav-output"))]
|
|
8
|
+
use audiowaveform::decode_audio_from_reader;
|
|
9
|
+
#[cfg(feature = "decode")]
|
|
10
|
+
use audiowaveform::generate_waveform_from_reader;
|
|
11
|
+
use audiowaveform::{
|
|
12
|
+
AmplitudeScale, AudioFormat, Color, ColorScheme, Error, GenerateOptions, RawAudioConfig,
|
|
13
|
+
RawSampleFormat, ScaleSpec, Waveform, WaveformColors, WaveformFormat,
|
|
14
|
+
generate_waveform_from_raw_reader,
|
|
15
|
+
};
|
|
16
|
+
#[cfg(feature = "render")]
|
|
17
|
+
use audiowaveform::{BarStyle, RenderOptions, RenderStyle, write_waveform_png};
|
|
18
|
+
#[cfg(feature = "wav-output")]
|
|
19
|
+
use audiowaveform::{decode_raw_audio_reader, write_pcm_as_wav};
|
|
20
|
+
use clap::builder::styling::{AnsiColor, Styles};
|
|
21
|
+
use clap::{CommandFactory, Parser, ValueEnum};
|
|
22
|
+
|
|
23
|
+
const CLI_STYLES: Styles = Styles::styled()
|
|
24
|
+
.header(AnsiColor::Cyan.on_default().bold())
|
|
25
|
+
.usage(AnsiColor::Cyan.on_default().bold().underline())
|
|
26
|
+
.literal(AnsiColor::Blue.on_default().bold())
|
|
27
|
+
.placeholder(AnsiColor::Yellow.on_default())
|
|
28
|
+
.error(AnsiColor::Red.on_default().bold())
|
|
29
|
+
.valid(AnsiColor::Green.on_default())
|
|
30
|
+
.invalid(AnsiColor::Magenta.on_default().bold())
|
|
31
|
+
.context(AnsiColor::BrightBlack.on_default().dimmed())
|
|
32
|
+
.context_value(AnsiColor::Yellow.on_default().italic());
|
|
33
|
+
|
|
34
|
+
#[derive(Debug, Parser)]
|
|
35
|
+
#[command(
|
|
36
|
+
name = "audiowaveform",
|
|
37
|
+
disable_version_flag = true,
|
|
38
|
+
disable_help_flag = true,
|
|
39
|
+
styles = CLI_STYLES
|
|
40
|
+
)]
|
|
41
|
+
/// Generate waveform data and images from audio.
|
|
42
|
+
struct Cli {
|
|
43
|
+
/// Show help information.
|
|
44
|
+
#[arg(long = "help")]
|
|
45
|
+
help: bool,
|
|
46
|
+
|
|
47
|
+
/// Show version information.
|
|
48
|
+
#[arg(short = 'v', long = "version")]
|
|
49
|
+
version: bool,
|
|
50
|
+
|
|
51
|
+
/// Disable progress and information messages.
|
|
52
|
+
#[arg(short = 'q', long = "quiet")]
|
|
53
|
+
quiet: bool,
|
|
54
|
+
|
|
55
|
+
/// Read input from a file or `-` for stdin.
|
|
56
|
+
#[arg(short = 'i', long = "input-filename")]
|
|
57
|
+
input_filename: Option<String>,
|
|
58
|
+
|
|
59
|
+
/// Write output to a file or `-` for stdout.
|
|
60
|
+
#[arg(short = 'o', long = "output-filename")]
|
|
61
|
+
output_filename: Option<String>,
|
|
62
|
+
|
|
63
|
+
/// Preserve channels instead of mixing to mono.
|
|
64
|
+
#[arg(long = "split-channels")]
|
|
65
|
+
split_channels: bool,
|
|
66
|
+
|
|
67
|
+
/// Override input format detection.
|
|
68
|
+
#[arg(long = "input-format", value_enum)]
|
|
69
|
+
input_format: Option<CliFormat>,
|
|
70
|
+
|
|
71
|
+
/// Override output format detection.
|
|
72
|
+
#[arg(long = "output-format", value_enum)]
|
|
73
|
+
output_format: Option<CliFormat>,
|
|
74
|
+
|
|
75
|
+
/// Use a fixed number of samples per pixel or `auto`.
|
|
76
|
+
#[arg(short = 'z', long = "zoom")]
|
|
77
|
+
zoom: Option<String>,
|
|
78
|
+
|
|
79
|
+
/// Set zoom using pixels per second.
|
|
80
|
+
#[arg(long = "pixels-per-second")]
|
|
81
|
+
pixels_per_second: Option<i32>,
|
|
82
|
+
|
|
83
|
+
/// Set waveform output bit depth.
|
|
84
|
+
#[arg(short = 'b', long = "bits")]
|
|
85
|
+
bits: Option<i32>,
|
|
86
|
+
|
|
87
|
+
/// Start rendering at a time offset in seconds.
|
|
88
|
+
#[arg(short = 's', long = "start", default_value_t = 0.0)]
|
|
89
|
+
start: f64,
|
|
90
|
+
|
|
91
|
+
/// Fit the output width to the given end time.
|
|
92
|
+
#[arg(short = 'e', long = "end")]
|
|
93
|
+
end: Option<f64>,
|
|
94
|
+
|
|
95
|
+
/// Set image width in pixels.
|
|
96
|
+
#[arg(short = 'w', long = "width", default_value_t = 800)]
|
|
97
|
+
width: i32,
|
|
98
|
+
|
|
99
|
+
/// Set image height in pixels.
|
|
100
|
+
#[arg(short = 'h', long = "height", default_value_t = 250)]
|
|
101
|
+
height: i32,
|
|
102
|
+
|
|
103
|
+
/// Choose a built-in color scheme.
|
|
104
|
+
#[arg(short = 'c', long = "colors", value_enum, default_value = "audacity")]
|
|
105
|
+
color_scheme: CliColorScheme,
|
|
106
|
+
|
|
107
|
+
/// Override the border color using `rrggbb[aa]`.
|
|
108
|
+
#[arg(long = "border-color")]
|
|
109
|
+
border_color: Option<String>,
|
|
110
|
+
|
|
111
|
+
/// Override the background color using `rrggbb[aa]`.
|
|
112
|
+
#[arg(long = "background-color")]
|
|
113
|
+
background_color: Option<String>,
|
|
114
|
+
|
|
115
|
+
/// Set one or more waveform colors using `rrggbb[aa]`.
|
|
116
|
+
#[arg(long = "waveform-color")]
|
|
117
|
+
waveform_color: Option<String>,
|
|
118
|
+
|
|
119
|
+
/// Render as lines or grouped bars.
|
|
120
|
+
#[arg(long = "waveform-style", value_enum, default_value = "normal")]
|
|
121
|
+
waveform_style: CliWaveformStyle,
|
|
122
|
+
|
|
123
|
+
/// Set bar width in pixels.
|
|
124
|
+
#[arg(long = "bar-width", default_value_t = 8)]
|
|
125
|
+
bar_width: i32,
|
|
126
|
+
|
|
127
|
+
/// Set gap between bars in pixels.
|
|
128
|
+
#[arg(long = "bar-gap", default_value_t = 4)]
|
|
129
|
+
bar_gap: i32,
|
|
130
|
+
|
|
131
|
+
/// Set the bar end-cap style.
|
|
132
|
+
#[arg(long = "bar-style", value_enum, default_value = "square")]
|
|
133
|
+
bar_style: CliBarStyle,
|
|
134
|
+
|
|
135
|
+
/// Override the axis label color using `rrggbb[aa]`.
|
|
136
|
+
#[arg(long = "axis-label-color")]
|
|
137
|
+
axis_label_color: Option<String>,
|
|
138
|
+
|
|
139
|
+
/// Hide time axis labels.
|
|
140
|
+
#[arg(long = "no-axis-labels")]
|
|
141
|
+
no_axis_labels: bool,
|
|
142
|
+
|
|
143
|
+
/// Show time axis labels.
|
|
144
|
+
#[arg(long = "with-axis-labels")]
|
|
145
|
+
with_axis_labels: bool,
|
|
146
|
+
|
|
147
|
+
/// Scale amplitude or use `auto`.
|
|
148
|
+
#[arg(long = "amplitude-scale", default_value = "1.0")]
|
|
149
|
+
amplitude_scale: String,
|
|
150
|
+
|
|
151
|
+
/// Set PNG compression level from `-1` to `9`.
|
|
152
|
+
#[arg(long = "compression", default_value_t = -1)]
|
|
153
|
+
compression: i32,
|
|
154
|
+
|
|
155
|
+
/// Set raw input sample rate in Hz.
|
|
156
|
+
#[arg(long = "raw-samplerate")]
|
|
157
|
+
raw_sample_rate: Option<i32>,
|
|
158
|
+
|
|
159
|
+
/// Set raw input channel count.
|
|
160
|
+
#[arg(long = "raw-channels")]
|
|
161
|
+
raw_channels: Option<i32>,
|
|
162
|
+
|
|
163
|
+
/// Set raw input sample format.
|
|
164
|
+
#[arg(long = "raw-format", value_enum)]
|
|
165
|
+
raw_format: Option<CliRawSampleFormat>,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
|
169
|
+
enum CliFormat {
|
|
170
|
+
#[value(alias = "adts")]
|
|
171
|
+
Aac,
|
|
172
|
+
#[value(aliases = ["aif", "aifc"])]
|
|
173
|
+
Aiff,
|
|
174
|
+
Caf,
|
|
175
|
+
#[value(aliases = ["m4a", "m4b", "m4r", "m4v", "mov"])]
|
|
176
|
+
Mp4,
|
|
177
|
+
#[value(aliases = ["mka", "webm"])]
|
|
178
|
+
Mkv,
|
|
179
|
+
Mp1,
|
|
180
|
+
Mp2,
|
|
181
|
+
Mp3,
|
|
182
|
+
#[value(alias = "w64")]
|
|
183
|
+
Wav,
|
|
184
|
+
Flac,
|
|
185
|
+
#[value(alias = "oga")]
|
|
186
|
+
Ogg,
|
|
187
|
+
Opus,
|
|
188
|
+
Raw,
|
|
189
|
+
Dat,
|
|
190
|
+
Json,
|
|
191
|
+
Txt,
|
|
192
|
+
Png,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
impl CliFormat {
|
|
196
|
+
fn from_path(path: &str) -> Result<Self, String> {
|
|
197
|
+
let extension = Path::new(path)
|
|
198
|
+
.extension()
|
|
199
|
+
.and_then(|value| value.to_str())
|
|
200
|
+
.ok_or_else(|| format!("Unknown file format: {path}"))?;
|
|
201
|
+
match extension.to_ascii_lowercase().as_str() {
|
|
202
|
+
"aac" | "adts" => Ok(Self::Aac),
|
|
203
|
+
"aiff" | "aif" | "aifc" => Ok(Self::Aiff),
|
|
204
|
+
"caf" => Ok(Self::Caf),
|
|
205
|
+
"mp4" | "m4a" | "m4b" | "m4r" | "m4v" | "mov" => Ok(Self::Mp4),
|
|
206
|
+
"mkv" | "mka" | "webm" => Ok(Self::Mkv),
|
|
207
|
+
"mp1" => Ok(Self::Mp1),
|
|
208
|
+
"mp2" => Ok(Self::Mp2),
|
|
209
|
+
"mp3" => Ok(Self::Mp3),
|
|
210
|
+
"wav" | "w64" => Ok(Self::Wav),
|
|
211
|
+
"flac" => Ok(Self::Flac),
|
|
212
|
+
"ogg" | "oga" => Ok(Self::Ogg),
|
|
213
|
+
"opus" => Ok(Self::Opus),
|
|
214
|
+
"raw" => Ok(Self::Raw),
|
|
215
|
+
"dat" => Ok(Self::Dat),
|
|
216
|
+
"json" => Ok(Self::Json),
|
|
217
|
+
"txt" => Ok(Self::Txt),
|
|
218
|
+
"png" => Ok(Self::Png),
|
|
219
|
+
_ => Err(format!("Unknown file format: {path}")),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
fn is_audio_input(self) -> bool {
|
|
224
|
+
self.as_audio_format().is_some()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
fn is_waveform_input(self) -> bool {
|
|
228
|
+
matches!(self, Self::Dat | Self::Json)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
fn as_audio_format(self) -> Option<AudioFormat> {
|
|
232
|
+
match self {
|
|
233
|
+
Self::Aac => Some(AudioFormat::Aac),
|
|
234
|
+
Self::Aiff => Some(AudioFormat::Aiff),
|
|
235
|
+
Self::Caf => Some(AudioFormat::Caf),
|
|
236
|
+
Self::Mp4 => Some(AudioFormat::Mp4),
|
|
237
|
+
Self::Mkv => Some(AudioFormat::Mkv),
|
|
238
|
+
Self::Mp1 => Some(AudioFormat::Mp1),
|
|
239
|
+
Self::Mp2 => Some(AudioFormat::Mp2),
|
|
240
|
+
Self::Mp3 => Some(AudioFormat::Mp3),
|
|
241
|
+
Self::Wav => Some(AudioFormat::Wav),
|
|
242
|
+
Self::Flac => Some(AudioFormat::Flac),
|
|
243
|
+
Self::Ogg => Some(AudioFormat::Ogg),
|
|
244
|
+
Self::Opus => Some(AudioFormat::Opus),
|
|
245
|
+
Self::Raw => Some(AudioFormat::Raw),
|
|
246
|
+
_ => None,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fn as_waveform_format(self) -> Option<WaveformFormat> {
|
|
251
|
+
match self {
|
|
252
|
+
Self::Dat => Some(WaveformFormat::Dat),
|
|
253
|
+
Self::Json => Some(WaveformFormat::Json),
|
|
254
|
+
Self::Txt => Some(WaveformFormat::Txt),
|
|
255
|
+
_ => None,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fn name(self) -> &'static str {
|
|
260
|
+
match self {
|
|
261
|
+
Self::Aac => "aac",
|
|
262
|
+
Self::Aiff => "aiff",
|
|
263
|
+
Self::Caf => "caf",
|
|
264
|
+
Self::Mp4 => "mp4",
|
|
265
|
+
Self::Mkv => "mkv",
|
|
266
|
+
Self::Mp1 => "mp1",
|
|
267
|
+
Self::Mp2 => "mp2",
|
|
268
|
+
Self::Mp3 => "mp3",
|
|
269
|
+
Self::Wav => "wav",
|
|
270
|
+
Self::Flac => "flac",
|
|
271
|
+
Self::Ogg => "ogg",
|
|
272
|
+
Self::Opus => "opus",
|
|
273
|
+
Self::Raw => "raw",
|
|
274
|
+
Self::Dat => "dat",
|
|
275
|
+
Self::Json => "json",
|
|
276
|
+
Self::Txt => "txt",
|
|
277
|
+
Self::Png => "png",
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
|
283
|
+
enum CliColorScheme {
|
|
284
|
+
Audacity,
|
|
285
|
+
Audition,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
impl CliColorScheme {
|
|
289
|
+
fn into_library(self) -> ColorScheme {
|
|
290
|
+
match self {
|
|
291
|
+
Self::Audacity => ColorScheme::Audacity,
|
|
292
|
+
Self::Audition => ColorScheme::Audition,
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
|
298
|
+
enum CliWaveformStyle {
|
|
299
|
+
Normal,
|
|
300
|
+
Bars,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
|
304
|
+
enum CliBarStyle {
|
|
305
|
+
Square,
|
|
306
|
+
Rounded,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#[cfg(feature = "render")]
|
|
310
|
+
impl CliBarStyle {
|
|
311
|
+
fn into_library(self) -> BarStyle {
|
|
312
|
+
match self {
|
|
313
|
+
Self::Square => BarStyle::Square,
|
|
314
|
+
Self::Rounded => BarStyle::Rounded,
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
|
320
|
+
enum CliRawSampleFormat {
|
|
321
|
+
#[value(name = "s8")]
|
|
322
|
+
S8,
|
|
323
|
+
#[value(name = "u8")]
|
|
324
|
+
U8,
|
|
325
|
+
#[value(name = "s16le")]
|
|
326
|
+
S16Le,
|
|
327
|
+
#[value(name = "s16be")]
|
|
328
|
+
S16Be,
|
|
329
|
+
#[value(name = "s24le")]
|
|
330
|
+
S24Le,
|
|
331
|
+
#[value(name = "s24be")]
|
|
332
|
+
S24Be,
|
|
333
|
+
#[value(name = "s32le")]
|
|
334
|
+
S32Le,
|
|
335
|
+
#[value(name = "s32be")]
|
|
336
|
+
S32Be,
|
|
337
|
+
#[value(name = "f32le")]
|
|
338
|
+
F32Le,
|
|
339
|
+
#[value(name = "f32be")]
|
|
340
|
+
F32Be,
|
|
341
|
+
#[value(name = "f64le")]
|
|
342
|
+
F64Le,
|
|
343
|
+
#[value(name = "f64be")]
|
|
344
|
+
F64Be,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
impl CliRawSampleFormat {
|
|
348
|
+
fn into_library(self) -> RawSampleFormat {
|
|
349
|
+
match self {
|
|
350
|
+
Self::S8 => RawSampleFormat::S8,
|
|
351
|
+
Self::U8 => RawSampleFormat::U8,
|
|
352
|
+
Self::S16Le => RawSampleFormat::S16Le,
|
|
353
|
+
Self::S16Be => RawSampleFormat::S16Be,
|
|
354
|
+
Self::S24Le => RawSampleFormat::S24Le,
|
|
355
|
+
Self::S24Be => RawSampleFormat::S24Be,
|
|
356
|
+
Self::S32Le => RawSampleFormat::S32Le,
|
|
357
|
+
Self::S32Be => RawSampleFormat::S32Be,
|
|
358
|
+
Self::F32Le => RawSampleFormat::F32Le,
|
|
359
|
+
Self::F32Be => RawSampleFormat::F32Be,
|
|
360
|
+
Self::F64Le => RawSampleFormat::F64Le,
|
|
361
|
+
Self::F64Be => RawSampleFormat::F64Be,
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#[derive(Clone, Copy, Debug)]
|
|
367
|
+
enum ParsedAmplitudeScale {
|
|
368
|
+
Fixed(f64),
|
|
369
|
+
Auto,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
struct Logger {
|
|
373
|
+
quiet: bool,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
impl Logger {
|
|
377
|
+
fn info(&self, message: impl AsRef<str>) {
|
|
378
|
+
if !self.quiet {
|
|
379
|
+
eprintln!("{}", message.as_ref());
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
fn main() -> ExitCode {
|
|
385
|
+
let cli = Cli::parse();
|
|
386
|
+
if cli.help {
|
|
387
|
+
let mut command = Cli::command();
|
|
388
|
+
if command.print_help().is_ok() {
|
|
389
|
+
println!();
|
|
390
|
+
}
|
|
391
|
+
return ExitCode::SUCCESS;
|
|
392
|
+
}
|
|
393
|
+
if cli.version {
|
|
394
|
+
println!("AudioWaveform v{}", env!("CARGO_PKG_VERSION"));
|
|
395
|
+
return ExitCode::SUCCESS;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
match run(cli) {
|
|
399
|
+
Ok(()) => ExitCode::SUCCESS,
|
|
400
|
+
Err(error) => {
|
|
401
|
+
eprintln!("{error}");
|
|
402
|
+
ExitCode::from(1)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
fn run(cli: Cli) -> Result<(), String> {
|
|
408
|
+
let logger = Logger { quiet: cli.quiet };
|
|
409
|
+
if cli.height < 1 {
|
|
410
|
+
return Err("Invalid image height: minimum 1".to_string());
|
|
411
|
+
}
|
|
412
|
+
let input_format = resolve_format(cli.input_filename.as_deref(), cli.input_format, true)?;
|
|
413
|
+
let output_format = resolve_format(cli.output_filename.as_deref(), cli.output_format, false)?;
|
|
414
|
+
let bits = resolve_bits(cli.bits)?;
|
|
415
|
+
let _compression = resolve_compression(cli.compression)?;
|
|
416
|
+
let amplitude = parse_amplitude_scale(&cli.amplitude_scale)?;
|
|
417
|
+
let _colors = resolve_colors(&cli)?;
|
|
418
|
+
let _axis_labels = if cli.with_axis_labels {
|
|
419
|
+
true
|
|
420
|
+
} else {
|
|
421
|
+
!cli.no_axis_labels
|
|
422
|
+
};
|
|
423
|
+
#[cfg(feature = "render")]
|
|
424
|
+
let render_style = resolve_render_style(&cli)?;
|
|
425
|
+
let scale = resolve_scale(&cli)?;
|
|
426
|
+
let raw_config = if input_format == CliFormat::Raw {
|
|
427
|
+
Some(resolve_raw_audio_config(&cli)?)
|
|
428
|
+
} else {
|
|
429
|
+
None
|
|
430
|
+
};
|
|
431
|
+
let has_resample = cli.zoom.is_some() || cli.pixels_per_second.is_some() || cli.end.is_some();
|
|
432
|
+
if let Some(format) = input_format.as_audio_format() {
|
|
433
|
+
format.ensure_enabled().map_err(stringify_error)?;
|
|
434
|
+
}
|
|
435
|
+
if output_format == CliFormat::Png && !cfg!(feature = "render") {
|
|
436
|
+
return Err(stringify_error(Error::FeatureDisabled {
|
|
437
|
+
feature: "render",
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
if output_format == CliFormat::Wav && !cfg!(feature = "wav-output") {
|
|
441
|
+
return Err(stringify_error(Error::FeatureDisabled {
|
|
442
|
+
feature: "wav-output",
|
|
443
|
+
}));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if input_format.is_audio_input() && output_format == CliFormat::Wav {
|
|
447
|
+
#[cfg(feature = "wav-output")]
|
|
448
|
+
{
|
|
449
|
+
let bytes = read_input_bytes(cli.input_filename.as_deref())?;
|
|
450
|
+
let mut output = create_output(cli.output_filename.as_deref())?;
|
|
451
|
+
match input_format {
|
|
452
|
+
CliFormat::Raw => {
|
|
453
|
+
let pcm = decode_raw_audio_reader(
|
|
454
|
+
Cursor::new(bytes),
|
|
455
|
+
raw_config.as_ref().expect("validated raw config"),
|
|
456
|
+
)
|
|
457
|
+
.map_err(stringify_error)?;
|
|
458
|
+
write_pcm_as_wav(&pcm, &mut output).map_err(stringify_error)?;
|
|
459
|
+
}
|
|
460
|
+
_ => {
|
|
461
|
+
#[cfg(feature = "decode")]
|
|
462
|
+
{
|
|
463
|
+
let pcm = decode_audio_from_reader(
|
|
464
|
+
Cursor::new(bytes),
|
|
465
|
+
input_format.as_audio_format(),
|
|
466
|
+
)
|
|
467
|
+
.map_err(stringify_error)?;
|
|
468
|
+
write_pcm_as_wav(&pcm, &mut output).map_err(stringify_error)?;
|
|
469
|
+
}
|
|
470
|
+
#[cfg(not(feature = "decode"))]
|
|
471
|
+
return Err(stringify_error(Error::FeatureDisabled {
|
|
472
|
+
feature: "decode",
|
|
473
|
+
}));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
} else if input_format.is_audio_input()
|
|
478
|
+
&& matches!(output_format, CliFormat::Dat | CliFormat::Json)
|
|
479
|
+
{
|
|
480
|
+
let waveform = generate_waveform_from_input(
|
|
481
|
+
cli.input_filename.as_deref(),
|
|
482
|
+
input_format,
|
|
483
|
+
raw_config.as_ref(),
|
|
484
|
+
GenerateOptions {
|
|
485
|
+
scale,
|
|
486
|
+
split_channels: cli.split_channels,
|
|
487
|
+
amplitude_scale: match amplitude {
|
|
488
|
+
ParsedAmplitudeScale::Auto => Some(AmplitudeScale::Auto),
|
|
489
|
+
ParsedAmplitudeScale::Fixed(value) => Some(AmplitudeScale::Fixed(value)),
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
)?;
|
|
493
|
+
let mut output = create_output(cli.output_filename.as_deref())?;
|
|
494
|
+
waveform
|
|
495
|
+
.write_to_writer(
|
|
496
|
+
&mut output,
|
|
497
|
+
output_format.as_waveform_format().expect("waveform format"),
|
|
498
|
+
Some(bits.unwrap_or(16) as u8),
|
|
499
|
+
)
|
|
500
|
+
.map_err(stringify_error)?;
|
|
501
|
+
} else if input_format.is_waveform_input()
|
|
502
|
+
&& matches!(
|
|
503
|
+
output_format,
|
|
504
|
+
CliFormat::Dat | CliFormat::Json | CliFormat::Txt
|
|
505
|
+
)
|
|
506
|
+
&& !has_resample
|
|
507
|
+
{
|
|
508
|
+
let waveform = load_waveform_input(cli.input_filename.as_deref(), input_format)?;
|
|
509
|
+
let mut output = create_output(cli.output_filename.as_deref())?;
|
|
510
|
+
waveform
|
|
511
|
+
.write_to_writer(
|
|
512
|
+
&mut output,
|
|
513
|
+
output_format.as_waveform_format().expect("waveform format"),
|
|
514
|
+
bits.map(|bits| bits as u8),
|
|
515
|
+
)
|
|
516
|
+
.map_err(stringify_error)?;
|
|
517
|
+
} else if input_format.is_waveform_input()
|
|
518
|
+
&& matches!(output_format, CliFormat::Dat | CliFormat::Json)
|
|
519
|
+
&& has_resample
|
|
520
|
+
{
|
|
521
|
+
let waveform = load_waveform_input(cli.input_filename.as_deref(), input_format)?;
|
|
522
|
+
let resampled = waveform.resample(scale).map_err(stringify_error)?;
|
|
523
|
+
let mut output = create_output(cli.output_filename.as_deref())?;
|
|
524
|
+
resampled
|
|
525
|
+
.write_to_writer(
|
|
526
|
+
&mut output,
|
|
527
|
+
output_format.as_waveform_format().expect("waveform format"),
|
|
528
|
+
bits.map(|bits| bits as u8),
|
|
529
|
+
)
|
|
530
|
+
.map_err(stringify_error)?;
|
|
531
|
+
} else if (input_format.is_audio_input() || input_format.is_waveform_input())
|
|
532
|
+
&& output_format == CliFormat::Png
|
|
533
|
+
{
|
|
534
|
+
#[cfg(feature = "render")]
|
|
535
|
+
{
|
|
536
|
+
let waveform = if input_format.is_audio_input() {
|
|
537
|
+
generate_waveform_from_input(
|
|
538
|
+
cli.input_filename.as_deref(),
|
|
539
|
+
input_format,
|
|
540
|
+
raw_config.as_ref(),
|
|
541
|
+
GenerateOptions {
|
|
542
|
+
scale,
|
|
543
|
+
split_channels: cli.split_channels,
|
|
544
|
+
amplitude_scale: None,
|
|
545
|
+
},
|
|
546
|
+
)?
|
|
547
|
+
} else {
|
|
548
|
+
let waveform = load_waveform_input(cli.input_filename.as_deref(), input_format)?;
|
|
549
|
+
waveform.resample(scale).map_err(stringify_error)?
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
let render_options = RenderOptions {
|
|
553
|
+
width: cli.width as u32,
|
|
554
|
+
height: cli.height as u32,
|
|
555
|
+
start_time: cli.start,
|
|
556
|
+
amplitude_scale: match amplitude {
|
|
557
|
+
ParsedAmplitudeScale::Auto => AmplitudeScale::Auto,
|
|
558
|
+
ParsedAmplitudeScale::Fixed(value) => AmplitudeScale::Fixed(value),
|
|
559
|
+
},
|
|
560
|
+
axis_labels: _axis_labels,
|
|
561
|
+
style: render_style,
|
|
562
|
+
colors: _colors,
|
|
563
|
+
png_compression_level: _compression.map(|value| value as u8),
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
let mut output = create_output(cli.output_filename.as_deref())?;
|
|
567
|
+
write_waveform_png(&waveform, &render_options, &mut output).map_err(stringify_error)?;
|
|
568
|
+
}
|
|
569
|
+
} else {
|
|
570
|
+
return Err(format!(
|
|
571
|
+
"Can't generate {} format output from {} format input",
|
|
572
|
+
output_format.name(),
|
|
573
|
+
input_format.name()
|
|
574
|
+
));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
logger.info("Done");
|
|
578
|
+
Ok(())
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
fn resolve_format(
|
|
582
|
+
filename: Option<&str>,
|
|
583
|
+
explicit: Option<CliFormat>,
|
|
584
|
+
input: bool,
|
|
585
|
+
) -> Result<CliFormat, String> {
|
|
586
|
+
if let Some(explicit) = explicit {
|
|
587
|
+
return Ok(explicit);
|
|
588
|
+
}
|
|
589
|
+
if let Some(filename) = filename {
|
|
590
|
+
return CliFormat::from_path(filename);
|
|
591
|
+
}
|
|
592
|
+
Err(if input {
|
|
593
|
+
"Error: Must specify either input filename or input format".to_string()
|
|
594
|
+
} else {
|
|
595
|
+
"Error: Must specify either output filename or output format".to_string()
|
|
596
|
+
})
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
fn resolve_bits(bits: Option<i32>) -> Result<Option<i32>, String> {
|
|
600
|
+
match bits {
|
|
601
|
+
Some(8 | 16) | None => Ok(bits),
|
|
602
|
+
Some(_) => Err("Error: Invalid bits: must be either 8 or 16".to_string()),
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
fn resolve_compression(compression: i32) -> Result<Option<i32>, String> {
|
|
607
|
+
if (-1..=9).contains(&compression) {
|
|
608
|
+
Ok((compression >= 0).then_some(compression))
|
|
609
|
+
} else {
|
|
610
|
+
Err(
|
|
611
|
+
"Error: Invalid compression level: must be from 0 (none) to 9 (best), or -1 (default)"
|
|
612
|
+
.to_string(),
|
|
613
|
+
)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
fn parse_amplitude_scale(value: &str) -> Result<ParsedAmplitudeScale, String> {
|
|
618
|
+
if value == "auto" {
|
|
619
|
+
return Ok(ParsedAmplitudeScale::Auto);
|
|
620
|
+
}
|
|
621
|
+
let parsed = value
|
|
622
|
+
.parse::<f64>()
|
|
623
|
+
.map_err(|_| "Error: Invalid amplitude scale: must be a number".to_string())?;
|
|
624
|
+
if !parsed.is_finite() || parsed < 0.0 {
|
|
625
|
+
Err("Error: Invalid amplitude scale: must be a positive number".to_string())
|
|
626
|
+
} else {
|
|
627
|
+
Ok(ParsedAmplitudeScale::Fixed(parsed))
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
fn resolve_scale(cli: &Cli) -> Result<ScaleSpec, String> {
|
|
632
|
+
if cli.zoom.is_some() && cli.end.is_some() {
|
|
633
|
+
return Err("Specify either --end or --zoom but not both".to_string());
|
|
634
|
+
}
|
|
635
|
+
if cli.pixels_per_second.is_some() && cli.end.is_some() {
|
|
636
|
+
return Err("Specify either --end or --pixels-per-second but not both".to_string());
|
|
637
|
+
}
|
|
638
|
+
if cli.zoom.is_some() && cli.pixels_per_second.is_some() {
|
|
639
|
+
return Err("Specify either --zoom or --pixels-per-second but not both".to_string());
|
|
640
|
+
}
|
|
641
|
+
if cli.width < 1 {
|
|
642
|
+
return Err("Invalid image width: minimum 1".to_string());
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if let Some(end) = cli.end {
|
|
646
|
+
return Ok(ScaleSpec::FitWidth {
|
|
647
|
+
width_pixels: cli.width as u32,
|
|
648
|
+
time_range: Some((cli.start, end)),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
if let Some(pixels_per_second) = cli.pixels_per_second {
|
|
652
|
+
if pixels_per_second <= 0 {
|
|
653
|
+
return Err("Invalid pixels per second: must be greater than zero".to_string());
|
|
654
|
+
}
|
|
655
|
+
return Ok(ScaleSpec::PixelsPerSecond(pixels_per_second as u32));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
match cli.zoom.as_deref() {
|
|
659
|
+
Some("auto") => Ok(ScaleSpec::FitWidth {
|
|
660
|
+
width_pixels: cli.width as u32,
|
|
661
|
+
time_range: None,
|
|
662
|
+
}),
|
|
663
|
+
Some(value) => {
|
|
664
|
+
let zoom = value
|
|
665
|
+
.parse::<u32>()
|
|
666
|
+
.map_err(|_| "Error: Invalid zoom: must be a number or 'auto'".to_string())?;
|
|
667
|
+
Ok(ScaleSpec::SamplesPerPixel(zoom))
|
|
668
|
+
}
|
|
669
|
+
None => Ok(ScaleSpec::SamplesPerPixel(256)),
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
fn resolve_colors(cli: &Cli) -> Result<WaveformColors, String> {
|
|
674
|
+
let mut colors = cli.color_scheme.into_library().palette();
|
|
675
|
+
|
|
676
|
+
if let Some(value) = &cli.border_color {
|
|
677
|
+
colors.border = Color::from_str(value).map_err(stringify_error)?;
|
|
678
|
+
}
|
|
679
|
+
if let Some(value) = &cli.background_color {
|
|
680
|
+
colors.background = Color::from_str(value).map_err(stringify_error)?;
|
|
681
|
+
}
|
|
682
|
+
if let Some(value) = &cli.axis_label_color {
|
|
683
|
+
colors.axis_label = Color::from_str(value).map_err(stringify_error)?;
|
|
684
|
+
}
|
|
685
|
+
if let Some(value) = &cli.waveform_color {
|
|
686
|
+
let waveform = value
|
|
687
|
+
.split(',')
|
|
688
|
+
.map(|item| Color::from_str(item).map_err(stringify_error))
|
|
689
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
690
|
+
if !waveform.is_empty() {
|
|
691
|
+
colors.waveform = waveform;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
Ok(colors)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
#[cfg(feature = "render")]
|
|
699
|
+
fn resolve_render_style(cli: &Cli) -> Result<RenderStyle, String> {
|
|
700
|
+
match cli.waveform_style {
|
|
701
|
+
CliWaveformStyle::Normal => Ok(RenderStyle::Normal),
|
|
702
|
+
CliWaveformStyle::Bars => {
|
|
703
|
+
if cli.bar_width < 1 {
|
|
704
|
+
return Err("Invalid bar width: minimum 1".to_string());
|
|
705
|
+
}
|
|
706
|
+
if cli.bar_gap < 0 {
|
|
707
|
+
return Err("Invalid bar gap: minimum 0".to_string());
|
|
708
|
+
}
|
|
709
|
+
Ok(RenderStyle::Bars {
|
|
710
|
+
width: cli.bar_width as u32,
|
|
711
|
+
gap: cli.bar_gap as u32,
|
|
712
|
+
style: cli.bar_style.into_library(),
|
|
713
|
+
})
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
fn resolve_raw_audio_config(cli: &Cli) -> Result<RawAudioConfig, String> {
|
|
719
|
+
let sample_rate = cli
|
|
720
|
+
.raw_sample_rate
|
|
721
|
+
.ok_or_else(|| "Error: Missing --raw-samplerate option".to_string())?;
|
|
722
|
+
let channels = cli
|
|
723
|
+
.raw_channels
|
|
724
|
+
.ok_or_else(|| "Error: Missing --raw-channels option".to_string())?;
|
|
725
|
+
let sample_format = cli
|
|
726
|
+
.raw_format
|
|
727
|
+
.map(CliRawSampleFormat::into_library)
|
|
728
|
+
.ok_or_else(|| "Error: Missing --raw-format option".to_string())?;
|
|
729
|
+
if sample_rate <= 0 {
|
|
730
|
+
return Err("Invalid input sample rate: must be greater than zero".to_string());
|
|
731
|
+
}
|
|
732
|
+
if channels <= 0 {
|
|
733
|
+
return Err("Invalid number of input channels: must be greater than zero".to_string());
|
|
734
|
+
}
|
|
735
|
+
let channels = u16::try_from(channels)
|
|
736
|
+
.map_err(|_| "Invalid number of input channels: maximum 65535".to_string())?;
|
|
737
|
+
|
|
738
|
+
RawAudioConfig::new(sample_rate as u32, channels, sample_format).map_err(stringify_error)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
fn generate_waveform_from_input(
|
|
742
|
+
filename: Option<&str>,
|
|
743
|
+
format: CliFormat,
|
|
744
|
+
raw: Option<&RawAudioConfig>,
|
|
745
|
+
options: GenerateOptions,
|
|
746
|
+
) -> Result<Waveform, String> {
|
|
747
|
+
let bytes = read_input_bytes(filename)?;
|
|
748
|
+
match format {
|
|
749
|
+
CliFormat::Raw => generate_waveform_from_raw_reader(
|
|
750
|
+
Cursor::new(bytes),
|
|
751
|
+
raw.expect("validated raw config"),
|
|
752
|
+
&options,
|
|
753
|
+
)
|
|
754
|
+
.map_err(stringify_error),
|
|
755
|
+
#[cfg(feature = "decode")]
|
|
756
|
+
_ => generate_waveform_from_reader(Cursor::new(bytes), format.as_audio_format(), &options)
|
|
757
|
+
.map_err(stringify_error),
|
|
758
|
+
#[cfg(not(feature = "decode"))]
|
|
759
|
+
_ => Err(stringify_error(Error::FeatureDisabled {
|
|
760
|
+
feature: "decode",
|
|
761
|
+
})),
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
fn load_waveform_input(filename: Option<&str>, format: CliFormat) -> Result<Waveform, String> {
|
|
766
|
+
let bytes = read_input_bytes(filename)?;
|
|
767
|
+
Waveform::load_from_reader(
|
|
768
|
+
Cursor::new(bytes),
|
|
769
|
+
format.as_waveform_format().expect("waveform input"),
|
|
770
|
+
)
|
|
771
|
+
.map_err(stringify_error)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
fn read_input_bytes(filename: Option<&str>) -> Result<Vec<u8>, String> {
|
|
775
|
+
if is_stdio_filename(filename) {
|
|
776
|
+
let mut buffer = Vec::new();
|
|
777
|
+
io::stdin()
|
|
778
|
+
.lock()
|
|
779
|
+
.read_to_end(&mut buffer)
|
|
780
|
+
.map_err(|error| error.to_string())?;
|
|
781
|
+
Ok(buffer)
|
|
782
|
+
} else {
|
|
783
|
+
std::fs::read(filename.expect("checked stdio")).map_err(|error| error.to_string())
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
fn create_output(filename: Option<&str>) -> Result<Box<dyn Write>, String> {
|
|
788
|
+
if is_stdio_filename(filename) {
|
|
789
|
+
Ok(Box::new(io::stdout()))
|
|
790
|
+
} else {
|
|
791
|
+
File::create(filename.expect("checked stdio"))
|
|
792
|
+
.map(|file| Box::new(file) as Box<dyn Write>)
|
|
793
|
+
.map_err(|error| error.to_string())
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
fn is_stdio_filename(filename: Option<&str>) -> bool {
|
|
798
|
+
filename.is_none() || filename == Some("-")
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
fn stringify_error(error: Error) -> String {
|
|
802
|
+
error.to_string()
|
|
803
|
+
}
|