mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-10 04:09:58 -06:00
618 lines
28 KiB
Rust
618 lines
28 KiB
Rust
//! 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 crate::{Engine, Result, cmd};
|
|
use serde_json::{Value, json};
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!("clip.edit_lock", "Edit Lock/Unlock", ["Clip"], Some("Cmd+L"), "{clips?}", has_selection, |e, p| flag(
|
|
e,
|
|
p,
|
|
|c| c.edit_locked,
|
|
|c, v| c.edit_locked = v
|
|
)),
|
|
cmd!("clip.time_lock", "Time Lock/Unlock", ["Clip"], Some("Ctrl+Alt+L"), "{clips?}", has_selection, |e, p| flag(
|
|
e,
|
|
p,
|
|
|c| c.time_locked,
|
|
|c, v| c.time_locked = v
|
|
)),
|
|
cmd!("clip.send_to_back", "Send to Back", ["Clip"], Some("Alt+Shift+B"), "{clips?}", has_selection, |e, p| layer(e, p, false)),
|
|
cmd!("clip.bring_to_front", "Bring to Front", ["Clip"], Some("Alt+Shift+F"), "{clips?}", has_selection, |e, p| layer(e, p, true)),
|
|
cmd!("clip.rating", "Rating", ["Clip", "Rating"], Some("Cmd+Alt+0-5"), "{clips?, rating: 0..5}", has_selection, |e, p| {
|
|
let ids = clip_ids_param(e, p);
|
|
let r = i64_or(p, "rating", 0).clamp(0, 5) as u8;
|
|
let s = e.session_mut();
|
|
for id in &ids {
|
|
if let Some(c) = s.find_clip_mut(*id) {
|
|
c.rating = r
|
|
}
|
|
}
|
|
Ok(json!({"rating": r}))
|
|
}),
|
|
cmd!("clip.group", "Group", ["Clip"], Some("Cmd+Alt+G"), "{clips?}", has_selection, |e, p| {
|
|
let ids = clip_ids_param(e, p);
|
|
if ids.len() < 2 {
|
|
return Err(bad("clip.group", "select two or more clips"));
|
|
}
|
|
let s = e.session_mut();
|
|
let g = s.alloc();
|
|
for id in &ids {
|
|
if let Some(c) = s.find_clip_mut(*id) {
|
|
c.group = Some(g)
|
|
}
|
|
}
|
|
Ok(json!({"group": g}))
|
|
}),
|
|
cmd!("clip.ungroup", "Ungroup", ["Clip"], Some("Cmd+Alt+U"), "{clips?}", has_selection, |e, p| {
|
|
let ids = clip_ids_param(e, p);
|
|
let s = e.session_mut();
|
|
for id in &ids {
|
|
if let Some(c) = s.find_clip_mut(*id) {
|
|
c.group = None
|
|
}
|
|
}
|
|
Ok(json!({}))
|
|
}),
|
|
cmd!("clip.ungroup_all", "Ungroup All", ["Clip"], None, "{}", always, |e, _| {
|
|
for t in &mut e.session_mut().tracks {
|
|
for pl in &mut t.playlists {
|
|
for c in &mut pl.clips {
|
|
c.group = None
|
|
}
|
|
}
|
|
}
|
|
Ok(json!({}))
|
|
}),
|
|
cmd!(
|
|
"clip.loop",
|
|
"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",
|
|
has_selection,
|
|
loop_clips
|
|
),
|
|
cmd!("clip.rename", "Rename...", ["Clip"], Some("Cmd+Alt+Shift+R"), "{clip?, name}", has_selection, rename),
|
|
cmd!("clip.gain", "Clip Gain", [], None, "{clips?, db | delta_db}", has_selection, gain),
|
|
cmd!("clip.gain_nudge_up", "Nudge Clip Gain Up", [], Some("Ctrl+Shift+Up"), "{clips?}", has_selection, |e, p| nudge_gain(e, p, 1.0)),
|
|
cmd!("clip.gain_nudge_down", "Nudge Clip Gain Down", [], Some("Ctrl+Shift+Down"), "{clips?}", has_selection, |e, p| nudge_gain(e, p, -1.0)),
|
|
cmd!("clip.gain_render", "Render", ["Clip", "Clip Gain"], None, "{clips?}", has_selection, |e, p| {
|
|
let ids = clip_ids_param(e, p);
|
|
let mut n = 0;
|
|
for id in ids {
|
|
if crate::io::render_clip_gain(e, "clip.gain_render", id)? {
|
|
n += 1
|
|
}
|
|
}
|
|
Ok(json!({"rendered": n}))
|
|
}),
|
|
cmd!("clip.gain_bypass", "Bypass", ["Clip", "Clip Gain"], None, "{clips?}", has_selection, |e, p| {
|
|
let ids = clip_ids_param(e, p);
|
|
let s = e.session_mut();
|
|
for id in &ids {
|
|
if let Some(c) = s.find_clip_mut(*id) {
|
|
c.gain_db = 0.0;
|
|
c.gain_env.clear()
|
|
}
|
|
}
|
|
Ok(json!({}))
|
|
}),
|
|
cmd!("clip.identify_sync_point", "Identify Sync Point", ["Clip"], Some("Cmd+,"), "{clip?, at?}", has_selection, |e, p| {
|
|
let at = position_param(e, "clip.identify_sync_point", p, "at")?.unwrap_or(e.session().edit.selection.start);
|
|
let ids = clip_ids_param(e, p);
|
|
let s = e.session_mut();
|
|
for id in &ids {
|
|
if let Some(c) = s.find_clip_mut(*id) {
|
|
c.sync_point = (at - c.start).clamp(0, c.length)
|
|
}
|
|
}
|
|
Ok(json!({}))
|
|
}),
|
|
cmd!("clip.remove_sync_point", "Remove Sync Point", ["Clip"], None, "{clips?}", has_sync_point, remove_sync_point),
|
|
cmd!("clip.quantize_to_grid", "Quantize to Grid", ["Clip"], Some("Cmd+0"), "{clips?}", has_selection, quantize_to_grid),
|
|
cmd!("clip.color", "Clip Color", [], None, "{clips?, color: [r,g,b] | null}", has_selection, |e, p| {
|
|
let ids = clip_ids_param(e, p);
|
|
let col = p.get("color").and_then(Value::as_array).map(|a| {
|
|
let g = |i: usize| a.get(i).and_then(Value::as_u64).unwrap_or(0).min(255) as u8;
|
|
[g(0), g(1), g(2)]
|
|
});
|
|
let s = e.session_mut();
|
|
for id in &ids {
|
|
if let Some(c) = s.find_clip_mut(*id) {
|
|
c.color = col
|
|
}
|
|
}
|
|
Ok(json!({}))
|
|
}),
|
|
cmd!(
|
|
"clip.elastic_properties",
|
|
"Elastic Properties",
|
|
["Clip"],
|
|
Some("Alt+5"),
|
|
"{clips?, ratio?: 1.0} — on Elastic Audio tracks (polyphonic/rhythmic/monophonic/x-form) the stretch is rendered pitch-preserving; elsewhere it is varispeed",
|
|
has_selection,
|
|
elastic
|
|
),
|
|
cmd!("clip.remove_warp", "Remove Warp", ["Clip"], None, "{clips?}", has_selection, remove_warp),
|
|
cmd!(
|
|
"clip.place_source",
|
|
"Place Audio File",
|
|
[],
|
|
None,
|
|
"{source: id, track?: id|name (default: a new track), at?: position}",
|
|
always,
|
|
|e, p| {
|
|
let src = p
|
|
.get("source")
|
|
.and_then(Value::as_u64)
|
|
.map(soundcraft_model::SourceId)
|
|
.ok_or_else(|| bad("clip.place_source", "`source` id required"))?;
|
|
let at = position_param(e, "clip.place_source", p, "at")?.unwrap_or(e.session().edit.selection.start).max(0);
|
|
let track = track_param(e, "clip.place_source", p, "track")?;
|
|
let (name, frames, chans) = e
|
|
.session()
|
|
.source(src)
|
|
.map(|x| (x.name.clone(), i64::try_from(x.frames).unwrap_or(0), usize::from(x.channels)))
|
|
.ok_or_else(|| bad("clip.place_source", "no such audio file"))?;
|
|
let s = e.session_mut();
|
|
let t = match track {
|
|
Some(t) if s.track(t).is_some_and(|x| x.kind == soundcraft_model::TrackKind::Audio) => t,
|
|
Some(_) => return Err(bad("clip.place_source", "audio files go on audio tracks")),
|
|
None => s.add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::for_channels(chans.min(2)), Some(&name)),
|
|
};
|
|
let (sh, len) = (s.edit.edit_mode == soundcraft_model::EditMode::Shuffle, frames.max(1));
|
|
let at = if sh { crate::edit::shuffle_slot(s, t, at, &[]) } else { at };
|
|
if sh {
|
|
crate::edit::make_room(s, t, at, len).map_err(|why| bad("clip.place_source", why))?;
|
|
}
|
|
let id = s.new_clip_id();
|
|
crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, len));
|
|
Ok(json!({"clip": id, "track": t}))
|
|
}
|
|
),
|
|
cmd!("clip.capture", "Capture...", ["Clip"], Some("Cmd+R"), "{name?}", has_range, |e, p| {
|
|
// Captures the selection as a new whole-file-referencing clip in the clip list (we keep it on the track).
|
|
let name = str_param(p, "name").unwrap_or("Captured").to_string();
|
|
let ids = clip_ids_param(e, p);
|
|
let s = e.session_mut();
|
|
for id in &ids {
|
|
if let Some(c) = s.find_clip_mut(*id) {
|
|
c.name = name.clone()
|
|
}
|
|
}
|
|
Ok(json!({"clips": ids.len()}))
|
|
}),
|
|
]
|
|
}
|
|
|
|
/// Warp clips. On Elastic tracks the material is rendered with a pitch-preserving time stretch
|
|
/// (the original source is remembered in `edit.values` so Remove Warp restores it).
|
|
fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
|
|
let ids = clip_ids_param(e, p);
|
|
let ratio = f64_or(p, "ratio", 1.0).clamp(0.05, 20.0);
|
|
let s = e.session_mut();
|
|
let mut rendered = 0;
|
|
for id in &ids {
|
|
let Some((t, c)) = s.find_clip(*id).map(|(t, c)| (t, c.clone())) else { continue };
|
|
let elastic = s.track(t).and_then(|x| x.elastic.clone()).filter(|a| a != "varispeed");
|
|
// Start from the unwarped original if this clip was warped before.
|
|
let key = |k: &str| format!("elastic.{}.{k}", id.0);
|
|
let orig = warp_original(s, *id);
|
|
let mut base = c.clone();
|
|
if let Some((src, off, len)) = orig {
|
|
base.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
|
|
base.length = len.max(1);
|
|
base.stretch = 1.0;
|
|
}
|
|
if elastic.is_none() || !base.is_audio() {
|
|
if let Some(cm) = s.find_clip_mut(*id) {
|
|
cm.stretch = ratio;
|
|
}
|
|
continue;
|
|
}
|
|
let out_len = soundcraft_time::to_samples(base.length as f64 * ratio).max(1);
|
|
let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &base, out_len)? else { continue };
|
|
if orig.is_none()
|
|
&& let soundcraft_model::ClipContent::Audio { source, offset } = base.content
|
|
{
|
|
s.edit.values.insert(key("src"), source.0 as f64);
|
|
s.edit.values.insert(key("off"), offset as f64);
|
|
s.edit.values.insert(key("len"), base.length as f64);
|
|
}
|
|
s.edit.values.insert(key("ratio"), ratio);
|
|
let mut nc = base;
|
|
nc.content = soundcraft_model::ClipContent::Audio { source: src, offset: 0 };
|
|
nc.length = out_len;
|
|
nc.stretch = 1.0;
|
|
nc.clamp_fades();
|
|
super::more_util::replace_clip(s, t, *id, nc);
|
|
rendered += 1;
|
|
}
|
|
Ok(json!({"ratio": ratio, "rendered": rendered}))
|
|
}
|
|
|
|
fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
|
|
let ids = clip_ids_param(e, p);
|
|
let s = e.session_mut();
|
|
let mut n = 0;
|
|
for id in &ids {
|
|
let key = |k: &str| format!("elastic.{}.{k}", id.0);
|
|
let orig = warp_original(s, *id);
|
|
if let Some(c) = s.find_clip_mut(*id) {
|
|
c.stretch = 1.0;
|
|
if let Some((src, off, len)) = orig {
|
|
c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
|
|
c.length = len.max(1);
|
|
c.clamp_fades();
|
|
n += 1;
|
|
}
|
|
}
|
|
for k in ["src", "off", "len", "ratio"] {
|
|
s.edit.values.remove(&key(k));
|
|
}
|
|
}
|
|
Ok(json!({"restored": n}))
|
|
}
|
|
|
|
/// The audio a warped clip was rendered from, while that file is still in the session.
|
|
fn warp_original(s: &soundcraft_model::Session, id: ClipId) -> Option<(soundcraft_model::SourceId, Samples, Samples)> {
|
|
let v = |k: &str| s.edit.values.get(&format!("elastic.{}.{k}", id.0)).copied();
|
|
let src = soundcraft_model::SourceId(v("src")? as u64);
|
|
s.source(src)?;
|
|
Some((src, v("off")? as i64, v("len")? as i64))
|
|
}
|
|
|
|
fn flag(e: &mut Engine, p: &Value, get: fn(&soundcraft_model::Clip) -> bool, set: fn(&mut soundcraft_model::Clip, bool)) -> Result<Value> {
|
|
let ids = clip_ids_param(e, p);
|
|
let s = e.session_mut();
|
|
let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !ids.iter().all(|id| s.find_clip(*id).is_some_and(|(_, c)| get(c))));
|
|
for id in &ids {
|
|
if let Some(c) = s.find_clip_mut(*id) {
|
|
set(c, v);
|
|
}
|
|
}
|
|
Ok(json!({"value": v, "clips": ids.len()}))
|
|
}
|
|
|
|
fn layer(e: &mut Engine, p: &Value, front: bool) -> Result<Value> {
|
|
let ids = clip_ids_param(e, p);
|
|
let s = e.session_mut();
|
|
for t in &mut s.tracks {
|
|
if let Some(pl) = t.playlist_mut() {
|
|
let (mut sel, mut rest): (Vec<_>, Vec<_>) = pl.clips.drain(..).partition(|c| ids.contains(&c.id));
|
|
if front {
|
|
rest.append(&mut sel);
|
|
pl.clips = rest;
|
|
} else {
|
|
sel.append(&mut rest);
|
|
pl.clips = sel;
|
|
}
|
|
}
|
|
}
|
|
Ok(json!({}))
|
|
}
|
|
|
|
/// The most loops `clip.loop` makes of one clip.
|
|
const MAX_LOOPS: i64 = 1000;
|
|
|
|
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);
|
|
let total = position_param(e, "clip.loop", p, "length")?;
|
|
let fill = p.get("fill").and_then(Value::as_bool).unwrap_or(false);
|
|
let s = e.session_mut();
|
|
let session_end = s.content_end();
|
|
let mut made = 0;
|
|
for id in ids {
|
|
let Some((tid, mut c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
|
|
if c.length <= 0 {
|
|
continue;
|
|
}
|
|
let len = c.length;
|
|
// Where the looped clip ends: the next clip or the session end, or `length` after the start.
|
|
let end = if fill {
|
|
let next = s.track(tid).and_then(|t| t.clips().iter().filter(|x| x.id != c.id && x.start >= c.end()).map(|x| x.start).min());
|
|
Some(next.unwrap_or(session_end))
|
|
} else {
|
|
total.map(|l| c.start.saturating_add(l))
|
|
};
|
|
// Whole iterations, then one cut short to reach `end` exactly.
|
|
let (reps, last) = match end {
|
|
Some(end) => {
|
|
let span = end.saturating_sub(c.end()).max(0);
|
|
let (whole, rest) = (span / len, span % len);
|
|
let reps = whole.saturating_add(i64::from(rest > 0));
|
|
if reps > MAX_LOOPS {
|
|
return Err(bad(
|
|
"clip.loop",
|
|
format!("“{}” would need {reps} loops to fill that; a clip loops at most {MAX_LOOPS} times", c.name),
|
|
));
|
|
}
|
|
(reps, if rest > 0 { rest } else { len })
|
|
}
|
|
None => ((count - 1).clamp(0, MAX_LOOPS), len),
|
|
};
|
|
if reps > 0 && c.loop_id.is_none() {
|
|
let lid = s.alloc();
|
|
if let Some(src) = s.find_clip_mut(id) {
|
|
src.loop_id = Some(lid);
|
|
}
|
|
c.loop_id = Some(lid);
|
|
}
|
|
let mut copies = 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));
|
|
}
|
|
for (at, nc) in copies {
|
|
let length = nc.length;
|
|
let _ = crate::edit::paste(s, tid, at, &[nc], length, false);
|
|
made += 1;
|
|
}
|
|
}
|
|
Ok(json!({"loops": made}))
|
|
}
|
|
|
|
fn rename(e: &mut Engine, p: &Value) -> Result<Value> {
|
|
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("clip.rename", "`name` required"))?.to_string();
|
|
let ids = clip_ids_param(e, p);
|
|
let s = e.session_mut();
|
|
for id in &ids {
|
|
if let Some(c) = s.find_clip_mut(*id) {
|
|
c.name = name.clone();
|
|
}
|
|
}
|
|
Ok(json!({"clips": ids.len()}))
|
|
}
|
|
|
|
fn gain(e: &mut Engine, p: &Value) -> Result<Value> {
|
|
let ids = clip_ids_param(e, p);
|
|
let abs = p.get("db").and_then(Value::as_f64);
|
|
let delta = p.get("delta_db").and_then(Value::as_f64);
|
|
if abs.is_none() && delta.is_none() {
|
|
return Err(bad("clip.gain", "`db` or `delta_db` required"));
|
|
}
|
|
let s = e.session_mut();
|
|
for id in &ids {
|
|
if let Some(c) = s.find_clip_mut(*id) {
|
|
let v = abs.unwrap_or(f64::from(c.gain_db) + delta.unwrap_or(0.0));
|
|
c.gain_db = if v.is_finite() { (v as f32).clamp(-144.0, 36.0) } else { c.gain_db };
|
|
}
|
|
}
|
|
Ok(json!({"clips": ids.len()}))
|
|
}
|
|
|
|
fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result<Value> {
|
|
let clips = clip_ids_param(e, p);
|
|
gain(e, &json!({"clips": clips, "delta_db": db}))
|
|
}
|
|
|
|
/// Sync point 0 is "none" (edit_window only draws one when > 0).
|
|
fn has_sync_point(e: &Engine) -> std::result::Result<(), String> {
|
|
let s = e.session();
|
|
if clip_ids_param(e, &json!({})).iter().any(|id| s.find_clip(*id).is_some_and(|(_, c)| c.sync_point != 0)) {
|
|
Ok(())
|
|
} else {
|
|
Err("no selected clip has a sync point".into())
|
|
}
|
|
}
|
|
|
|
fn remove_sync_point(e: &mut Engine, p: &Value) -> Result<Value> {
|
|
let s = e.session();
|
|
// Only clips that have a point are touched, so a call that changes nothing leaves no undo step.
|
|
let ids: Vec<ClipId> = clip_ids_param(e, p).into_iter().filter(|id| s.find_clip(*id).is_some_and(|(_, c)| c.sync_point != 0)).collect();
|
|
if !ids.is_empty() {
|
|
let s = e.session_mut();
|
|
for id in &ids {
|
|
if let Some(c) = s.find_clip_mut(*id) {
|
|
c.sync_point = 0
|
|
}
|
|
}
|
|
}
|
|
Ok(json!({"clips": ids.len()}))
|
|
}
|
|
|
|
fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
|
let ids = clip_ids_param(e, p);
|
|
let s = e.session();
|
|
let moves: Vec<(soundcraft_model::ClipId, i64)> =
|
|
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false, &ids)))).collect();
|
|
let s = e.session_mut();
|
|
for (id, d) in &moves {
|
|
crate::edit::move_clips(s, &[*id], *d, &[]);
|
|
}
|
|
Ok(json!({"clips": moves.len()}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
use soundcraft_model::{ClipId, TrackId};
|
|
|
|
#[test]
|
|
fn elastic_warp_renders_and_remove_warp_restores() {
|
|
let mut e = crate::demo::demo_engine();
|
|
let pad = e.session().track_by_name("Pad").map(|t| t.id.0).unwrap();
|
|
e.execute("track.elastic", &json!({"track": pad, "algorithm": "polyphonic"})).unwrap();
|
|
let cid = e.session().track(TrackId(pad)).unwrap().clips()[0].id.0;
|
|
let len0 = e.session().find_clip(ClipId(cid)).unwrap().1.length;
|
|
let r = e.execute("clip.elastic_properties", &json!({"clips": [cid], "ratio": 1.5})).unwrap();
|
|
assert_eq!(r["rendered"], 1);
|
|
let c = e.session().find_clip(ClipId(cid)).unwrap().1.clone();
|
|
assert!((c.length as f64 - len0 as f64 * 1.5).abs() < 2.0, "{} vs {}", c.length, len0);
|
|
assert_eq!(c.stretch, 1.0);
|
|
// Re-warping starts from the original, not the rendered audio.
|
|
e.execute("clip.elastic_properties", &json!({"clips": [cid], "ratio": 0.5})).unwrap();
|
|
let c = e.session().find_clip(ClipId(cid)).unwrap().1.clone();
|
|
assert!((c.length as f64 - len0 as f64 * 0.5).abs() < 2.0);
|
|
e.execute("clip.remove_warp", &json!({"clips": [cid]})).unwrap();
|
|
assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0);
|
|
}
|
|
|
|
/// A clip on an Elastic track playing `clip_len` samples of a source that is silent before `tone_at`.
|
|
fn elastic_clip(source_len: usize, tone_at: usize, clip_len: i64) -> (crate::Engine, TrackId, ClipId) {
|
|
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
|
|
let mut s = Session::default();
|
|
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Elastic"));
|
|
let samples = (0..source_len).map(|i| if i >= tone_at { (i as f32 * 0.2).sin() * 0.4 } else { 0.0 }).collect();
|
|
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![samples] };
|
|
let src = crate::io::add_source(&mut s, "Tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
|
let cid = s.new_clip_id();
|
|
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Clip", src, 0, 0, clip_len));
|
|
let mut e = crate::Engine::new(s);
|
|
e.execute("track.elastic", &json!({"track": t.0, "algorithm": "polyphonic"})).unwrap();
|
|
(e, t, cid)
|
|
}
|
|
|
|
fn rendered_peak(e: &crate::Engine, t: TrackId, cid: ClipId) -> f32 {
|
|
let range = e.session().find_clip(cid).unwrap().1.range();
|
|
soundcraft_mix::render_clips(e.session(), t, range).iter().flatten().fold(0.0, |m, x| m.max(x.abs()))
|
|
}
|
|
|
|
/// Elastic Properties stretches the material the clip plays, by the requested ratio (#227).
|
|
#[test]
|
|
fn elastic_properties_stretches_the_clip_material() {
|
|
let (mut e, t, cid) = elastic_clip(20_000, 10_000, 20_000);
|
|
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap();
|
|
assert_eq!(e.session().find_clip(cid).unwrap().1.length, 40_000);
|
|
assert!(rendered_peak(&e, t, cid) > 0.3, "the tone in the second half was lost");
|
|
|
|
let (mut e, t, cid) = elastic_clip(60_000, 20_000, 20_000);
|
|
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap();
|
|
assert_eq!(rendered_peak(&e, t, cid), 0.0, "audio from past the clip end leaked in");
|
|
|
|
for (ratio, len) in [(4.0, 80_000), (0.2, 4_000)] {
|
|
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
|
|
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": ratio})).unwrap();
|
|
assert_eq!(e.session().find_clip(cid).unwrap().1.length, len, "ratio {ratio}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn warping_and_unwarping_keep_fades_within_the_clip() {
|
|
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
|
|
if let Some(c) = e.session_mut().find_clip_mut(cid) {
|
|
c.fade_in.len = 20_000;
|
|
c.fade_out.len = 20_000;
|
|
}
|
|
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap();
|
|
let c = e.session().find_clip(cid).unwrap().1.clone();
|
|
assert!(c.fade_in.len <= c.length && c.fade_out.len <= c.length, "{} {} {}", c.length, c.fade_in.len, c.fade_out.len);
|
|
|
|
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
|
|
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap();
|
|
if let Some(c) = e.session_mut().find_clip_mut(cid) {
|
|
c.fade_in.len = 40_000;
|
|
}
|
|
e.execute("clip.remove_warp", &json!({"clips": [cid.0]})).unwrap();
|
|
let c = e.session().find_clip(cid).unwrap().1.clone();
|
|
assert!(c.fade_in.len <= c.length, "{} {}", c.length, c.fade_in.len);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
|
|
let mut e = crate::demo::demo_engine();
|
|
let pad = e.session().track_by_name("Pad").map(|t| t.id).unwrap();
|
|
e.execute("track.elastic", &json!({"track": pad, "algorithm": "polyphonic"})).unwrap();
|
|
let clip = e.session().track(pad).unwrap().clips()[0].clone();
|
|
e.execute("clip.elastic_properties", &json!({"clips": [clip.id], "ratio": 1.5})).unwrap();
|
|
e.execute("clip.clear", &json!({"sources": [clip.source()]})).unwrap();
|
|
let r = e.execute("clip.remove_warp", &json!({"clips": [clip.id]})).unwrap();
|
|
assert_eq!(r["restored"], 0);
|
|
let src = e.session().find_clip(clip.id).and_then(|(_, c)| c.source()).unwrap();
|
|
assert!(e.session().source(src).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn gain_nudges_one_db_at_a_time_up_to_the_ceiling() {
|
|
let mut e = crate::demo::demo_engine();
|
|
let kick = e.session().track_by_name("Kick").unwrap().clips()[0].id;
|
|
let gain = |e: &crate::Engine| e.session().find_clip(kick).unwrap().1.gain_db;
|
|
e.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
|
|
e.execute("clip.gain_nudge_up", &json!({})).unwrap();
|
|
e.execute("clip.gain_nudge_up", &json!({})).unwrap();
|
|
e.execute("clip.gain_nudge_down", &json!({})).unwrap();
|
|
assert_eq!(gain(&e), 1.0);
|
|
e.execute("clip.gain", &json!({"db": 35.5})).unwrap();
|
|
e.execute("clip.gain_nudge_up", &json!({})).unwrap();
|
|
assert_eq!(gain(&e), 36.0);
|
|
}
|
|
|
|
/// Three 10 000-sample clips side by side on one track; sync points are set by each test.
|
|
fn three_clips() -> (crate::Engine, [ClipId; 3]) {
|
|
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
|
|
let mut s = Session::default();
|
|
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Dialog"));
|
|
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10_000]] };
|
|
let src = crate::io::add_source(&mut s, "Line", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
|
let ids = [0, 1, 2].map(|k| {
|
|
let cid = s.new_clip_id();
|
|
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Line", src, 0, k * 12_000, 10_000));
|
|
cid
|
|
});
|
|
(crate::Engine::new(s), ids)
|
|
}
|
|
|
|
fn sync_of(e: &crate::Engine, id: ClipId) -> i64 {
|
|
e.session().find_clip(id).unwrap().1.sync_point
|
|
}
|
|
|
|
#[test]
|
|
fn remove_sync_point_clears_the_point_identify_set() {
|
|
let (mut e, [a, ..]) = three_clips();
|
|
e.execute("edit.select", &json!({"clips": [a.0]})).unwrap();
|
|
e.execute("clip.identify_sync_point", &json!({"at": 4_000})).unwrap();
|
|
assert_eq!(sync_of(&e, a), 4_000);
|
|
e.execute("clip.remove_sync_point", &json!({})).unwrap();
|
|
assert_eq!(sync_of(&e, a), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_sync_point_clears_every_selected_clip_and_only_those() {
|
|
let (mut e, [a, b, c]) = three_clips();
|
|
for (id, at) in [(a, 1_000), (b, 2_000), (c, 3_000)] {
|
|
e.session_mut().find_clip_mut(id).unwrap().sync_point = at;
|
|
}
|
|
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
|
|
let r = e.execute("clip.remove_sync_point", &json!({})).unwrap();
|
|
assert_eq!(r["clips"], 2);
|
|
assert_eq!([sync_of(&e, a), sync_of(&e, b), sync_of(&e, c)], [0, 0, 3_000]);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_sync_point_is_disabled_unless_a_selected_clip_has_one() {
|
|
let (mut e, [a, b, c]) = three_clips();
|
|
e.session_mut().find_clip_mut(c).unwrap().sync_point = 3_000;
|
|
let enabled = |e: &crate::Engine| crate::find_command("clip.remove_sync_point").unwrap().info(e).enabled;
|
|
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
|
|
assert!(!enabled(&e), "neither selected clip has a sync point (the unselected one does)");
|
|
e.execute("edit.select", &json!({"clips": [b.0, c.0]})).unwrap();
|
|
assert!(enabled(&e));
|
|
}
|
|
|
|
#[test]
|
|
fn remove_sync_point_is_one_undo_step_that_puts_the_points_back() {
|
|
let (mut e, [a, b, _]) = three_clips();
|
|
for (id, at) in [(a, 1_000), (b, 2_000)] {
|
|
e.session_mut().find_clip_mut(id).unwrap().sync_point = at;
|
|
}
|
|
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
|
|
let steps = e.undo_history().len();
|
|
e.execute("clip.remove_sync_point", &json!({})).unwrap();
|
|
assert_eq!(e.undo_history().len(), steps + 1);
|
|
assert_eq!(e.undo_label(), Some("Remove Sync Point"));
|
|
assert_eq!([sync_of(&e, a), sync_of(&e, b)], [0, 0]);
|
|
assert!(e.undo());
|
|
assert_eq!([sync_of(&e, a), sync_of(&e, b)], [1_000, 2_000]);
|
|
}
|
|
}
|