diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 88584e0f..f12901dd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -53,6 +53,8 @@ pub type Result = std::result::Result; /// Requests for the transport/audio engine, drained by the host app each frame. #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub enum TransportRequest { + /// Stop the old session's transport without applying stop-time edits to the new document. + ResetSession, Play, Stop, TogglePlay, @@ -158,6 +160,9 @@ impl Engine { /// Replace the whole document (open / new); clears history. pub fn replace_session(&mut self, s: Session) { + self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() }; + self.transport_requests.clear(); + self.transport_requests.push(TransportRequest::ResetSession); self.doc = Arc::new(s); self.undo.clear(); self.redo.clear(); diff --git a/crates/engine/tests/session_transport.rs b/crates/engine/tests/session_transport.rs new file mode 100644 index 00000000..73048e17 --- /dev/null +++ b/crates/engine/tests/session_transport.rs @@ -0,0 +1,30 @@ +use serde_json::json; +use soundcraft_engine::{Engine, TransportRequest, TransportStatus}; +use soundcraft_model::Session; +use soundcraft_time::Range; + +#[test] +fn replacing_session_resets_status_and_discards_old_transport_requests() { + let mut engine = Engine::default(); + engine.transport = TransportStatus { playing: true, recording: true, position: 48_000 }; + engine.execute("transport.play", &json!({"from": 96_000})).unwrap(); + engine.execute("transport.record", &json!({})).unwrap(); + let mut session = Session::default(); + session.edit.selection = Range::point(12_000); + engine.replace_session(session); + assert_eq!(engine.transport, TransportStatus { position: 12_000, ..TransportStatus::default() }); + assert_eq!(engine.transport_requests, vec![TransportRequest::ResetSession]); + assert!(!engine.is_dirty()); +} + +#[test] +fn failed_new_session_keeps_the_current_transport() { + let mut engine = Engine::default(); + engine.transport = TransportStatus { playing: true, position: 48_000, ..TransportStatus::default() }; + engine.execute("transport.play", &json!({})).unwrap(); + let status = engine.transport; + let requests = engine.transport_requests.clone(); + assert!(engine.execute("session.new", &json!({"sample_rate": 0})).is_err()); + assert_eq!(engine.transport, status); + assert_eq!(engine.transport_requests, requests); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc0685d2..c4345d31 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -642,6 +642,25 @@ impl SoundApp { let reqs: Vec = std::mem::take(&mut self.engine.transport_requests); for r in reqs { match r { + TransportRequest::ResetSession => { + if let Some(p) = &self.player { + p.stop(); + p.set_recording(false); + p.set_speed(1.0); + p.locate(self.engine.transport.position); + } + self.sim = None; + // The old capture belongs to the replaced document, never to the new one. + self.recorder = None; + self.recorder_failed = false; + self.punch = None; + self.gesture = None; + self.touched.clear(); + self.last_write_at = i64::MIN; + self.edit_layout.follow_hold = false; + self.meters.clear(); + self.main_meter = MeterDisplay::default(); + } TransportRequest::Play => { if !self.is_playing() { self.play_from_selection(); @@ -761,13 +780,14 @@ impl SoundApp { self.engine.end_merge(); } } + // Stop the old playback before sending a replacement session to the audio thread. + self.handle_transport(dt); if self.engine.revision != self.last_rev { self.last_rev = self.engine.revision; if let Some(p) = &self.player { p.update_session(self.engine.session_arc()); } } - self.handle_transport(dt); self.write_automation(ctx); self.autosave(now); if self.recorder.is_none() @@ -889,7 +909,64 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) { #[cfg(test)] mod tests { - use super::automation_step_due; + use super::{Services, SoundApp, automation_step_due}; + use serde_json::json; + + #[test] + fn new_session_stops_playback_and_the_old_clock() { + for (command, params) in [ + ("session.new", json!({"name": "New project", "sample_rate": 96_000})), + ("session.new", json!({"template": "demo"})), + ("session.close", json!({})), + ] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("transport.play", json!({"from": 48_000})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert!(app.position() > 48_000); + app.run(command, params).unwrap(); + let insertion = app.engine.session().edit.selection.start; + app.handle_transport(0.25); + assert!(!app.is_playing(), "{command}"); + assert_eq!(app.position(), insertion, "{command}"); + assert_eq!(app.engine.transport.position, insertion, "{command}"); + app.handle_transport(0.25); + assert_eq!(app.engine.transport.position, insertion, "the old clock must stay stopped"); + app.run("transport.play", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert_eq!(app.position(), insertion + i64::from(app.engine.session().sample_rate.hz()) / 4); + } + } + + #[test] + fn new_session_cancels_play_queued_before_the_next_frame() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("transport.play", json!({})).unwrap(); + app.run("session.new", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(!app.is_playing()); + assert_eq!(app.engine.transport.position, 0); + } + + #[test] + fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("transport.play", json!({"from": 48_000})).unwrap(); + app.handle_transport(0.25); + let mut session = soundcraft_model::Session::default(); + session.edit.selection = soundcraft_time::Range::new(12_000, 24_000); + session.edit.insertion_follows_playback = true; + session.edit.scrolling = "after_playback".into(); + let selection = session.edit.selection; + app.engine.replace_session(session); + app.handle_transport(0.25); + assert!(!app.is_playing()); + assert_eq!(app.engine.session().edit.selection, selection); + assert_eq!(app.position(), selection.start); + assert_eq!(app.engine.session().edit.zoom.scroll, 0); + assert!(!app.engine.is_dirty()); + } #[test] fn automation_first_pass_after_start_is_due_without_overflow() {