soundcraft/crates/ui-egui/tests/waveform.rs

106 lines
5.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use egui::{Color32, Pos2, RawInput, Rect, Shape, vec2};
use serde_json::json;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{Clip, ClipId, Session, SourceAudio, SourceId};
use soundcraft_time::SampleRate;
use soundcraft_ui_egui::{Services, SoundApp, waveform};
const WAVE: Color32 = Color32::from_rgb(12, 12, 14);
/// The lines and meshes `f` paints across a 400×100 band on a 2x screen.
fn paint(samples_per_px: f64, f: impl Fn(&egui::Painter, &Session, Rect)) -> Vec<Shape> {
let mut s = Session::new("t", SampleRate::HZ_48000);
s.edit.zoom.samples_per_px = samples_per_px;
let band = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 100.0));
let ctx = egui::Context::default();
ctx.set_pixels_per_point(2.0);
let mut out = ctx.run_ui(RawInput { screen_rect: Some(band), ..Default::default() }, |ui| f(ui.painter(), &s, band));
out.textures_delta.clear();
out.shapes.into_iter().map(|c| c.shape).filter(|s| matches!(s, Shape::Path(_) | Shape::Mesh(_))).collect()
}
fn draw(samples: Vec<f32>, samples_per_px: f64) -> Vec<Shape> {
let clip = Clip::audio(ClipId(1), "c", SourceId(1), 0, 0, samples.len() as i64);
let audio = SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![samples] });
paint(samples_per_px, |p, s, band| waveform::draw(p, s, band, &clip, &audio, 1, band, WAVE))
}
#[test]
fn a_take_draws_its_overview_while_it_records() {
let blocks = vec![vec![(-0.5, 0.5); 100]];
let shapes = paint(128.0, |p, s, band| waveform::draw_live(p, s, band, 1280.0, 128.0, &blocks, band, WAVE));
let [Shape::Mesh(mesh)] = shapes.as_slice() else { panic!("{shapes:?}") };
let span = |f: fn(&Pos2) -> f32| mesh.vertices.iter().map(|v| f(&v.pos)).fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(v), b.max(v)));
let ((left, right), (top, bottom)) = (span(|p| p.x), span(|p| p.y));
assert!((left - 10.0).abs() <= 1.0 && (right - 110.0).abs() <= 1.0, "drawn from {left} to {right}");
assert!((top - 27.0).abs() <= 1.0 && (bottom - 73.0).abs() <= 1.0, "drawn from {top} to {bottom}");
}
#[test]
fn at_the_sample_level_the_waveform_is_a_line_through_the_samples() {
let x: Vec<f32> = (0..200).map(|i| (i as f32 * 0.3).sin() * 0.8).collect();
let shapes = draw(x.clone(), 0.25);
let [Shape::Path(line)] = shapes.as_slice() else { panic!("{shapes:?}") };
assert!(line.points.len() > 100);
for p in &line.points {
let i = (p.x / 4.0).round() as usize;
assert!((p.x - i as f32 * 4.0).abs() < 1e-3 && (p.y - (50.0 - x[i] * 46.0)).abs() < 1e-3, "{p:?}");
}
}
#[test]
fn zoomed_out_a_one_sample_spike_stays_a_point_wide() {
let mut x = vec![0.0f32; 4800];
x[1600] = 1.0;
let shapes = draw(x, 8.0);
let [Shape::Mesh(mesh)] = shapes.as_slice() else { panic!("{shapes:?}") };
let tall: Vec<f32> = mesh.vertices.iter().filter(|v| v.pos.y < 40.0).map(|v| v.pos.x).collect();
let (left, right) = tall.iter().fold((f32::MAX, f32::MIN), |(a, b), &x| (a.min(x), b.max(x)));
assert!(right - left <= 1.0 && (199.0..=201.0).contains(&left), "spike drawn from {left} to {right}");
assert!(mesh.vertices.iter().any(|v| v.pos.y < 5.0 && v.color == WAVE));
}
/// Waveform meshes drawn in a track's row, from `below` its top, once the app has settled.
fn waveforms(ctx: &egui::Context, app: &mut SoundApp, name: &str, below: f32) -> usize {
let mut shapes = Vec::new();
for _ in 0..2 {
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
shapes = out.shapes;
}
let id = app.engine.session().track_by_name(name).unwrap().id.0;
let row = app.edit_layout.rows.iter().find(|(i, _)| *i == id).unwrap().1;
let [x0, _, x1, _] = app.edit_layout.timeline;
let lane = Rect::from_min_max(egui::pos2(x0, row[1] + below), egui::pos2(x1, row[3]));
shapes.iter().filter(|c| matches!(&c.shape, Shape::Mesh(m) if m.vertices.iter().any(|v| lane.contains(v.pos)))).count()
}
#[test]
fn blocks_hide_waveforms_and_playlist_lanes_show_them() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.engine.execute("track.playlist_duplicate", &json!({"track": "Bass"})).unwrap();
app.engine.execute("track.view", &json!({"tracks": ["Bass"], "view": "playlists"})).unwrap();
app.engine.execute("track.view", &json!({"tracks": ["Snare"], "view": "blocks"})).unwrap();
let main = app.engine.session().track_by_name("Bass").unwrap().height.points();
let ctx = egui::Context::default();
assert!(waveforms(&ctx, &mut app, "Kick", 0.0) > 0);
assert_eq!(waveforms(&ctx, &mut app, "Snare", 0.0), 0);
assert!(waveforms(&ctx, &mut app, "Bass", main) > 0, "the alternate playlist's lane");
}
#[test]
fn overlapped_crossfades_show_the_outgoing_clip_too() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.engine.execute("edit.fades_create", &json!({"tracks": ["Bass"], "start": {"seconds": 18.2}, "end": {"seconds": 18.7}})).unwrap();
app.engine.execute("view.zoom_set", &json!({"samples_per_px": 64})).unwrap();
app.engine.execute("view.scroll", &json!({"to": {"seconds": 18.0}})).unwrap();
let ctx = egui::Context::default();
let plain = waveforms(&ctx, &mut app, "Bass", 0.0);
app.engine.execute("view.waveform_overlapped_crossfades", &json!({"value": true})).unwrap();
assert_eq!(waveforms(&ctx, &mut app, "Bass", 0.0), plain + 1);
}