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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/COPYING +674 -0
  3. data/Cargo.lock +1108 -0
  4. data/Cargo.toml +28 -0
  5. data/README.md +255 -0
  6. data/bindings/ruby/CHANGELOG.md +29 -0
  7. data/bindings/ruby/Cargo.lock +621 -0
  8. data/bindings/ruby/Cargo.toml +8 -0
  9. data/bindings/ruby/README.md +193 -0
  10. data/bindings/ruby/ext/audiowaveform/Cargo.toml +20 -0
  11. data/bindings/ruby/ext/audiowaveform/build.rs +4 -0
  12. data/bindings/ruby/ext/audiowaveform/extconf.rb +6 -0
  13. data/bindings/ruby/ext/audiowaveform/src/lib.rs +239 -0
  14. data/bindings/ruby/lib/audiowaveform/version.rb +5 -0
  15. data/bindings/ruby/lib/audiowaveform.rb +131 -0
  16. data/crates/audiowaveform/Cargo.toml +73 -0
  17. data/crates/audiowaveform/examples/generate_from_pcm.rs +25 -0
  18. data/crates/audiowaveform/examples/generate_waveform.rs +18 -0
  19. data/crates/audiowaveform/examples/render_waveform.rs +18 -0
  20. data/crates/audiowaveform/examples/resample_waveform.rs +19 -0
  21. data/crates/audiowaveform/src/audio.rs +938 -0
  22. data/crates/audiowaveform/src/color.rs +201 -0
  23. data/crates/audiowaveform/src/error.rs +89 -0
  24. data/crates/audiowaveform/src/format.rs +215 -0
  25. data/crates/audiowaveform/src/lib.rs +79 -0
  26. data/crates/audiowaveform/src/render.rs +802 -0
  27. data/crates/audiowaveform/src/wav.rs +95 -0
  28. data/crates/audiowaveform/src/waveform.rs +790 -0
  29. data/crates/audiowaveform/tests/formats.rs +223 -0
  30. data/crates/audiowaveform/tests/generate.rs +233 -0
  31. data/crates/audiowaveform/tests/render.rs +263 -0
  32. data/crates/audiowaveform/tests/support/mod.rs +125 -0
  33. data/crates/audiowaveform/tests/wav.rs +54 -0
  34. data/crates/audiowaveform/tests/waveform_io.rs +255 -0
  35. data/crates/audiowaveform-cli/Cargo.toml +43 -0
  36. data/crates/audiowaveform-cli/src/main.rs +803 -0
  37. data/crates/audiowaveform-cli/tests/cli.rs +483 -0
  38. data/crates/audiowaveform-cli/tests/support/mod.rs +111 -0
  39. data/sig/audiowaveform.rbs +33 -0
  40. metadata +100 -0
