From b9e4873ffa6acc8e8b6fbdb3dd17ce0d6bb98d6e Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:51:18 +0100 Subject: [PATCH] Tab to Transient: every selected track, Alt+Tab back, Shift+Tab extends Tab found the next transient only on the first selected track, analysed everything from the cursor to the end of the track on every press, and Alt+Tab, Shift+Tab and Alt+Shift+Tab ignored Tab to Transient (Shift+Tab did nothing at all). The toolbar button used the Selector tool's icon. - A new command, edit.tab {back?, extend?}, does what the Tab keys do, as in Pro Tools: Tab and Alt+Tab (Option+Tab on a Mac, Ctrl+Tab on Windows and Linux) move the insertion point to the next or previous clip start, end or sync point on the selected tracks; Shift+Tab and Alt+Shift+Tab (Ctrl+Shift+Tab) extend the selection. Alt+Shift+Tab pulls the selection end back while a stop lies inside the selection, then grows its start. - With Tab to Transient on, the stops also include the transients on every selected audio track and the note-ons and note-offs on MIDI and Instrument tracks. A transient within 10 ms of a clip boundary or note is that stop, not a second one. - Transients are detected in fixed 10-second blocks of each clip, so Tab and Alt+Tab always find the same ones and a long clip costs one block per press. Analysed blocks are cached while their clips, clip effects and audio are unchanged: 0.5 ms a press across the demo's five audio tracks, 43 ms without the cache. - The Tab to Transient button gets its own icon, an arrow into a transient. Cmd+Alt+Tab still toggles Tab to Transient. On Windows and Linux the desktop takes Ctrl+Alt+Tab, and Pro Tools' Ctrl+Alt+T is Thin Automation here too, so the button and the Options menu are the way there. --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/tab.rs | 296 +++++++++++++++++++++++++ crates/ui-egui/src/icons.rs | 8 + crates/ui-egui/src/shortcuts.rs | 31 +-- crates/ui-egui/src/toolbar.rs | 2 +- crates/ui-egui/tests/pro_tools_keys.rs | 27 +++ 6 files changed, 338 insertions(+), 28 deletions(-) create mode 100644 crates/engine/src/cmd/tab.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d0a453c..95752c5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -28,6 +28,7 @@ mod query; mod sampler; mod setup_more; mod surround; +mod tab; mod track; mod track_more; mod transport; @@ -110,6 +111,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(mix::specs()); v.extend(options::specs()); v.extend(transport::specs()); + v.extend(tab::specs()); v.extend(query::specs()); v.extend(health::specs()); v.extend(audiosuite::specs()); diff --git a/crates/engine/src/cmd/tab.rs b/crates/engine/src/cmd/tab.rs new file mode 100644 index 0000000..af4c783 --- /dev/null +++ b/crates/engine/src/cmd/tab.rs @@ -0,0 +1,296 @@ +//! Tab and Tab to Transient: the insertion point or selection end to the next or previous clip boundary, transient or note. + +use super::*; +use crate::{TransportRequest, cmd}; +use serde_json::json; +use soundcraft_model::{Clip, ClipContent, Session, SourceAudio}; +use soundcraft_time::SampleRate; +use std::sync::{Arc, Mutex, PoisonError, Weak}; + +/// Transients are found in fixed blocks of each clip, so a long clip costs a block per press and +/// Tab and Alt+Tab always see the same transients. +const BLOCK_SECS: f64 = 10.0; +const SCAN_LIMIT: i64 = 64; +const CACHE_LIMIT: usize = 64; + +/// Blocks already analysed, reused while their clips, clip effects and audio stay the same. +static ANALYSED: Mutex> = Mutex::new(Vec::new()); + +struct Analysed { + track: TrackId, + around: Range, + rate: SampleRate, + clips: Vec, + audio: Vec>, + fx: Vec<(String, f64)>, + hits: Vec, +} + +pub fn specs() -> Vec { + vec![cmd!(noundo "edit.tab", "Tab", [], None, "{back?: bool, extend?: bool (extend the selection)}", always, tab)] +} + +fn tab(e: &mut Engine, p: &Value) -> Result { + let (back, extend) = (bool_or(p, "back", false), bool_or(p, "extend", false)); + let s = e.session(); + let sel = s.edit.selection; + let to = |from| stop(s, from, back); + let next = match (extend, back) { + (false, _) => to(sel.start).map(Range::point), + (true, false) => to(sel.end).map(|x| Range::new(sel.start, x)), + // Alt+Shift+Tab pulls the end back while a stop lies inside the selection, then grows the start. + (true, true) => match to(sel.end) { + Some(x) if x > sel.start => Some(Range::new(sel.start, x)), + _ => to(sel.start).map(|x| Range::new(x, sel.end)), + }, + }; + let Some(r) = next else { return Ok(json!({"selection": sel})) }; + let s = e.session_mut(); + s.edit.selection = r; + s.edit.selected_clips.clear(); + if s.edit.link_timeline_edit { + s.edit.timeline_selection = r; + } + if !extend { + s.edit.playhead = r.start; + e.transport_requests.push(TransportRequest::Locate(r.start)); + } + Ok(json!({"selection": r})) +} + +/// The nearest stop after `from` (before it when `back`) on the selected tracks. +fn stop(s: &Session, from: Samples, back: bool) -> Option { + let nearer = |x: Samples, best: Option| { + if back { x < from && best.is_none_or(|b| x > b) } else { x > from && best.is_none_or(|b| x < b) } + }; + let transients = s.edit.tab_to_transient; + let tracks: Vec<_> = s.tracks.iter().filter(|t| s.edit.selected_tracks.contains(&t.id)).collect(); + let mut stops = Vec::new(); + for c in tracks.iter().flat_map(|t| t.clips()) { + let sync = (c.sync_point > 0 && c.sync_point < c.length).then_some(c.start.saturating_add(c.sync_point)); + let notes: Vec = match &c.content { + ClipContent::Midi { sequence } if transients => { + let base = s.tempo.samples_to_ticks(c.start, s.sample_rate); + let ticks = sequence.notes.iter().flat_map(|n| { + let on = base.saturating_add(n.start); + [on, on.saturating_add(n.length)] + }); + ticks.map(|tick| s.tempo.tick_to_samples(tick, s.sample_rate)).filter(|x| c.range().contains(*x)).collect() + } + _ => Vec::new(), + }; + stops.extend([c.start, c.end()].into_iter().chain(sync).chain(notes)); + } + let mut best = None; + for &x in &stops { + if nearer(x, best) { + best = Some(x); + } + } + if transients { + // A hit right at a clip start or note is the same stop, not a second one a few samples later. + let gap = s.sample_rate.samples(0.01).unsigned_abs(); + let apart = |x: Samples| stops.iter().all(|b| b.abs_diff(x) > gap); + for t in &tracks { + for c in t.clips().iter().filter(|c| matches!(c.content, ClipContent::Audio { .. })) { + if let Some(x) = transient(s, t.id, c.range(), from, back, best, apart) { + best = Some(x); + } + } + } + } + best +} + +/// The transient in `clip` nearest to `from` on its side, if it is nearer than `best`. +fn transient( + s: &Session, + t: TrackId, + clip: Range, + from: Samples, + back: bool, + best: Option, + apart: impl Fn(Samples) -> bool, +) -> Option { + let (lo, hi) = if back { + (best.map_or(clip.start, |b| b.saturating_add(1)).max(clip.start), from.min(clip.end)) + } else { + (from.saturating_add(1).max(clip.start), best.unwrap_or(clip.end).min(clip.end)) + }; + if lo >= hi { + return None; + } + let block = s.sample_rate.samples(BLOCK_SECS).max(1); + let margin = s.sample_rate.samples(0.25); + let first = lo.saturating_sub(clip.start) / block; + let last = hi.saturating_sub(1).saturating_sub(clip.start) / block; + for i in 0..=last.saturating_sub(first).min(SCAN_LIMIT) { + let k = if back { last - i } else { first + i }; + let start = clip.start.saturating_add(k.saturating_mul(block)); + let b = Range::new(start, start.saturating_add(block).min(clip.end)); + let around = Range::new(b.start.saturating_sub(margin), b.end.saturating_add(margin)); + let hits = analysed(s, t, around).into_iter().filter(|x| b.contains(*x) && (lo..hi).contains(x) && apart(*x)); + if let Some(x) = if back { hits.max() } else { hits.min() } { + return Some(x); + } + } + None +} + +/// The transients of track `t` in `around`, from the cache when nothing there has changed. +fn analysed(s: &Session, t: TrackId, around: Range) -> Vec { + let clips: Vec = s.track(t).map_or_else(Vec::new, |tr| tr.clips().iter().filter(|c| c.range().overlaps(&around)).cloned().collect()); + let audio: Vec> = clips + .iter() + .filter_map(|c| if let ClipContent::Audio { source, .. } = c.content { s.pool.get(source).map(Arc::downgrade) } else { None }) + .collect(); + let fx: Vec<(String, f64)> = clips + .iter() + .flat_map(|c| { + let pre = format!("clip_fx.{}.", c.id.0); + s.edit.values.range(pre.clone()..).take_while(move |(k, _)| k.starts_with(&pre)).map(|(k, v)| (k.clone(), *v)) + }) + .collect(); + let same = |a: &Analysed| { + a.track == t + && a.around == around + && a.rate == s.sample_rate + && a.clips == clips + && a.fx == fx + && a.audio.len() == audio.len() + && a.audio.iter().zip(&audio).all(|(x, y)| x.ptr_eq(y)) + }; + if let Some(a) = ANALYSED.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|a| same(a)) { + return a.hits.clone(); + } + let hits = crate::io::transients_in(s, t, around, 0.5); + let mut kept = ANALYSED.lock().unwrap_or_else(PoisonError::into_inner); + if kept.len() >= CACHE_LIMIT { + kept.remove(0); + } + kept.push(Analysed { track: t, around, rate: s.sample_rate, clips, audio, fx, hits: hits.clone() }); + hits +} + +#[cfg(test)] +mod tests { + use crate::Engine; + use serde_json::json; + use soundcraft_audio_io::{AudioBuffer, FileFormat}; + use soundcraft_midi::{Note, Sequence}; + use soundcraft_model::{ChannelFormat, Clip, Session, TrackId, TrackKind}; + use soundcraft_time::{SampleRate, TICKS_PER_QUARTER}; + + const SR: f64 = 48_000.0; + + fn at(secs: f64) -> i64 { + (secs * SR) as i64 + } + + fn hits(times: &[f64], secs: f64) -> Vec { + let mut v = vec![0.0f32; at(secs) as usize]; + let mut seed = 7u32; + for &t in times { + for i in 0..at(0.2) as usize { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let noise = (seed >> 8) as f32 / (1u32 << 24) as f32 - 0.5; + if let Some(x) = v.get_mut(at(t) as usize + i) { + *x = if i == 0 { 0.9 } else { noise * (-(i as f32) / 1500.0).exp() }; + } + } + } + v + } + + fn audio_track(s: &mut Session, name: &str, clips: &[(f64, f64)], audio: Vec) -> TrackId { + let src = crate::io::add_source(s, name, AudioBuffer { sample_rate: 48_000, channels: vec![audio] }, None, FileFormat::Wav); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(name)); + for &(start, len) in clips { + let id = s.new_clip_id(); + crate::edit::place_clip(s, t, Clip::audio(id, name, src, 0, at(start), at(len))); + } + t + } + + fn tab(e: &mut Engine, back: bool, extend: bool) -> (i64, i64) { + e.execute("edit.tab", &json!({"back": back, "extend": extend})).unwrap(); + let r = e.session().edit.selection; + (r.start, r.end) + } + + fn walk(e: &mut Engine, back: bool) -> Vec { + let mut out = Vec::new(); + loop { + let (x, _) = tab(e, back, false); + if out.last() == Some(&x) { + return out; + } + out.push(x); + } + } + + #[test] + fn tab_steps_through_clip_boundaries_and_sync_points_and_extends_the_selection() { + let mut s = Session::new("t", SampleRate::HZ_48000); + let t = audio_track(&mut s, "A", &[(1.0, 2.0), (5.0, 3.0)], vec![0.0; at(10.0) as usize]); + if let Some(c) = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.get_mut(1)) { + c.sync_point = at(1.0); + } + let mut e = Engine::new(s); + e.execute("edit.select", &json!({"tracks": [t.0], "start": 0})).unwrap(); + assert_eq!(walk(&mut e, false), [1.0, 3.0, 5.0, 6.0, 8.0].map(at)); + assert_eq!(walk(&mut e, true), [6.0, 5.0, 3.0, 1.0].map(at)); + assert_eq!(e.transport_requests.last(), Some(&crate::TransportRequest::Locate(at(1.0)))); + + assert_eq!(tab(&mut e, false, true), (at(1.0), at(3.0))); + assert_eq!(tab(&mut e, false, true), (at(1.0), at(5.0))); + assert_eq!(tab(&mut e, true, true), (at(1.0), at(3.0))); + e.execute("edit.select", &json!({"start": at(5.5), "end": at(7.0)})).unwrap(); + assert_eq!(tab(&mut e, true, true), (at(5.5), at(6.0))); + assert_eq!(tab(&mut e, true, true), (at(5.0), at(6.0))); + } + + #[test] + fn tab_to_transient_finds_the_same_hits_both_ways_across_analysis_blocks() { + let planted = [0.5, 1.7, 9.9, 10.1, 15.0, 19.95, 23.0]; + let mut s = Session::new("t", SampleRate::HZ_48000); + let t = audio_track(&mut s, "Drums", &[(2.0, 25.0)], hits(&[&[0.003][..], &planted].concat(), 25.0)); + let mut e = Engine::new(s); + e.execute("options.tab_to_transient", &json!({"value": true})).unwrap(); + e.execute("edit.select", &json!({"tracks": [t.0], "start": 0})).unwrap(); + let forward = walk(&mut e, false); + let mut backward = walk(&mut e, true); + backward.reverse(); + backward.push(at(27.0)); + assert_eq!(forward, [&[at(2.0)][..], &backward[1..]].concat()); + assert_eq!(forward.len(), planted.len() + 2, "{forward:?}"); + for (found, want) in forward[1..=planted.len()].iter().zip(planted) { + assert!((found - at(2.0 + want)).abs() < 256, "{found} vs {want} s"); + } + } + + #[test] + fn tab_to_transient_stops_at_the_first_hit_on_any_selected_track_and_at_midi_notes() { + let mut s = Session::new("t", SampleRate::HZ_48000); + let keys = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let mut seq = Sequence::default(); + seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 4 * TICKS_PER_QUARTER, length: TICKS_PER_QUARTER }); + let id = s.new_clip_id(); + crate::edit::place_clip(&mut s, keys, Clip::midi(id, "Keys", 0, at(4.0), seq)); + let a = audio_track(&mut s, "A", &[(0.0, 4.0)], hits(&[1.0, 3.0], 4.0)); + let b = audio_track(&mut s, "B", &[(0.0, 4.0)], hits(&[2.2], 4.0)); + let note = |tick| s.tempo.tick_to_samples(tick, s.sample_rate); + let (on, off) = (note(4 * TICKS_PER_QUARTER), note(5 * TICKS_PER_QUARTER)); + let mut e = Engine::new(s); + e.execute("edit.select", &json!({"tracks": [keys.0, a.0, b.0], "start": 0})).unwrap(); + assert_eq!(walk(&mut e, false), [at(4.0)]); + e.execute("options.tab_to_transient", &json!({"value": true})).unwrap(); + e.execute("edit.select", &json!({"start": 0})).unwrap(); + let stops = walk(&mut e, false); + assert_eq!(stops.len(), 6, "{stops:?}"); + for (i, want) in [(0, 1.0), (2, 2.2), (4, 3.0)] { + assert!((stops[i] - at(want)).abs() < 256, "{stops:?}"); + } + assert_eq!([stops[1], stops[3], stops[5]], [on, off, at(4.0)]); + } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7de4119..9072355 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -153,6 +153,14 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) { .collect(); painter.add(Shape::line(pts, s)); } + "tab_transient" => { + painter.line_segment([p(r, 0.08, 0.5), p(r, 0.3, 0.5)], s); + painter.add(Shape::convex_polygon(vec![p(r, 0.42, 0.5), p(r, 0.28, 0.36), p(r, 0.28, 0.64)], c, Stroke::NONE)); + painter.line_segment([p(r, 0.5, 0.16), p(r, 0.5, 0.84)], s); + for (x, h) in [(0.64, 0.32), (0.78, 0.2), (0.92, 0.1)] { + painter.line_segment([p(r, x, 0.5 - h), p(r, x, 0.5 + h)], s); + } + } _ => { painter.rect_stroke(r.shrink(r.width() * 0.25), 1.0, s, egui::StrokeKind::Middle); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 7a32a16..9691d61 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -157,33 +157,10 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run("edit.nudge", json!({"direction": dir})); true } - // Alt+Tab goes back; off macOS the desktop takes Alt+Tab, so Ctrl+Tab does. Cmd+Alt+Tab is Tab to Transient. - Key::Tab if plain || (m.alt && !m.command) || (!mac && m.ctrl && !m.alt) => { - // Tab to next clip boundary (or transient when enabled). - let s = app.engine.session(); - let at = s.edit.selection.start; - let back = !plain; - if s.edit.tab_to_transient - && !back - && let Some(t) = s.edit.selected_tracks.first().copied() - { - let r = soundcraft_time::Range::new(at + 1, s.content_end()); - if let Some(n) = soundcraft_engine::io::transients_in(s, t, r, 0.5).into_iter().find(|x| *x > at + 64) { - let _ = app.run("transport.locate", json!({"at": n})); - return true; - } - } - let mut edges: Vec = s - .tracks - .iter() - .filter(|t| s.edit.selected_tracks.contains(&t.id)) - .flat_map(|t| t.clips().iter().flat_map(|c| [c.start, c.end()])) - .collect(); - edges.sort_unstable(); - let next = if back { edges.iter().rev().find(|e| **e < at).copied() } else { edges.iter().find(|e| **e > at).copied() }; - if let Some(n) = next { - let _ = app.run("transport.locate", json!({"at": n})); - } + // Alt+Tab goes back (off macOS the desktop takes Alt+Tab, so Ctrl+Tab does) and Shift extends the selection. + // Cmd+Alt+Tab is Tab to Transient, and the Mac's Control+Tab is Pro Tools' Go to Next Clip. + Key::Tab if !(m.command && (mac || m.alt)) && !(mac && m.ctrl) => { + let _ = app.run("edit.tab", json!({"back": m.alt || (!mac && m.ctrl), "extend": m.shift})); true } Key::ArrowUp | Key::ArrowDown if m.ctrl && !m.shift => { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e47df13..4daad8d 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -150,7 +150,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { ui.spacing_mut().item_spacing.x = 2.0; let e = app.engine.session().edit.clone(); let toggles: [(&str, &str, bool, &str); 5] = [ - ("options.tab_to_transient", "selector", e.tab_to_transient, "Tab to Transient"), + ("options.tab_to_transient", "tab_transient", e.tab_to_transient, "Tab to Transient"), ("options.link_timeline_edit", "link", e.link_timeline_edit, "Link Timeline and Edit Selection"), ("options.link_track_edit", "link", e.link_track_edit, "Link Track and Edit Selection"), ("options.mirrored_midi", "note", e.mirrored_midi, "Mirrored MIDI Editing"), diff --git a/crates/ui-egui/tests/pro_tools_keys.rs b/crates/ui-egui/tests/pro_tools_keys.rs index a6af215..c87ede6 100644 --- a/crates/ui-egui/tests/pro_tools_keys.rs +++ b/crates/ui-egui/tests/pro_tools_keys.rs @@ -232,3 +232,30 @@ fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() { frame(&mut app, keys(Key::ArrowUp, Modifiers::NONE)); assert_eq!(pitch(&app), before + 1); } + +#[test] +fn shift_tab_extends_the_selection_and_tab_to_transient_stops_on_hits() { + for (os, cmd) in OSES { + let (ctx, mut app) = rig(os); + let sel = |app: &SoundApp| { + let r = app.engine.session().edit.selection; + (r.start, r.end) + }; + let clip = app.engine.session().track_by_name("Kick").unwrap().clips()[0].range(); + let mid = clip.start + clip.len() / 2; + app.run("edit.select", json!({"tracks": ["Kick"], "start": mid, "end": mid})).unwrap(); + press(&ctx, &mut app, Key::Tab, Modifiers::SHIFT); + assert_eq!(sel(&app), (mid, clip.end), "{os:?}"); + let back_extend = if os == OperatingSystem::Mac { ALT_SHIFT } else { Modifiers { shift: true, ..cmd } }; + press(&ctx, &mut app, Key::Tab, back_extend); + assert_eq!(sel(&app), (clip.start, clip.end), "{os:?}"); + + press(&ctx, &mut app, Key::Tab, Modifiers { alt: true, ..cmd }); + app.run("edit.select", json!({"start": clip.start})).unwrap(); + press(&ctx, &mut app, Key::Tab, Modifiers::NONE); + let hit = start(&app); + assert!(hit > clip.start + 480 && hit < clip.end, "{os:?}: {hit}"); + press(&ctx, &mut app, Key::Tab, if os == OperatingSystem::Mac { Modifiers::ALT } else { cmd }); + assert_eq!(start(&app), clip.start, "{os:?}"); + } +}