Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (Cancel Zoom Toggle, side panels in dock columns, 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-31 · **Change:** trivial (Clip Looping crossfade, Cancel Zoom Toggle, side panels in dock columns, 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
Expand Down Expand Up @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track

## Progress log

- **2026-10-31:** Clip Looping has Enable Crossfade with a length in milliseconds (`clip.loop` `crossfade`, `crossfade_shape`). Each seam between two loops is crossfaded, centred on the seam and overlapping over the clip's handles; without handles each side fades over its half. Unloop still finds a crossfaded loop and puts the first clip back to its own end. Number of Loops now reaches the same 1000 loops as Loop Length and Loop Until End of Session or Next Clip, instead of stopping at 511. No readiness percentage change.
- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change.
- **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.
- **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.
Expand Down Expand Up @@ -177,6 +178,7 @@ mutated and truncated movies. Field reports from the first public week are track

| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | trivial | Progress log: Clip Looping crossfade and 1000-loop Number of Loops; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ mod panic_probe {
}
}

pub use clip::MAX_LOOPS as MAX_CLIP_LOOPS;
pub use file::bounce_job;

pub(crate) use edit::remember_selection;
Expand Down
55 changes: 47 additions & 8 deletions crates/engine/src/cmd/clip.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
//! Clip menu commands.
use super::{CommandSpec, always, bad, clip_ids_param, f64_or, has_range, has_selection, i64_or, position_param, str_param, track_param};
use soundcraft_model::ClipId;
use soundcraft_time::Samples;
use soundcraft_model::{ClipId, FadeShape};
use soundcraft_time::{Range, Samples};

use crate::{Engine, Result, cmd};
use serde_json::{Value, json};

/// The Clip menu commands this module registers.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("clip.edit_lock", "Edit Lock/Unlock", ["Clip"], Some("Cmd+L"), "{clips?}", has_selection, |e, p| flag(
Expand Down Expand Up @@ -72,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Loop...",
["Clip"],
Some("Cmd+Alt+L"),
"{clips?, count?: n, length?: samples, fill?: bool}: count is the clip plus its loops; length is the whole looped clip, with the last loop cut short to fit; fill loops up to the next clip on the track, or else to the session end",
"{clips?, count?: n, length?: samples, fill?: bool, crossfade?: samples, crossfade_shape?: linear|equal power|s-curve|exponential|logarithmic}: count is the clip plus its loops (at most 1001, 1000 loops); length is the whole looped clip, with the last loop cut short to fit; fill loops up to the next clip on the track, or else to the session end; crossfade (at most half the clip) crossfades each loop into the next, centred on the seam, using the clip's handles (default shape equal power)",
has_selection,
loop_clips
),
Expand Down Expand Up @@ -296,14 +297,37 @@ fn layer(e: &mut Engine, p: &Value, front: bool) -> Result<Value> {
Ok(json!({}))
}

/// The most loops `clip.loop` makes of one clip.
const MAX_LOOPS: i64 = 1000;
/// The most loops `clip.loop` makes of one clip, whether by count, length or fill.
pub const MAX_LOOPS: i64 = 1000;

/// `clip.loop`: repeats each selected clip back to back after itself, by `count`, by a total
/// `length` (the last loop cut short to fit) or by `fill` (up to the next clip or the session end).
/// The copies share a loop id with the original so Unloop finds them. With `crossfade`, each seam
/// between two loops is crossfaded over the clip's handles, never past the copy after the seam.
/// Returns how many copies it made; a request for more than `MAX_LOOPS` loops is refused whole.
fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let count = i64_or(p, "count", 4).clamp(1, 512);
// `count` is the clip plus its loops, so up to MAX_LOOPS loops is MAX_LOOPS + 1.
let count = i64_or(p, "count", 4).max(1);
if count > MAX_LOOPS.saturating_add(1) {
return Err(bad(
"clip.loop",
format!("count {count} is the clip and {} loops; a clip loops at most {MAX_LOOPS} times", count.saturating_sub(1)),
));
}
let total = position_param(e, "clip.loop", p, "length")?;
let fill = p.get("fill").and_then(Value::as_bool).unwrap_or(false);
let crossfade = position_param(e, "clip.loop", p, "crossfade")?.unwrap_or(0);
if crossfade < 0 {
return Err(bad("clip.loop", "crossfade must be a length of zero or more samples"));
}
let shape = match p.get("crossfade_shape") {
None => FadeShape::EqualPower,
Some(v) => v
.as_str()
.and_then(FadeShape::from_id)
.ok_or_else(|| bad("clip.loop", "crossfade_shape must be linear|equal power|s-curve|exponential|logarithmic"))?,
};
let s = e.session_mut();
let session_end = s.content_end();
let mut made = 0;
Expand Down Expand Up @@ -334,7 +358,7 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
}
(reps, if rest > 0 { rest } else { len })
}
None => ((count - 1).clamp(0, MAX_LOOPS), len),
None => (count.saturating_sub(1).clamp(0, MAX_LOOPS), len),
};
if reps > 0 && c.loop_id.is_none() {
let lid = s.alloc();
Expand All @@ -344,20 +368,35 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
c.loop_id = Some(lid);
}
let mut copies = Vec::new();
let mut seams = Vec::new();
for k in 1..=reps {
let mut nc = c.clone();
nc.start = 0;
if k == reps && last < c.length {
// `start` is 0 here, so the end is the length; the trim also shortens the fades to fit and removes a sync point past the cut.
nc.trim_end_to(last);
}
copies.push((c.start + c.length * k, nc));
let at = c.start.saturating_add(c.length.saturating_mul(k));
// A seam and the end of the copy it starts, which a short last copy brings close.
seams.push((at, at.saturating_add(nc.length)));
copies.push((at, nc));
}
for (at, nc) in copies {
let length = nc.length;
let _ = crate::edit::paste(s, tid, at, &[nc], length, false);
made += 1;
}
// Each seam's crossfade stays inside its own two iterations: at most half a whole one, and
// never past the end of the copy after the seam, so a short last copy is not crossfaded
// into the clip that follows the loop.
let xf = crossfade.min(len / 2);
if xf > 0 {
for (seam, copy_end) in seams {
let half = xf / 2;
let range = Range::new(seam.saturating_sub(half), seam.saturating_add(xf - half).min(copy_end));
crate::edit::create_fades(s, tid, range, shape);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}
Ok(json!({"loops": made}))
}
Expand Down
Loading