@@ -0,0 +1,802 @@
1
+ use std::fs::File;
2
+ use std::io::{BufWriter, Write};
3
+ use std::path::Path;
4
+
5
+ use image::{Rgba, RgbaImage};
6
+ use png::{BitDepth, ColorType, Compression, Encoder, FilterType};
7
+
8
+ use crate::Error;
9
+ use crate::{AmplitudeScale, Color, Waveform, WaveformColors};
10
+
11
+ /// Available waveform bar styles.
12
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
13
+ pub enum BarStyle {
14
+ /// Square-ended bars.
15
+ Square,
16
+ /// Rounded bars.
17
+ Rounded,
18
+ }
19
+
20
+ /// Waveform rendering style.
21
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
22
+ pub enum RenderStyle {
23
+ /// Draw each waveform point as a vertical line.
24
+ Normal,
25
+ /// Draw waveform points as grouped bars.
26
+ Bars {
27
+ /// Width of each bar in pixels.
28
+ width: u32,
29
+ /// Gap between bars in pixels.
30
+ gap: u32,
31
+ /// Bar end-cap style.
32
+ style: BarStyle,
33
+ },
34
+ }
35
+
36
+ /// Options controlling waveform rendering.
37
+ #[derive(Clone, Debug, PartialEq)]
38
+ pub struct RenderOptions {
39
+ /// Output image width in pixels.
40
+ pub width: u32,
41
+ /// Output image height in pixels.
42
+ pub height: u32,
43
+ /// Start time offset in seconds.
44
+ pub start_time: f64,
45
+ /// Amplitude scaling applied during rendering.
46
+ pub amplitude_scale: AmplitudeScale,
47
+ /// Whether to render time-axis labels.
48
+ pub axis_labels: bool,
49
+ /// Drawing style.
50
+ pub style: RenderStyle,
51
+ /// Render palette.
52
+ pub colors: WaveformColors,
53
+ /// Optional PNG compression level from `0` to `9`.
54
+ pub png_compression_level: Option<u8>,
55
+ }
56
+
57
+ impl Default for RenderOptions {
58
+ fn default() -> Self {
59
+ Self {
60
+ width: 800,
61
+ height: 250,
62
+ start_time: 0.0,
63
+ amplitude_scale: AmplitudeScale::Fixed(1.0),
64
+ axis_labels: true,
65
+ style: RenderStyle::Normal,
66
+ colors: WaveformColors::default(),
67
+ png_compression_level: None,
68
+ }
69
+ }
70
+ }
71
+
72
+ /// Renders a waveform into an RGBA image buffer.
73
+ pub fn render_waveform(waveform: &Waveform, options: &RenderOptions) -> Result<RgbaImage, Error> {
74
+ if options.width == 0 {
75
+ return Err(Error::invalid_argument(
76
+ "image width",
77
+ "Invalid image width: minimum 1",
78
+ ));
79
+ }
80
+ if options.height == 0 {
81
+ return Err(Error::invalid_argument(
82
+ "image height",
83
+ "Invalid image height: minimum 1",
84
+ ));
85
+ }
86
+ if !options.start_time.is_finite() || options.start_time < 0.0 {
87
+ return Err(Error::invalid_argument(
88
+ "start time",
89
+ "Invalid start time: minimum 0",
90
+ ));
91
+ }
92
+ if waveform.is_empty() {
93
+ return Err(Error::invalid_argument("waveform", "Empty waveform buffer"));
94
+ }
95
+ if options.colors.waveform.is_empty() {
96
+ return Err(Error::invalid_argument(
97
+ "waveform colors",
98
+ "At least one waveform color is required",
99
+ ));
100
+ }
101
+
102
+ let mut image = RgbaImage::from_pixel(
103
+ options.width,
104
+ options.height,
105
+ rgba(options.colors.background),
106
+ );
107
+
108
+ if options.axis_labels {
109
+ draw_border(&mut image, rgba(options.colors.border));
110
+ }
111
+
112
+ match options.style {
113
+ RenderStyle::Normal => draw_waveform_lines(&mut image, waveform, options)?,
114
+ RenderStyle::Bars { width, gap, style } => {
115
+ if width == 0 {
116
+ return Err(Error::invalid_argument(
117
+ "bar width",
118
+ "Invalid bar width: minimum 1",
119
+ ));
120
+ }
121
+ draw_waveform_bars(&mut image, waveform, options, width, gap, style)?
122
+ }
123
+ }
124
+
125
+ if options.axis_labels {
126
+ draw_time_axis_labels(&mut image, waveform, options);
127
+ }
128
+
129
+ Ok(image)
130
+ }
131
+
132
+ impl Waveform {
133
+ /// Renders the waveform into an RGBA image buffer.
134
+ pub fn render(&self, options: &RenderOptions) -> Result<RgbaImage, Error> {
135
+ render_waveform(self, options)
136
+ }
137
+
138
+ /// Writes the waveform as a PNG image to an arbitrary writer.
139
+ pub fn write_png<W: Write>(&self, options: &RenderOptions, writer: W) -> Result<(), Error> {
140
+ write_waveform_png(self, options, writer)
141
+ }
142
+ }
143
+
144
+ /// Writes a rendered waveform PNG to an arbitrary writer.
145
+ pub fn write_waveform_png<W: Write>(
146
+ waveform: &Waveform,
147
+ options: &RenderOptions,
148
+ writer: W,
149
+ ) -> Result<(), Error> {
150
+ let image = render_waveform(waveform, options)?;
151
+ let mut encoder = Encoder::new(writer, image.width(), image.height());
152
+ encoder.set_color(ColorType::Rgba);
153
+ encoder.set_depth(BitDepth::Eight);
154
+ encoder.set_filter(FilterType::NoFilter);
155
+ encoder.set_compression(match options.png_compression_level {
156
+ Some(0) => Compression::Fast,
157
+ Some(level @ 1..=6) if level <= 3 => Compression::Fast,
158
+ Some(7..=9) => Compression::Best,
159
+ _ => Compression::Default,
160
+ });
161
+ let mut png = encoder.write_header()?;
162
+ png.write_image_data(image.as_raw())?;
163
+ Ok(())
164
+ }
165
+
166
+ /// Renders a waveform directly to a PNG file.
167
+ pub fn render_waveform_to_path(
168
+ waveform: &Waveform,
169
+ options: &RenderOptions,
170
+ path: impl AsRef<Path>,
171
+ ) -> Result<(), Error> {
172
+ let file = File::create(path)?;
173
+ write_waveform_png(waveform, options, BufWriter::new(file))
174
+ }
175
+
176
+ fn draw_waveform_lines(
177
+ image: &mut RgbaImage,
178
+ waveform: &Waveform,
179
+ options: &RenderOptions,
180
+ ) -> Result<(), Error> {
181
+ let top_y = if options.axis_labels { 1 } else { 0 };
182
+ let bottom_y = if options.axis_labels {
183
+ options.height as i32 - 2
184
+ } else {
185
+ options.height as i32 - 1
186
+ };
187
+ let start_index = seconds_to_pixels(waveform, options.start_time);
188
+ let end_index = (start_index + options.width as usize).min(waveform.len());
189
+ let amplitude =
190
+ resolve_amplitude_scale(waveform, options.amplitude_scale, start_index, end_index)?;
191
+ let channels = waveform.channels() as usize;
192
+ let mut available_height = bottom_y - top_y + 1;
193
+ let row_height = available_height / channels as i32;
194
+ let mut waveform_top_y = top_y;
195
+
196
+ for channel in 0..channels {
197
+ let waveform_bottom_y = if channel == channels - 1 {
198
+ waveform_top_y + available_height - 1
199
+ } else {
200
+ waveform_top_y + row_height
201
+ };
202
+ let height = waveform_bottom_y - waveform_top_y + 1;
203
+ let color = options.colors.waveform[channel % options.colors.waveform.len()];
204
+ for (x, index) in (0..options.width as usize).zip(start_index..) {
205
+ if index >= waveform.len() {
206
+ break;
207
+ }
208
+ let point = waveform
209
+ .point(channel as u16, index)
210
+ .expect("point within range");
211
+ let low = i32::from(scale_sample(point.min, amplitude)) + 32768;
212
+ let high = i32::from(scale_sample(point.max, amplitude)) + 32768;
213
+ let top = waveform_top_y + height - 1 - high * height / 65_536;
214
+ let bottom = waveform_top_y + height - 1 - low * height / 65_536;
215
+ draw_vertical_line(image, x as i32, top, bottom, rgba(color));
216
+ }
217
+ available_height -= row_height + 1;
218
+ waveform_top_y += row_height + 1;
219
+ }
220
+ Ok(())
221
+ }
222
+
223
+ fn draw_waveform_bars(
224
+ image: &mut RgbaImage,
225
+ waveform: &Waveform,
226
+ options: &RenderOptions,
227
+ bar_width: u32,
228
+ bar_gap: u32,
229
+ bar_style: BarStyle,
230
+ ) -> Result<(), Error> {
231
+ let top_y = if options.axis_labels { 1 } else { 0 };
232
+ let bottom_y = if options.axis_labels {
233
+ options.height as i32 - 2
234
+ } else {
235
+ options.height as i32 - 1
236
+ };
237
+ let start_index = seconds_to_pixels(waveform, options.start_time);
238
+ let end_index = (start_index + options.width as usize).min(waveform.len());
239
+ let amplitude =
240
+ resolve_amplitude_scale(waveform, options.amplitude_scale, start_index, end_index)?;
241
+ let channels = waveform.channels() as usize;
242
+ let mut available_height = bottom_y - top_y + 1;
243
+ let row_height = available_height / channels as i32;
244
+ let mut waveform_top_y = top_y;
245
+ let bar_total = (bar_width + bar_gap) as usize;
246
+ let bar_start_index = (start_index / bar_total) * bar_total;
247
+ let bar_start_offset = bar_start_index as isize - start_index as isize;
248
+
249
+ for channel in 0..channels {
250
+ let waveform_bottom_y = if channel == channels - 1 {
251
+ waveform_top_y + available_height - 1
252
+ } else {
253
+ waveform_top_y + row_height
254
+ };
255
+ let height = waveform_bottom_y - waveform_top_y + 1;
256
+ let color = rgba(options.colors.waveform[channel % options.colors.waveform.len()]);
257
+
258
+ let mut index = bar_start_index;
259
+ let mut x = bar_start_offset;
260
+ while x < options.width as isize {
261
+ let bar_height = get_bar_height(waveform, channel as u16, index, bar_total);
262
+ let low = i32::from(scale_sample(-(bar_height as i16), amplitude)) + 32768;
263
+ let high = i32::from(scale_sample(bar_height as i16, amplitude)) + 32768;
264
+ let top = waveform_top_y + height - 1 - high * height / 65_536;
265
+ let bottom = waveform_top_y + height - 1 - low * height / 65_536;
266
+ if top != bottom {
267
+ if bar_style == BarStyle::Rounded && bar_width > 2 && height >= 3 {
268
+ let radius = if bar_width > 4 {
269
+ (bar_width / 4) as i32
270
+ } else {
271
+ (bar_width / 2) as i32
272
+ };
273
+ draw_rounded_rect(
274
+ image,
275
+ x as i32,
276
+ top,
277
+ x as i32 + bar_width as i32 - 1,
278
+ bottom,
279
+ radius,
280
+ color,
281
+ );
282
+ } else {
283
+ fill_rect(
284
+ image,
285
+ x as i32,
286
+ top,
287
+ x as i32 + bar_width as i32 - 1,
288
+ bottom,
289
+ color,
290
+ );
291
+ }
292
+ }
293
+ index += bar_total;
294
+ x += bar_total as isize;
295
+ }
296
+
297
+ available_height -= row_height + 1;
298
+ waveform_top_y += row_height + 1;
299
+ }
300
+ Ok(())
301
+ }
302
+
303
+ fn get_bar_height(waveform: &Waveform, channel: u16, start: usize, width: usize) -> i32 {
304
+ if start >= waveform.len() {
305
+ return 0;
306
+ }
307
+ let mut low = i32::MAX;
308
+ let mut high = i32::MIN;
309
+ for index in start..(start + width).min(waveform.len()) {
310
+ let point = waveform.point(channel, index).expect("point within range");
311
+ low = low.min(i32::from(point.min));
312
+ high = high.max(i32::from(point.max));
313
+ }
314
+ low = low.abs().clamp(0, i32::from(i16::MAX));
315
+ high = high.abs().clamp(0, i32::from(i16::MAX));
316
+ low.max(high)
317
+ }
318
+
319
+ fn draw_time_axis_labels(image: &mut RgbaImage, waveform: &Waveform, options: &RenderOptions) {
320
+ let marker_height = 10_i32;
321
+ let Some(interval_secs) = axis_label_scale(waveform) else {
322
+ return;
323
+ };
324
+ let Some(first_secs) = round_up_to_nearest(options.start_time, interval_secs) else {
325
+ return;
326
+ };
327
+ let axis_label_offset_secs = first_secs as f64 - options.start_time;
328
+ let axis_label_offset_pixels = ((axis_label_offset_secs * waveform.sample_rate() as f64)
329
+ / waveform.samples_per_pixel() as f64) as i64;
330
+ let border = rgba(options.colors.border);
331
+ let text = rgba(options.colors.axis_label);
332
+ let mut secs = first_secs;
333
+
334
+ loop {
335
+ let x = i128::from(axis_label_offset_pixels)
336
+ + (i128::from(secs - first_secs) * i128::from(waveform.sample_rate())
337
+ / i128::from(waveform.samples_per_pixel()));
338
+ if x >= i128::from(image.width()) {
339
+ break;
340
+ }
341
+ let Ok(x) = i32::try_from(x) else {
342
+ break;
343
+ };
344
+ draw_vertical_line(image, x, 0, marker_height, border);
345
+ draw_vertical_line(
346
+ image,
347
+ x,
348
+ image.height() as i32 - 1,
349
+ image.height() as i32 - 1 - marker_height,
350
+ border,
351
+ );
352
+
353
+ let label = seconds_to_string(secs);
354
+ let width = text_width(&label);
355
+ let label_x = x - (width / 2) + 1;
356
+ let label_y = image.height() as i32 - 1 - marker_height - 1 - LABEL_FONT_HEIGHT;
357
+ if label_x >= 0 {
358
+ draw_text(image, label_x, label_y, &label, text);
359
+ }
360
+ let Some(next_secs) = secs.checked_add(interval_secs) else {
361
+ break;
362
+ };
363
+ secs = next_secs;
364
+ }
365
+ }
366
+
367
+ fn axis_label_scale(waveform: &Waveform) -> Option<i64> {
368
+ let steps = [1_i64, 2, 5, 10, 20, 30];
369
+ let mut base = 1_i64;
370
+ let mut index = 0_usize;
371
+ loop {
372
+ let secs = base.checked_mul(steps[index])?;
373
+ let pixels = seconds_to_pixels(waveform, secs as f64);
374
+ if pixels < 60 {
375
+ index += 1;
376
+ if index == steps.len() {
377
+ base = base.checked_mul(60)?;
378
+ index = 0;
379
+ }
380
+ } else {
381
+ return Some(secs);
382
+ }
383
+ }
384
+ }
385
+
386
+ fn resolve_amplitude_scale(
387
+ waveform: &Waveform,
388
+ scale: AmplitudeScale,
389
+ start_index: usize,
390
+ end_index: usize,
391
+ ) -> Result<f64, Error> {
392
+ match scale {
393
+ AmplitudeScale::Fixed(value) => {
394
+ if !value.is_finite() || value < 0.0 {
395
+ Err(Error::invalid_argument(
396
+ "amplitude scale",
397
+ "Invalid amplitude scale: must be a positive number",
398
+ ))
399
+ } else {
400
+ Ok(value)
401
+ }
402
+ }
403
+ AmplitudeScale::Auto if start_index >= end_index => Ok(1.0),
404
+ AmplitudeScale::Auto => waveform.auto_amplitude_scale(start_index, end_index),
405
+ }
406
+ }
407
+
408
+ fn draw_border(image: &mut RgbaImage, color: Rgba<u8>) {
409
+ let width = image.width() as i32;
410
+ let height = image.height() as i32;
411
+ draw_horizontal_line(image, 0, width - 1, 0, color);
412
+ draw_horizontal_line(image, 0, width - 1, height - 1, color);
413
+ draw_vertical_line(image, 0, 0, height - 1, color);
414
+ draw_vertical_line(image, width - 1, 0, height - 1, color);
415
+ }
416
+
417
+ fn draw_vertical_line(image: &mut RgbaImage, x: i32, y1: i32, y2: i32, color: Rgba<u8>) {
418
+ let start = y1.min(y2);
419
+ let end = y1.max(y2);
420
+ for y in start..=end {
421
+ put_pixel(image, x, y, color);
422
+ }
423
+ }
424
+
425
+ fn draw_horizontal_line(image: &mut RgbaImage, x1: i32, x2: i32, y: i32, color: Rgba<u8>) {
426
+ let start = x1.min(x2);
427
+ let end = x1.max(x2);
428
+ for x in start..=end {
429
+ put_pixel(image, x, y, color);
430
+ }
431
+ }
432
+
433
+ fn fill_rect(image: &mut RgbaImage, left: i32, top: i32, right: i32, bottom: i32, color: Rgba<u8>) {
434
+ for y in top..=bottom {
435
+ for x in left..=right {
436
+ put_pixel(image, x, y, color);
437
+ }
438
+ }
439
+ }
440
+
441
+ fn draw_rounded_rect(
442
+ image: &mut RgbaImage,
443
+ left: i32,
444
+ top: i32,
445
+ right: i32,
446
+ bottom: i32,
447
+ radius: i32,
448
+ color: Rgba<u8>,
449
+ ) {
450
+ let left_arc_x = left + radius;
451
+ let top_arc_y = top + radius;
452
+ let right_arc_x = right - radius;
453
+ let bottom_arc_y = bottom - radius;
454
+ fill_rect(image, left, top_arc_y, right, bottom_arc_y, color);
455
+ fill_rect(image, left_arc_x, top, right_arc_x, top_arc_y, color);
456
+ fill_rect(image, left_arc_x, bottom_arc_y, right_arc_x, bottom, color);
457
+ fill_quarter_circle(
458
+ image,
459
+ left_arc_x,
460
+ top_arc_y,
461
+ radius,
462
+ Quadrant::TopLeft,
463
+ color,
464
+ );
465
+ fill_quarter_circle(
466
+ image,
467
+ right_arc_x,
468
+ top_arc_y,
469
+ radius,
470
+ Quadrant::TopRight,
471
+ color,
472
+ );
473
+ fill_quarter_circle(
474
+ image,
475
+ left_arc_x,
476
+ bottom_arc_y,
477
+ radius,
478
+ Quadrant::BottomLeft,
479
+ color,
480
+ );
481
+ fill_quarter_circle(
482
+ image,
483
+ right_arc_x,
484
+ bottom_arc_y,
485
+ radius,
486
+ Quadrant::BottomRight,
487
+ color,
488
+ );
489
+ }
490
+
491
+ enum Quadrant {
492
+ TopLeft,
493
+ TopRight,
494
+ BottomLeft,
495
+ BottomRight,
496
+ }
497
+
498
+ fn fill_quarter_circle(
499
+ image: &mut RgbaImage,
500
+ center_x: i32,
501
+ center_y: i32,
502
+ radius: i32,
503
+ quadrant: Quadrant,
504
+ color: Rgba<u8>,
505
+ ) {
506
+ let radius_sq = radius * radius;
507
+ for dy in -radius..=radius {
508
+ for dx in -radius..=radius {
509
+ if dx * dx + dy * dy > radius_sq {
510
+ continue;
511
+ }
512
+ let allowed = match quadrant {
513
+ Quadrant::TopLeft => dx <= 0 && dy <= 0,
514
+ Quadrant::TopRight => dx >= 0 && dy <= 0,
515
+ Quadrant::BottomLeft => dx <= 0 && dy >= 0,
516
+ Quadrant::BottomRight => dx >= 0 && dy >= 0,
517
+ };
518
+ if allowed {
519
+ put_pixel(image, center_x + dx, center_y + dy, color);
520
+ }
521
+ }
522
+ }
523
+ }
524
+
525
+ fn draw_text(image: &mut RgbaImage, x: i32, y: i32, text: &str, color: Rgba<u8>) {
526
+ let mut cursor_x = x;
527
+ for character in text.chars() {
528
+ draw_glyph(image, cursor_x, y, character, color);
529
+ cursor_x += LABEL_FONT_ADVANCE;
530
+ }
531
+ }
532
+
533
+ fn draw_glyph(image: &mut RgbaImage, x: i32, y: i32, character: char, color: Rgba<u8>) {
534
+ let glyph = glyph_bitmap(character);
535
+
536
+ for (row, bits) in glyph.iter().enumerate() {
537
+ for column in 0..LABEL_FONT_WIDTH {
538
+ if bits & (1 << (LABEL_FONT_WIDTH - 1 - column)) != 0 {
539
+ put_pixel(image, x + column, y + row as i32, color);
540
+ }
541
+ }
542
+ }
543
+ }
544
+
545
+ fn glyph_bitmap(character: char) -> [u8; LABEL_FONT_HEIGHT as usize] {
546
+ match character {
547
+ '0' => [0x1E, 0x21, 0x21, 0x23, 0x25, 0x29, 0x31, 0x21, 0x21, 0x1E],
548
+ '1' => [0x08, 0x18, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x1C],
549
+ '2' => [0x1E, 0x21, 0x01, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x3F],
550
+ '3' => [0x1E, 0x21, 0x01, 0x01, 0x0E, 0x01, 0x01, 0x01, 0x21, 0x1E],
551
+ '4' => [0x06, 0x0A, 0x12, 0x22, 0x3F, 0x02, 0x02, 0x02, 0x02, 0x02],
552
+ '5' => [0x3F, 0x20, 0x20, 0x20, 0x1E, 0x01, 0x01, 0x01, 0x21, 0x1E],
553
+ '6' => [0x0E, 0x10, 0x20, 0x20, 0x3E, 0x21, 0x21, 0x21, 0x21, 0x1E],
554
+ '7' => [0x3F, 0x01, 0x02, 0x02, 0x04, 0x08, 0x08, 0x10, 0x10, 0x10],
555
+ '8' => [0x1E, 0x21, 0x21, 0x21, 0x1E, 0x21, 0x21, 0x21, 0x21, 0x1E],
556
+ '9' => [0x1E, 0x21, 0x21, 0x21, 0x1F, 0x01, 0x01, 0x01, 0x02, 0x1C],
557
+ ':' => [0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00],
558
+ _ => [0x00; LABEL_FONT_HEIGHT as usize],
559
+ }
560
+ }
561
+
562
+ fn text_width(text: &str) -> i32 {
563
+ let glyph_count = text.chars().count() as i32;
564
+ if glyph_count == 0 {
565
+ 0
566
+ } else {
567
+ glyph_count * LABEL_FONT_ADVANCE - LABEL_FONT_TRACKING
568
+ }
569
+ }
570
+
571
+ fn seconds_to_string(seconds: i64) -> String {
572
+ let hours = seconds / 3600;
573
+ let minutes = (seconds % 3600) / 60;
574
+ let seconds = seconds % 60;
575
+ if hours > 0 {
576
+ format!("{hours:02}:{minutes:02}:{seconds:02}")
577
+ } else {
578
+ format!("{minutes:02}:{seconds:02}")
579
+ }
580
+ }
581
+
582
+ fn round_up_to_nearest(value: f64, multiple: i64) -> Option<i64> {
583
+ if multiple <= 0 || !value.is_finite() || value < 0.0 || value > i64::MAX as f64 {
584
+ return None;
585
+ }
586
+ let rounded_up = value.ceil() as i64;
587
+ rounded_up
588
+ .checked_add(multiple - 1)?
589
+ .checked_div(multiple)?
590
+ .checked_mul(multiple)
591
+ }
592
+
593
+ fn seconds_to_pixels(waveform: &Waveform, seconds: f64) -> usize {
594
+ (seconds * waveform.sample_rate() as f64 / waveform.samples_per_pixel() as f64) as usize
595
+ }
596
+
597
+ fn scale_sample(value: i16, multiplier: f64) -> i16 {
598
+ (f64::from(value) * multiplier).clamp(f64::from(i16::MIN), f64::from(i16::MAX)) as i16
599
+ }
600
+
601
+ fn put_pixel(image: &mut RgbaImage, x: i32, y: i32, color: Rgba<u8>) {
602
+ if x >= 0 && y >= 0 && x < image.width() as i32 && y < image.height() as i32 {
603
+ image.put_pixel(x as u32, y as u32, color);
604
+ }
605
+ }
606
+
607
+ fn rgba(color: Color) -> Rgba<u8> {
608
+ Rgba([color.red, color.green, color.blue, color.alpha])
609
+ }
610
+
611
+ const LABEL_FONT_WIDTH: i32 = 6;
612
+ const LABEL_FONT_HEIGHT: i32 = 10;
613
+ const LABEL_FONT_TRACKING: i32 = 1;
614
+ const LABEL_FONT_ADVANCE: i32 = LABEL_FONT_WIDTH + LABEL_FONT_TRACKING;
615
+
616
+ #[cfg(test)]
617
+ mod tests {
618
+ use super::{
619
+ LABEL_FONT_HEIGHT, RenderOptions, RenderStyle, glyph_bitmap, render_waveform,
620
+ round_up_to_nearest, seconds_to_string,
621
+ };
622
+ use crate::{AmplitudeScale, Waveform, WaveformColors, WaveformPoint};
623
+
624
+ fn sample_waveform() -> Waveform {
625
+ let mut waveform = Waveform::new(48_000, 64, 1).expect("waveform");
626
+ waveform
627
+ .push_frame(&[WaveformPoint {
628
+ min: -16_384,
629
+ max: 16_384,
630
+ }])
631
+ .expect("push point");
632
+ waveform
633
+ }
634
+
635
+ #[test]
636
+ fn formats_time_axis_labels() {
637
+ assert_eq!(seconds_to_string(5), "00:05");
638
+ assert_eq!(seconds_to_string(125), "02:05");
639
+ assert_eq!(seconds_to_string(3_665), "01:01:05");
640
+ assert_eq!(round_up_to_nearest(0.0, 5), Some(0));
641
+ assert_eq!(round_up_to_nearest(0.1, 5), Some(5));
642
+ assert_eq!(round_up_to_nearest(12.3, 10), Some(20));
643
+ }
644
+
645
+ #[test]
646
+ fn one_glyph_uses_a_thin_stem() {
647
+ assert_eq!(
648
+ glyph_bitmap('1'),
649
+ [0x08, 0x18, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x1C]
650
+ );
651
+ }
652
+
653
+ #[test]
654
+ fn glyph_fallback_and_metrics_are_stable() {
655
+ assert_eq!(glyph_bitmap('?'), [0x00; LABEL_FONT_HEIGHT as usize]);
656
+ }
657
+
658
+ #[test]
659
+ fn validates_render_options_and_waveform_state() {
660
+ let waveform = sample_waveform();
661
+
662
+ let error = render_waveform(
663
+ &waveform,
664
+ &RenderOptions {
665
+ width: 0,
666
+ ..RenderOptions::default()
667
+ },
668
+ )
669
+ .expect_err("invalid width");
670
+ assert_eq!(error.to_string(), "Invalid image width: minimum 1");
671
+
672
+ let error = render_waveform(
673
+ &waveform,
674
+ &RenderOptions {
675
+ height: 0,
676
+ ..RenderOptions::default()
677
+ },
678
+ )
679
+ .expect_err("invalid height");
680
+ assert_eq!(error.to_string(), "Invalid image height: minimum 1");
681
+
682
+ let error = render_waveform(
683
+ &waveform,
684
+ &RenderOptions {
685
+ start_time: -0.1,
686
+ ..RenderOptions::default()
687
+ },
688
+ )
689
+ .expect_err("invalid start time");
690
+ assert_eq!(error.to_string(), "Invalid start time: minimum 0");
691
+
692
+ let error = render_waveform(
693
+ &waveform,
694
+ &RenderOptions {
695
+ start_time: f64::INFINITY,
696
+ ..RenderOptions::default()
697
+ },
698
+ )
699
+ .expect_err("non-finite start time");
700
+ assert_eq!(error.to_string(), "Invalid start time: minimum 0");
701
+
702
+ let error = render_waveform(
703
+ &Waveform::new(48_000, 64, 1).expect("empty waveform"),
704
+ &RenderOptions::default(),
705
+ )
706
+ .expect_err("empty waveform");
707
+ assert_eq!(error.to_string(), "Empty waveform buffer");
708
+
709
+ let mut colors = WaveformColors::default();
710
+ colors.waveform.clear();
711
+ let error = render_waveform(
712
+ &waveform,
713
+ &RenderOptions {
714
+ colors,
715
+ ..RenderOptions::default()
716
+ },
717
+ )
718
+ .expect_err("empty waveform color palette");
719
+ assert_eq!(error.to_string(), "At least one waveform color is required");
720
+ }
721
+
722
+ #[test]
723
+ fn validates_bar_rendering_and_amplitude_ranges() {
724
+ let waveform = sample_waveform();
725
+
726
+ let error = render_waveform(
727
+ &waveform,
728
+ &RenderOptions {
729
+ style: RenderStyle::Bars {
730
+ width: 0,
731
+ gap: 4,
732
+ style: super::BarStyle::Square,
733
+ },
734
+ ..RenderOptions::default()
735
+ },
736
+ )
737
+ .expect_err("invalid bar width");
738
+ assert_eq!(error.to_string(), "Invalid bar width: minimum 1");
739
+
740
+ let error = render_waveform(
741
+ &waveform,
742
+ &RenderOptions {
743
+ amplitude_scale: AmplitudeScale::Fixed(-1.0),
744
+ ..RenderOptions::default()
745
+ },
746
+ )
747
+ .expect_err("negative amplitude scale");
748
+ assert_eq!(
749
+ error.to_string(),
750
+ "Invalid amplitude scale: must be a positive number"
751
+ );
752
+
753
+ let error = render_waveform(
754
+ &waveform,
755
+ &RenderOptions {
756
+ amplitude_scale: AmplitudeScale::Fixed(f64::NAN),
757
+ ..RenderOptions::default()
758
+ },
759
+ )
760
+ .expect_err("non-finite amplitude scale");
761
+ assert_eq!(
762
+ error.to_string(),
763
+ "Invalid amplitude scale: must be a positive number"
764
+ );
765
+ }
766
+
767
+ #[test]
768
+ fn renders_an_image_with_expected_dimensions() {
769
+ let image = render_waveform(
770
+ &sample_waveform(),
771
+ &RenderOptions {
772
+ width: 32,
773
+ height: 20,
774
+ colors: WaveformColors::default(),
775
+ ..RenderOptions::default()
776
+ },
777
+ )
778
+ .expect("render image");
779
+
780
+ assert_eq!(image.dimensions(), (32, 20));
781
+ }
782
+
783
+ #[test]
784
+ fn renders_without_axis_labels_when_no_interval_fits() {
785
+ let mut waveform = Waveform::new(1, u32::MAX, 1).expect("waveform");
786
+ waveform
787
+ .push_frame(&[WaveformPoint { min: -1, max: 1 }])
788
+ .expect("push point");
789
+
790
+ let image = render_waveform(
791
+ &waveform,
792
+ &RenderOptions {
793
+ width: 32,
794
+ height: 20,
795
+ ..RenderOptions::default()
796
+ },
797
+ )
798
+ .expect("render image");
799
+
800
+ assert_eq!(image.dimensions(), (32, 20));
801
+ }
802
+ }