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.
This commit is contained in:
grovebit 2026-10-12 23:51:18 +01:00
parent 3617011342
commit b9e4873ffa
6 changed files with 338 additions and 28 deletions

View File

@ -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());

View File

@ -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<Vec<Analysed>> = Mutex::new(Vec::new());
struct Analysed {
track: TrackId,
around: Range,
rate: SampleRate,
clips: Vec<Clip>,
audio: Vec<Weak<SourceAudio>>,
fx: Vec<(String, f64)>,
hits: Vec<Samples>,
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(noundo "edit.tab", "Tab", [], None, "{back?: bool, extend?: bool (extend the selection)}", always, tab)]
}
fn tab(e: &mut Engine, p: &Value) -> Result<Value> {
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<Samples> {
let nearer = |x: Samples, best: Option<Samples>| {
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<Samples> = 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<Samples>,
apart: impl Fn(Samples) -> bool,
) -> Option<Samples> {
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<Samples> {
let clips: Vec<Clip> = s.track(t).map_or_else(Vec::new, |tr| tr.clips().iter().filter(|c| c.range().overlaps(&around)).cloned().collect());
let audio: Vec<Weak<SourceAudio>> = 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<f32> {
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<f32>) -> 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<i64> {
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)]);
}
}

View File

@ -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);
}

View File

@ -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<i64> = 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 => {

View File

@ -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"),

View File

@ -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:?}");
}
}