Files
soundcraft/crates/engine/src/cmd/mix/tests.rs
T

487 lines
23 KiB
Rust

use super::*;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
use soundcraft_time::Range;
use std::sync::Arc;
fn rms_db(before: &[f32], after: &[f32]) -> f32 {
let energy = |xs: &[f32]| xs.iter().map(|x| x * x).sum::<f32>() / xs.len().max(1) as f32;
10.0 * (energy(after).max(1e-20) / energy(before).max(1e-20)).log10()
}
fn tone_session(points: &[(i64, f32)]) -> (Engine, TrackId) {
let mut s = Session::default();
let n = 8192;
let tone: Vec<f32> = (0..n).map(|i| (i as f32 * 0.07).sin() * 0.25).collect();
s.pool.insert(SourceId(910), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![tone] })));
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
let id = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "tone", SourceId(910), 0, 0, n));
s.track_mut(t).unwrap().mixer.volume_db = -3.0;
for &(at, value) in points {
s.track_mut(t).unwrap().lane_mut(&AutoParam::Volume).set_point(at, value);
}
s.edit.selected_tracks = vec![t];
s.edit.selection = Range::new(2048, 6144);
(Engine::new(s), t)
}
fn rendered(s: &Session) -> Vec<f32> {
soundcraft_mix::render_range(s, Range::new(0, 8192), 1024).into_iter().next().unwrap_or_default()
}
fn convert(e: &mut Engine, t: TrackId, start: i64, end: i64) {
e.session_mut().edit.selection = Range::new(start, end);
e.execute("automation.volume_to_clip_gain", &json!({"track": t.0, "start": start, "end": end})).unwrap();
}
#[test]
fn partial_volume_to_clip_gain_keeps_each_region() {
let points = [(0, -6.0), (2048, -6.0), (4096, -6.0), (6144, -6.0), (8192, -6.0)];
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2048), (2048, 6144), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.02, "constant {start}..{end} changed by {delta} dB");
}
let tr = e.session().track(t).unwrap();
assert!((tr.mixer.volume_db + 3.0).abs() < 1e-5);
let lane = tr.lane(&AutoParam::Volume).unwrap();
assert!((lane.value_at(100, 0.0) + 6.0).abs() < 1e-3);
assert!((lane.value_at(3000, 0.0) + 3.0).abs() < 1e-3);
assert!((lane.value_at(7000, 0.0) + 6.0).abs() < 1e-3);
let (mut e, t) = tone_session(&[]);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(diff < 1e-5, "no lane changed a sample by {diff}");
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 0, 8193);
let after = rendered(e.session());
let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(diff < 1e-4, "full selection changed a sample by {diff}");
assert!(e.session().track(t).unwrap().lane(&AutoParam::Volume).is_none_or(|l| l.points.is_empty()));
for points in
[[(0, -12.0), (2048, -9.0), (4096, -6.0), (6144, -3.0), (8192, 0.0)], [(0, -12.0), (2048, -6.0), (4096, -12.0), (6144, -6.0), (8192, -12.0)]]
{
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2048), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.02, "outside {start}..{end} changed by {delta} dB for {points:?}");
}
let delta = rms_db(&before[2048..6144], &after[2048..6144]);
assert!(delta.abs() < 0.15, "inside changed by {delta} dB for {points:?}");
}
}
#[test]
fn lane_points_all_inside_the_selection_keep_the_outside_level() {
let (mut e, t) = tone_session(&[(2048, -9.0), (6143, -9.0)]);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2000), (2048, 6144), (6200, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.05, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn set_point_from_drops_the_points_in_between() {
let (mut e, t) = tone_session(&[(100, -20.0), (150, -20.0), (200, -20.0), (250, -20.0), (300, -20.0)]);
e.execute("automation.set_point", &json!({"track": t.0, "from": 100, "at": 250, "value": 0.0})).unwrap();
// A loop wrap: `from` lies after `at`, so nothing goes.
e.execute("automation.set_point", &json!({"track": t.0, "from": 400, "at": 50, "value": -6.0})).unwrap();
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
let points: Vec<(i64, f32)> = lane.points.iter().map(|p| (p.at, p.value)).collect();
assert_eq!(points, [(50, -6.0), (100, -20.0), (250, 0.0), (300, -20.0)]);
}
#[test]
fn clip_gain_points_inside_the_selection_are_kept() {
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
let clip = e.session().track(t).unwrap().clips()[0].id;
e.session_mut().find_clip_mut(clip).unwrap().gain_env = vec![(3000, 0.0), (4000, -20.0), (5000, 0.0)];
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(2048, 3000), (3500, 4500), (5000, 6144)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_keeps_the_existing_lane() {
let clip_gain_to_volume = |points: &[(i64, f32)], env: Vec<(i64, f32)>| {
let (mut e, t) = tone_session(points);
let clip = e.session().track(t).unwrap().clips()[0].id;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.gain_db = 6.0;
c.gain_env = env;
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
let after = rendered(e.session());
assert!(e.session().find_clip(clip).unwrap().1.gain_db == 0.0);
(before, after)
};
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], Vec::new());
let delta = rms_db(&before, &after);
assert!(delta.abs() < 0.02, "level changed by {delta} dB");
// A moving lane keeps its shape, and its points inside the clip carry the gain.
let (before, after) = clip_gain_to_volume(&[(0, -12.0), (4096, -6.0), (8192, -12.0)], Vec::new());
for (start, end) in [(0, 2048), (2048, 4096), (4096, 6144), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
}
// The clip's own gain envelope rides on the lane value as well.
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], vec![(2000, 0.0), (6000, -10.0)]);
for (start, end) in [(3900, 4100), (5000, 5200)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_holds_the_envelope_ends_out_to_the_clip_edges() {
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
let clip = e.session().track(t).unwrap().clips()[0].id;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.gain_db = 6.0;
c.gain_env = vec![(2000, 0.0), (6000, -10.0)];
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
let after = rendered(e.session());
for (start, end) in [(0, 1000), (1000, 2000), (6000, 7000), (7000, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_leaves_audio_outside_the_clip_alone() {
// Three clips in a row; only the middle one is converted, and the lane extends past it on both sides.
let (mut e, t) = tone_session(&[(0, -6.0), (16384, -6.0)]);
let (middle, last) = (e.session_mut().new_clip_id(), e.session_mut().new_clip_id());
let pl = e.session_mut().track_mut(t).unwrap().playlist_mut().unwrap();
if let Some(c) = pl.clips.first_mut() {
c.length = 2048;
}
pl.clips.push(Clip::audio(middle, "mid", SourceId(910), 2048, 2048, 4096));
pl.clips.push(Clip::audio(last, "end", SourceId(910), 6144, 6144, 2048));
e.session_mut().find_clip_mut(middle).unwrap().gain_db = 6.0;
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 2048, "end": 6144})).unwrap();
let after = rendered(e.session());
assert!(e.session().find_clip(middle).unwrap().1.gain_db == 0.0);
for (start, end) in [(0, 2040), (2056, 6136), (6152, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn insert_params_rejects_unknown_and_non_numeric_keys() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let t = e.session().tracks[0].id.0;
e.execute("mix.insert", &json!({"track": t, "slot": 0, "plugin": "gain"})).unwrap();
fn gain(e: &Engine) -> f32 {
e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().params["gain"]
}
// A typo names no parameter: the call fails and says which key.
let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain_db": -14.0}})).unwrap_err();
assert!(err.to_string().contains("gain_db"), "{err}");
// Mixed keys fail as a whole and change nothing.
let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0, "width": 1.0}})).unwrap_err();
assert!(err.to_string().contains("width"), "{err}");
assert_eq!(gain(&e), 0.0);
// Non-numeric and non-finite values are rejected too.
assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": "loud"}})).is_err());
assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": null}})).is_err());
assert_eq!(gain(&e), 0.0);
// Correct keys still apply, clamped to the parameter range.
let r = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0}})).unwrap();
assert_eq!(r["set"], 1);
assert_eq!(gain(&e), -14.0);
e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": 999.0}})).unwrap();
assert_eq!(gain(&e), 24.0);
// Empty params with a preset name still recall (the rename path).
e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {}, "preset": "Saved"})).unwrap();
assert_eq!(e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().preset, "Saved");
}
#[test]
fn copy_to_send_rejects_an_invalid_send_slot() {
let mut e = crate::demo::demo_engine();
let history = e.undo_history().len();
let lanes = |e: &Engine| e.session().tracks.iter().filter(|t| t.lane(&AutoParam::SendLevel(0)).is_some_and(|l| !l.points.is_empty())).count();
let before = lanes(&e);
for bad_params in [json!({"track": "Kick", "send": 99}), json!({"track": "Kick", "send": "zz"})] {
let r = e.execute("automation.copy_to_send", &bad_params);
assert!(r.is_err(), "{bad_params} accepted: {r:?}");
assert_eq!(e.undo_history().len(), history, "{bad_params} pushed an undo step");
assert_eq!(lanes(&e), before, "{bad_params} edited Send A");
}
// No `send` (the menu item's call) still means Send A.
e.execute("automation.copy_to_send", &json!({"track": "Kick"})).unwrap();
assert_eq!(lanes(&e), before + 1);
// A valid slot still copies.
e.execute("automation.copy_to_send", &json!({"track": "Kick", "send": "a"})).unwrap();
assert_eq!(lanes(&e), before + 1);
}
#[test]
fn retargeting_a_send_keeps_its_settings() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Delay"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
assert_eq!(sl.target, soundcraft_model::Route::Bus(delay));
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
}
fn grouped_demo() -> Engine {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &json!({"tracks": [3], "db": 0.0})).unwrap();
e.execute("mix.volume", &json!({"tracks": [6], "db": 0.0})).unwrap();
e.execute("track.group", &json!({"tracks": [3, 6]})).unwrap();
e
}
fn volumes(e: &Engine) -> Vec<f32> {
[3, 6].iter().map(|id| e.session().track(TrackId(*id)).unwrap().mixer.volume_db).collect()
}
#[test]
fn volume_delta_on_a_whole_group_applies_once() {
let mut e = grouped_demo();
e.execute("mix.volume", &json!({"tracks": [3, 6], "delta_db": -3.0})).unwrap();
assert_eq!(volumes(&e), [-3.0, -3.0]);
let mut e = grouped_demo();
e.execute("mix.volume", &json!({"tracks": [3], "delta_db": -3.0})).unwrap();
assert_eq!(volumes(&e), [-3.0, -3.0]);
}
#[test]
fn volume_absolute_on_a_whole_group_keeps_offsets() {
let mut e = grouped_demo();
e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = -2.0;
e.execute("mix.volume", &json!({"tracks": [3, 6], "db": -10.0})).unwrap();
assert_eq!(volumes(&e), [-10.0, -12.0]);
}
#[test]
fn clear_with_unknown_param_keeps_lanes() {
let (mut e, t) = tone_session(&[(0, -6.0), (100, -3.0)]);
e.session_mut().track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0)).set_point(0, 0.5);
e.session_mut().edit.selection = Range::new(0, 0);
let count = |e: &Engine| e.session().track(t).unwrap().automation.iter().map(|l| l.points.len()).sum::<usize>();
let r = e.execute("automation.clear", &json!({"track": t.0, "param": "volum"}));
assert!(r.is_err(), "unknown param succeeded: {r:?}");
assert_eq!(count(&e), 3, "unknown param changed the lanes");
e.execute("automation.clear", &json!({"track": t.0, "param": "volume"})).unwrap();
assert_eq!(count(&e), 1, "named param cleared the wrong lanes");
e.execute("automation.clear", &json!({"track": t.0})).unwrap();
assert_eq!(count(&e), 0, "absent param must still clear every lane");
}
#[test]
fn send_rejects_unknown_bus() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
let err = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Nowhere"})).unwrap_err();
assert!(err.to_string().contains("no bus named \"Nowhere\""), "{err}");
assert_eq!(e.session().busses.len(), 1);
assert!(e.session().tracks[0].mixer.sends.iter().all(Option::is_none));
e.execute("mix.send", &json!({"track": "Gtr", "bus": "verb"})).unwrap();
assert_eq!(e.session().busses.len(), 1);
}
#[test]
fn insert_and_send_reject_invalid_slot() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
for slot in [json!(12), json!("k"), json!("x1")] {
let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"}));
assert!(r.is_err(), "insert slot {slot} accepted: {r:?}");
}
for slot in [json!(12), json!("k")] {
let r = e.execute("mix.send", &json!({"track": "Gtr", "slot": slot, "bus": "Verb"}));
assert!(r.is_err(), "send slot {slot} accepted: {r:?}");
}
let mixer = &e.session().tracks[0].mixer;
assert!(mixer.inserts.iter().all(Option::is_none));
assert!(mixer.sends.iter().all(Option::is_none));
// Leaving `slot` out still picks the first free slot.
let r = e.execute("mix.insert", &json!({"track": "Gtr", "plugin": "compressor"})).unwrap();
assert_eq!(r["slot"], 0);
let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();
assert_eq!(r["slot"], 0);
}
#[test]
fn indexed_envelope_lookup_matches_the_walk() {
let envs: [Vec<(i64, f32)>; 5] = [
Vec::new(),
vec![(100, -3.0)],
vec![(0, 0.0), (100, -6.0), (100, 3.0), (250, -12.0), (900, 1.0)],
// Out of order, as a hand-edited session file may hold.
vec![(500, -1.0), (100, -6.0), (700, 4.0), (300, 2.0), (700, -9.0), (50, 0.5)],
(0..200).map(|i| ((i * 37) % 1000, (i as f32 * 0.7).sin() * 12.0)).collect(),
];
for env in &envs {
let lookup = EnvLookup::new(env);
for at in -20..1100 {
assert_eq!(lookup.db(at).to_bits(), env_db(env, at).to_bits(), "{env:?} at {at}");
}
}
}
#[test]
fn clip_gain_to_volume_with_a_large_envelope_stays_fast() {
let (mut e, t) = tone_session(&[]);
let clip = e.session().track(t).unwrap().clips()[0].id;
let n = 200_000i64;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.length = n * 5 + 10;
c.gain_env = (0..n).map(|i| (i * 5, if i % 2 == 0 { -6.0 } else { 0.0 })).collect();
let start = std::time::Instant::now();
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": n * 5 + 10})).unwrap();
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
assert!(lane.points.len() as i64 >= n);
}
/// Live input toggles stay outside document history in both enabled and disabled states.
#[test]
fn live_input_states_are_transient_and_survive_document_history() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let track = e.session().tracks[0].id;
e.mark_clean();
let history = e.undo_history();
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
e.execute("track.set_record_to_input_only", &json!({})).unwrap();
let mixer = &e.session().track(track).unwrap().mixer;
assert!(mixer.record_arm && mixer.input_monitor);
assert!(!e.is_dirty(), "live input state is not a document change");
assert_eq!(e.undo_history(), history, "live input state has no Undo entry");
// A later document edit captures a full Session snapshot. Undoing or redoing it must not
// resurrect the live state that happened to be present when that snapshot was taken.
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -6.0})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": false})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": false})).unwrap();
assert!(e.is_dirty());
assert!(e.undo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(!e.is_dirty(), "undo returned to the saved document");
assert!(e.redo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(e.is_dirty());
// Live states that are enabled after another document edit survive both directions too.
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -12.0})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": true})).unwrap();
assert!(e.undo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(mixer.record_arm && mixer.input_monitor);
assert!(e.redo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(mixer.record_arm && mixer.input_monitor);
}
/// Returning to a saved point stays clean with independently enabled live flags on each track.
#[test]
fn live_input_states_keep_saved_history_points_clean() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 2})).unwrap();
let (a, b) = (e.session().tracks[0].id, e.session().tracks[1].id);
e.mark_clean();
e.execute("mix.volume", &json!({"tracks": [a.0], "db": -6.0})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": true})).unwrap();
assert!(e.undo());
assert!(!e.is_dirty(), "live flags cannot dirty the saved point reached by Undo");
for redo in [true, false, true] {
assert!(if redo { e.redo() } else { e.undo() });
let a = &e.session().track(a).unwrap().mixer;
let b = &e.session().track(b).unwrap().mixer;
assert!(a.record_arm && !a.input_monitor);
assert!(!b.record_arm && b.input_monitor);
assert_eq!(e.is_dirty(), redo);
}
e.mark_clean();
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": false})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": false})).unwrap();
assert!(!e.is_dirty());
assert!(e.undo());
assert!(e.is_dirty());
assert!(e.redo());
assert!(!e.is_dirty(), "Redo to a saved armed snapshot stays clean after disarming");
}
/// Restoring a missing track never resurrects its historical arm or monitoring flags.
#[test]
fn live_input_states_of_restored_tracks_start_disabled() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 2})).unwrap();
let (a, b) = (e.session().tracks[0].id, e.session().tracks[1].id);
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": true})).unwrap();
e.execute("track.delete", &json!({"tracks": [a.0]})).unwrap();
assert!(e.undo());
let mixer = &e.session().track(a).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(e.session().track(b).unwrap().mixer.input_monitor);
assert!(e.redo());
assert!(e.undo());
assert!(!e.session().track(a).unwrap().mixer.record_arm);
e.execute("track.new", &json!({})).unwrap();
let c = e.session().tracks.last().unwrap().id;
e.execute("mix.record_arm", &json!({"tracks": [c.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [c.0], "value": true})).unwrap();
assert!(e.undo());
assert!(e.session().track(c).is_none());
assert!(e.redo());
let mixer = &e.session().track(c).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(e.session().track(b).unwrap().mixer.input_monitor);
}