Files
soundcraft/crates/ui-egui/tests/waveform.rs
grovebit 7f9b629964 Draw each take in the Edit window while it records
Pro Tools draws a take's waveform as it records; ours showed nothing
until Stop. Each record-enabled track now shows its take growing as a
red clip with its waveform, drawn from the take's live summary through
the same column grid and channel bands as clips (Peak view, as Pro Tools
always shows while recording).

- The take writer keeps, per take channel, the (min, max) of every 128
  frames it writes (at most 2^22 blocks, three hours at 48 kHz; the file
  records on regardless). Recorder::live_take hands the Edit window what
  a track's take holds so far, while the writer runs. The blocks sit
  behind a mutex the writer thread and the UI lock briefly; the input
  callback is untouched.
- io::live_span places the take as Stop will: from the record start,
  only the pass under way when loop recording, and within the punch
  range.

Tests: the take writer's summary (record.rs), live_span with loop and
punch (io.rs) and draw_live (tests/waveform.rs).
2026-10-12 23:43:58 +01:00

106 lines
5.5 KiB
Rust
Raw Permalink 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);
}