collective

Browse / github.com/storytold/soundcraft / patch

Clip > Unloop... opens the Unloop dialog: Remove or Flatten

Clip > Unloop... opens the Unloop dialog: Remove or Flatten On `main`, Clip > Unloop... removes the loop iterations of the selected clips at once, with no dialog. `clip.unloop` already has a `flatten` mode, but the menu has no way to choose it. The reference guide's "Unlooping Clips" section (Pro Tools Reference Guide 2020.12, Chapter 42, page 933) describes an Unloop dialog: "Click Remove to unloop and remove all loop iterations except the first full loop iteration (source clip)" and "Click Flatten to unloop and create individual clips from each loop iteration": https://web.archive.org/web/20210805202452/https://resources.avid.com/SupportFiles/PT/Pro_

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #1036, open)
Base
0197e27fc41ead385783fde4b21090f995e5bb5b
Size
+264 −2 in 8 files
Review
pending
Shared
2026-10-28
Id
500746c5fea39f985ddacdb1

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd/clip_more.rs crates/engine/src/cmd/mod.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/menus.rs crates/ui-egui/tests/it/focused_dialog_buttons.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/unloop_dialog.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -2,7 +2,7 @@  **Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows) -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Unloop dialog, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1  SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is@@ -123,6 +123,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log  - **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.+- **2026-10-24:** Clip › Unloop… opens the Unloop dialog. Remove deletes the loop iterations and keeps the source clip, and Flatten keeps each loop iteration as its own clip. No readiness percentage change. - **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change. - **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change. - **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change.@@ -176,6 +177,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| | 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |+| 2026-10-24 | trivial | Progress log: Unloop dialog; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged |diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs--- a/crates/engine/src/cmd/clip_more.rs+++ b/crates/engine/src/cmd/clip_more.rs@@ -150,6 +150,11 @@ fn loop_chain(s: &Session, t: TrackId, id: ClipId) -> Vec<ClipId> {     chain } +/// Whether a clip belongs to a loop that `clip.unloop` changes: two or more back-to-back copies.+pub fn is_looped(s: &Session, id: ClipId) -> bool {+    s.find_clip(id).is_some_and(|(t, _)| loop_chain(s, t, id).len() >= 2)+}+ fn unloop(e: &mut Engine, p: &Value) -> Result<Value> {     let id = "clip.unloop";     let remove = match str_param(p, "mode") {diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs--- a/crates/engine/src/cmd/mod.rs+++ b/crates/engine/src/cmd/mod.rs@@ -60,6 +60,7 @@ mod panic_probe {     } } +pub use clip_more::is_looped; pub use file::bounce_job;  pub(crate) use edit::remember_selection;diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -19,6 +19,8 @@ pub enum Dialog {     RenameClip { id: ClipId, name: String },     RenameMarker { id: soundcraft_model::MarkerId, name: String },     ClearClips { clips: Vec<ClipId>, sources: Vec<SourceId>, message: String },+    // Clip › Unloop…: the clips `clip.unloop` would take when it opened.+    Unloop { clips: Vec<ClipId> },     PathPrompt { cmd: String, title: String, path: String, key: String },     Number { cmd: String, title: String, key: String, value: f64, suffix: String },     TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler@@ -104,6 +106,7 @@ impl Dialogs {             Dialog::RenameClip { .. } => "rename_clip",             Dialog::RenameMarker { .. } => "rename_marker",             Dialog::ClearClips { .. } => "clear_clips",+            Dialog::Unloop { .. } => "unloop",             Dialog::PathPrompt { .. } => "path",             Dialog::Number { .. } => "number",             Dialog::TempoChange { .. } => "tempo_change",@@ -179,6 +182,13 @@ impl Dialogs {                 let ed = &e.session().edit;                 return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone());             }+            "clip.unloop" => {+                let clips = soundcraft_engine::cmd::clip_ids_param(e, &json!({}));+                if !clips.iter().any(|&c| soundcraft_engine::cmd::is_looped(e.session(), c)) {+                    return false;+                }+                Dialog::Unloop { clips }+            }             "track.new" => {                 Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }             }@@ -423,6 +433,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {         Dialog::RenameClip { .. } => "Rename Clip",         Dialog::RenameMarker { .. } => "Rename Marker",         Dialog::ClearClips { .. } => "Clear",+        Dialog::Unloop { .. } => "Unloop",         Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(),         Dialog::TempoChange { .. } => "Tempo Change (BPM)",         Dialog::Spot(_) => "Spot",@@ -438,7 +449,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {         Dialog::SaveChanges { .. } => "Unsaved Changes",     }     .to_string();-    let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter));+    // egui treats Enter on a focused button, checkbox, radio button or combo box as a click, so Enter presses the default+    // button only when no widget has the keyboard focus or a text field has it.+    let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter)) && (ctx.memory(|m| m.focused().is_none()) || ctx.text_edit_focused());     let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape));     egui::Window::new(egui::RichText::new(&title).font(bold(13.0)))         .collapsible(false)@@ -580,6 +593,19 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {                         action = Some(("clip.clear".into(), json!({"clips": clips, "sources": sources, "with_clips": true})));                     }                 }+                Dialog::Unloop { clips } => {+                    ui.set_max_width(340.0);+                    ui.label("Remove deletes the loop iterations and keeps the source clip. Flatten keeps each loop iteration as its own clip.");+                    ui.separator();+                    ui.horizontal(|ui| {+                        if ui.button("Flatten").clicked() {+                            action = Some(unloop_action(clips, false));+                        }+                        if buttons(ui, "Remove", enter) {+                            action = Some(unloop_action(clips, true));+                        }+                    });+                }                 Dialog::PathPrompt { cmd, path, key, .. } => {                     crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "Path");                     if let Some(pick) = &app.services.pick_open@@ -1067,6 +1093,11 @@ fn name_field(ui: &mut egui::Ui, name: &mut String) {     } } +/// The command the Unloop dialog runs: Remove (`remove`) or Flatten.+fn unloop_action(clips: &[ClipId], remove: bool) -> (String, Value) {+    ("clip.unloop".into(), json!({"clips": clips, "mode": if remove { "remove" } else { "flatten" }}))+}+ /// The command the tempo-change dialog runs on OK. fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) {     ("event.tempo".into(), json!({"bpm": bpm, "at": at}))@@ -1202,6 +1233,37 @@ mod tests {         assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. })));     } +    #[test]+    fn clip_unloop_opens_unloop_and_sends_the_chosen_mode() {+        let mut e = soundcraft_engine::demo::demo_engine();+        let mut d = Dialogs::default();+        e.execute("edit.select_none", &json!({})).unwrap();+        assert!(!d.open_for_command(&e, "clip.unloop"));+        let track = e.session().track_by_name("Snare").unwrap().id;+        let clip = e.session().track(track).unwrap().clips()[0].id;+        e.execute("edit.select", &json!({"clips": [clip.0]})).unwrap();+        assert!(!d.open_for_command(&e, "clip.unloop"), "a clip that is not looped opens no dialog");+        assert!(d.open.is_none());+        e.execute("clip.loop", &json!({"clip": clip.0, "count": 3})).unwrap();+        e.execute("edit.select", &json!({"clips": [clip.0]})).unwrap();+        assert!(d.open_for_command(&e, "clip.unloop"));+        assert_eq!(d.open_name(), Some("unloop"));+        let Some(Dialog::Unloop { clips }) = d.open.take() else { panic!("Unloop… did not open the Unloop dialog") };+        assert_eq!(clips, vec![clip]);+        let count = |e: &Engine| e.session().track(track).map_or(0, |t| t.clips().len());+        let before = count(&e);+        let (cmd, params) = unloop_action(&clips, false);+        assert_eq!(params["mode"], "flatten");+        e.execute(&cmd, &params).unwrap();+        assert_eq!(count(&e), before, "Flatten keeps every iteration");+        assert!(e.session().track(track).unwrap().clips().iter().all(|c| c.loop_id.is_none()));+        e.execute("edit.undo", &json!({})).unwrap();+        let (cmd, params) = unloop_action(&clips, true);+        assert_eq!(params["mode"], "remove");+        e.execute(&cmd, &params).unwrap();+        assert_eq!(count(&e), before - 2, "Remove keeps only the source clip");+    }+     #[test]     fn clip_rename_opens_the_rename_dialog_for_the_selected_clip() {         let mut e = soundcraft_engine::demo::demo_engine();diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -150,6 +150,7 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[ /// Commands that open a dialog when invoked from a menu (programmatic calls never do). const DIALOG_COMMANDS: &[&str] = &[     "clip.clear",+    "clip.unloop",     "track.new",     "file.bounce_mix",     "session.new",diff --git a/crates/ui-egui/tests/it/focused_dialog_buttons.rs b/crates/ui-egui/tests/it/focused_dialog_buttons.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/focused_dialog_buttons.rs@@ -0,0 +1,127 @@+//! Enter on a focused dialog button, checkbox, radio button or combo box acts on that widget and does not press the+//! dialog's default button.++use egui::accesskit::Role;+use egui::{Event, Key, Modifiers, vec2};+use egui_kittest::{Harness, kittest::Queryable};+use serde_json::json;+use soundcraft_ui_egui::dialogs::Dialog;+use soundcraft_ui_egui::{Services, SoundApp, menus};++fn harness(app: SoundApp) -> Harness<'static, SoundApp> {+    let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(+        |ui, app: &mut SoundApp| {+            let ctx = ui.ctx().clone();+            app.logic(&ctx);+            app.ui(ui);+        },+        app,+    );+    h.run_steps(3);+    h+}++/// Gives the button the keyboard focus, then presses Enter.+fn enter_on(h: &mut Harness<'_, SoundApp>, button: &str) {+    enter_on_widget(h, Role::Button, button);+}++/// Gives the widget the keyboard focus, then presses Enter.+fn enter_on_widget(h: &mut Harness<'_, SoundApp>, role: Role, label: &str) {+    h.get_by_role_and_label(role, label).focus();+    h.run_steps(2);+    h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });+    h.run_steps(1);+    h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });+    h.run_steps(2);+}++#[test]+fn enter_on_a_focused_cancel_closes_the_dialog_without_its_action() {+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+    let groups = app.engine.session().groups.len();+    menus::invoke_menu(&mut app, "track.group", "Track > Group...");+    let mut h = harness(app);+    assert_eq!(h.state().dialogs.open_name(), Some("group"));+    enter_on(&mut h, "Cancel");+    assert_eq!(h.state().dialogs.open_name(), None);+    assert_eq!(h.state().engine.session().groups.len(), groups);+}++#[test]+fn enter_on_a_focused_dont_save_closes_without_saving() {+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+    app.run("mix.mute", json!({"track": "Kick"})).unwrap();+    menus::invoke_menu(&mut app, "session.close", "File > Close Session");+    let mut h = harness(app);+    assert_eq!(h.state().dialogs.open_name(), Some("save_changes"));+    enter_on(&mut h, "Don't Save");+    assert_eq!(h.state().dialogs.open_name(), None, "no Save As prompt");+    assert!(h.state().engine.session().tracks.is_empty());+}++#[test]+fn enter_on_a_focused_browse_browses_without_pressing_ok() {+    let picked = "picked/Song.scraft";+    let services = Services { pick_open: Some(Box::new(move |_, _| Some(picked.to_string()))), ..Services::default() };+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, services);+    menus::invoke_menu(&mut app, "session.open", "File > Open Session...");+    let mut h = harness(app);+    enter_on(&mut h, "Browse…");+    assert!(matches!(&h.state().dialogs.open, Some(Dialog::PathPrompt { path, .. }) if path == picked));+    assert!(h.state().engine.session().track_by_name("Kick").is_some(), "the demo session is still open");+}++#[test]+fn enter_on_a_focused_browse_in_bounce_mix_does_not_bounce() {+    let picked = "picked/Mix.flac";+    let services = Services { pick_save: Some(Box::new(move |_, _| Some(picked.to_string()))), ..Services::default() };+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, services);+    menus::invoke_menu(&mut app, "file.bounce_mix", "File > Bounce Mix...");+    let mut h = harness(app);+    enter_on(&mut h, "Browse…");+    assert!(matches!(&h.state().dialogs.open, Some(Dialog::Bounce { path, format, .. }) if path == picked && format == "flac"));+}++#[test]+fn enter_on_a_focused_checkbox_toggles_it_and_keeps_the_dialog_open() {+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+    menus::invoke_menu(&mut app, "file.score_setup", "File > Score Setup...");+    let mut h = harness(app);+    let Some(Dialog::ScoreSetup { show_track_names: before, .. }) = h.state().dialogs.open.clone() else { panic!("Score Setup did not open") };+    enter_on_widget(&mut h, Role::CheckBox, "Show track names");+    assert!(matches!(&h.state().dialogs.open, Some(Dialog::ScoreSetup { show_track_names, .. }) if *show_track_names != before));+}++#[test]+fn enter_on_a_focused_radio_button_picks_it_and_keeps_the_dialog_open() {+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+    menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");+    let mut h = harness(app);+    assert_eq!(h.state().dialogs.open_name(), Some("fades"));+    enter_on_widget(&mut h, Role::RadioButton, "Linear");+    assert!(matches!(&h.state().dialogs.open, Some(Dialog::Fades { shape }) if shape == "linear"));+}++#[test]+fn enter_with_nothing_focused_presses_the_default_button() {+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+    menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");+    let mut h = harness(app);+    assert_eq!(h.state().dialogs.open_name(), Some("fades"));+    h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });+    h.run_steps(2);+    assert_eq!(h.state().dialogs.open_name(), None);+}++#[test]+fn enter_on_a_focused_combo_box_opens_it_and_keeps_the_dialog_open() {+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+    menus::invoke_menu(&mut app, "setup.session", "Setup > Session");+    let mut h = harness(app);+    assert_eq!(h.state().dialogs.open_name(), Some("session"));+    assert!(h.query_by_label("47.952").is_none());+    enter_on_widget(&mut h, Role::ComboBox, "Timecode Rate");+    assert_eq!(h.state().dialogs.open_name(), Some("session"));+    assert!(h.query_by_label("47.952").is_some(), "the Timecode Rate list is open");+}diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -17,6 +17,7 @@ mod docking_workflows; mod edit_menu; mod edit_modes; mod edit_window_headers;+mod focused_dialog_buttons; mod group_dialog; mod hide_floating; mod idle;@@ -58,6 +59,7 @@ mod track_name_menu; mod track_preset_state; mod trackpad; mod tracks_scroll_bar;+mod unloop_dialog; mod waveform; mod window_shortcuts; mod zoom_toggle;diff --git a/crates/ui-egui/tests/it/unloop_dialog.rs b/crates/ui-egui/tests/it/unloop_dialog.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/unloop_dialog.rs@@ -0,0 +1,62 @@+use egui::accesskit::Role;+use egui::{Event, Key, Modifiers, vec2};+use egui_kittest::{Harness, kittest::Queryable};+use serde_json::json;+use soundcraft_model::TrackId;+use soundcraft_ui_egui::{Services, SoundApp, menus};++/// Loops the Snare clip three times, selects it and opens Clip > Unloop... from the menu.+fn unloop_dialog() -> (Harness<'static, SoundApp>, TrackId, usize) {+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+    let track = app.engine.session().track_by_name("Snare").unwrap().id;+    let clip = app.engine.session().track(track).unwrap().clips()[0].id;+    app.run("clip.loop", json!({"clip": clip.0, "count": 3})).unwrap();+    app.run("edit.select", json!({"clips": [clip.0]})).unwrap();+    let looped = app.engine.session().track(track).unwrap().clips().len();+    menus::invoke_menu(&mut app, "clip.unloop", "Clip > Unloop...");+    assert_eq!(app.dialogs.open_name(), Some("unloop"));+    let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(+        |ui, app: &mut SoundApp| {+            let ctx = ui.ctx().clone();+            app.logic(&ctx);+            app.ui(ui);+        },+        app,+    );+    h.run_steps(3);+    (h, track, looped)+}++fn clips(h: &Harness<'_, SoundApp>, track: TrackId) -> Vec<Option<u64>> {+    h.state().engine.session().track(track).map(|t| t.clips().iter().map(|c| c.loop_id).collect()).unwrap_or_default()+}++#[test]+fn unloop_dialogs_flatten_keeps_each_iteration_as_its_own_clip() {+    let (mut h, track, looped) = unloop_dialog();+    assert_eq!(clips(&h, track).len(), looped, "opening the dialog does not unloop yet");+    h.get_by_role_and_label(Role::Button, "Flatten").click();+    h.run_steps(2);+    assert_eq!(h.state().dialogs.open_name(), None);+    assert_eq!(clips(&h, track), vec![None; looped]);+}++#[test]+fn enter_on_a_focused_flatten_flattens() {+    let (mut h, track, looped) = unloop_dialog();+    h.get_by_role_and_label(Role::Button, "Flatten").focus();+    h.run_steps(2);+    h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });+    h.run_steps(2);+    assert_eq!(h.state().dialogs.open_name(), None);+    assert_eq!(clips(&h, track), vec![None; looped]);+}++#[test]+fn unloop_dialogs_remove_keeps_only_the_source_clip() {+    let (mut h, track, looped) = unloop_dialog();+    h.get_by_role_and_label(Role::Button, "Remove").click();+    h.run_steps(2);+    assert_eq!(h.state().dialogs.open_name(), None);+    assert_eq!(clips(&h, track).len(), looped - 2);+}