Files
soundcraft/crates/engine/src/io.rs
T
Dylan M. Taylor 64669e9a7c chore: resolve SonarCloud maintainability issues (#1003)
* chore: resolve SonarCloud maintainability issues

* fix(packaging): extract verify_sha256 helper in package.sh

* style: apply rustfmt formatting

* fix(video): ensure prefix is i32 in cabac_mb
2026-10-23 22:33:59 -04:00

2194 lines
102 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Import, export, save/load, rendering helpers (bounce, consolidate, commit) and analysis.
use crate::{Engine, EngineError, Result};
use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_time::{Range, Samples};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30;
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples: a file with a
/// tiny sample rate grows by orders of magnitude.
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> Option<AudioBuffer> {
let from = buf.sample_rate.max(1);
if buf.frames() as f64 * buf.num_channels() as f64 * f64::from(rate) / f64::from(from) > limit as f64 {
return None;
}
if from != rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, from, rate);
buf.sample_rate = rate;
}
Some(buf)
}
// ─── Checked file access ──────────────────────────────────────────────────────────────────────
//
// Every file a command names by path is read and written through the helpers below, so the
// automation roots (`crate::file_access`, `soundcraft-cli mcp --automation-read-root` and
// `--automation-write-root`) are checked in one place. A test in `file_access` keeps the rest of
// the engine from calling `std::fs` itself. Audio is decoded into the session's pool the moment it
// enters it (import, open, sampler load), so playback and bounce read nothing from disk and
// need no check of their own.
/// The bytes of the file at `path`, which must be inside the automation read root when there is
/// one. Only a regular file qualifies: a device or a pipe named by a session or a command would
/// never end or never answer. Errors name the file.
pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>> {
let path = path.as_ref();
crate::file_access::check_read(path).map_err(EngineError::Io)?;
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
if !std::fs::metadata(path).map_err(err)?.is_file() {
return Err(EngineError::Io(format!("{}: not a regular file", path.display())));
}
std::fs::read(path).map_err(err)
}
/// [`read_file`] as UTF-8 text.
pub fn read_text(path: impl AsRef<Path>) -> Result<String> {
let path = path.as_ref();
String::from_utf8(read_file(path)?).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
}
/// Is there a file at `path` the automation read root lets us see? A path outside the root is
/// reported as not found, so a command can't probe for files beyond it.
pub fn file_found(path: impl AsRef<Path>) -> bool {
let path = path.as_ref();
crate::file_access::check_read(path).is_ok() && path.is_file()
}
/// The file a path stored in the session file `session` refers to, or why it is not opened.
///
/// Every path a session names (audio sources, movies) goes through here before it reaches the
/// file system. A relative path is taken from the session's folder, and only files on this
/// computer or on the network share the session itself is on are opened (`craft_docpath`).
/// Paths the person picks in this session (imports) are not stored paths and skip this.
pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf, String> {
let session = std::path::absolute(session).unwrap_or_else(|_| session.to_path_buf());
craft_docpath::resolve_absolute(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))
}
/// Create `dir` and its parents, inside the automation write root when there is one.
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
let dir = dir.as_ref();
crate::file_access::check_write(dir).map_err(EngineError::Io)?;
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))
}
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
pub(crate) fn write_path(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> Result<()> {
let path = path.as_ref();
crate::file_access::check_write(path).map_err(EngineError::Io)?;
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
}
/// A relative `Audio Files/` path no other source in `s` already uses.
fn unique_audio_rel(s: &Session, stem: &str) -> String {
let stem = sanitize_name(stem);
let (plain, prefix) = (format!("Audio Files/{stem}.wav"), format!("Audio Files/{stem}-"));
let used: std::collections::HashSet<u64> = s
.sources
.iter()
.filter_map(|src| {
if src.path == plain {
return Some(1);
}
let n = src.path.strip_prefix(&prefix)?.strip_suffix(".wav")?;
n.parse().ok().filter(|i: &u64| *i > 1 && i.to_string() == n)
})
.collect();
match (1..).find(|i| !used.contains(i)) {
Some(n) if n > 1 => format!("{prefix}{n}.wav"),
_ => plain,
}
}
/// Register decoded audio as a new source. Resamples to the session rate if needed.
pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Option<&str>, format: FileFormat) -> SourceId {
let sr = s.sample_rate.hz();
if buf.sample_rate != sr && buf.sample_rate > 0 {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr);
buf.sample_rate = sr;
}
let id = SourceId(s.alloc());
let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or(name).to_string();
let stored = match path {
Some(p) => p.to_string(),
None => unique_audio_rel(s, &stem),
};
s.sources.push(Source {
id,
name: stem.clone(),
path: stored,
channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX),
frames: buf.frames() as u64,
sample_rate: sr,
format,
time_reference: 0,
unsaved: true,
});
s.pool.insert(id, Arc::new(SourceAudio::new(buf)));
id
}
/// A BWF time stamp (samples at the file's rate) at the session's rate, as the audio is resampled.
fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 {
if file_rate == 0 || file_rate == session_rate {
return stamp;
}
let (from, to) = (u128::from(file_rate), u128::from(session_rate));
u64::try_from((u128::from(stamp) * to + from / 2) / from).unwrap_or(u64::MAX)
}
/// Decode and place an audio file. With no target track, a new track of matching width is made.
pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES)
.ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
if buf.frames() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
let channels = buf.num_channels();
let file_rate = info.sample_rate;
let s = e.session_mut();
let session_rate = s.sample_rate.hz();
let src = add_source(s, name, buf, path, info.format);
if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) {
sx.time_reference = stamp_at_rate(bwf.time_reference, file_rate, session_rate);
}
let frames = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0));
let track = match target {
Some(t) => t,
None => {
let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or("Audio").to_string();
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem))
}
};
let cid = s.new_clip_id();
let stem = s.source(src).map(|x| x.name.clone()).unwrap_or_default();
crate::edit::place_clip(s, track, Clip::audio(cid, stem, src, 0, at, frames));
Ok(json!({"track": track, "clip": cid, "source": src, "frames": frames, "channels": channels}))
}
/// Loads an audio file's bytes into an instrument track's Sampler as one undo step ("Load
/// Sampler Sample"): for files that have no path, such as one dropped into the browser. The same
/// checks as `mix.sampler_load`; not journaled, since the bytes can be large.
pub fn load_sampler_bytes(e: &mut Engine, track: TrackId, name: &str, bytes: &[u8]) -> Result<Value> {
let before = e.session_arc();
let r = crate::cmd::sampler::attach(e, track, name, bytes, None, Vec::new())?;
e.push_undo("Load Sampler Sample", before);
Ok(r)
}
/// Import a movie: a Video track clip spanning the picture (the movie is referenced by `path`,
/// never copied) plus its audio on a new audio track when it has a usable audio stream. Fails
/// only when the file has neither a readable picture nor audio.
pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Samples) -> Result<Value> {
let stem = Path::new(path).file_stem().and_then(OsStr::to_str).unwrap_or("Video").to_string();
let ext = Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("mp4").to_ascii_lowercase();
let bytes: Arc<[u8]> = bytes.into();
let movie = soundcraft_video::Movie::open(Arc::clone(&bytes)).map(|m| m.info().clone());
let audio_name = format!("{stem} audio.{}", if ext == "mov" { "mp4" } else { ext.as_str() });
let audio = import_audio_bytes(e, &audio_name, &bytes, Some(path), None, at);
let info = match (movie, &audio) {
(Ok(info), _) => info,
(Err(verr), Ok(a)) => {
e.message(format!("{stem}: imported the audio; no picture ({verr})"));
return Ok(json!({"audio": a, "video": Value::Null, "video_error": verr.to_string()}));
}
(Err(verr), Err(aerr)) => return Err(EngineError::Io(format!("{path}: {verr}; no usable audio stream ({aerr})"))),
};
let abs = std::fs::canonicalize(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned());
let s = e.session_mut();
let sr = s.sample_rate.as_f64();
let len = (info.duration * sr).round();
let len = if len.is_finite() { (len as Samples).clamp(1, sr as Samples * 3600 * 48) } else { 1 };
let vid = SourceId(s.alloc());
s.videos.push(soundcraft_model::VideoSource {
id: vid,
name: Path::new(path).file_name().and_then(OsStr::to_str).unwrap_or("movie").to_string(),
path: abs,
width: info.width,
height: info.height,
frame_rate: info.frame_rate,
duration: info.duration,
codec: info.codec.clone(),
offline: None,
});
// Reuse the first Video track when the new clip does not overlap its clips; else add one at the top.
let range = Range::new(at, at.saturating_add(len));
let track = match s.tracks.iter().find(|t| t.kind == TrackKind::Video) {
Some(t) if !t.clips().iter().any(|c| c.range().overlaps(&range)) => t.id,
_ => {
let id = s.add_track(TrackKind::Video, ChannelFormat::Mono, Some(&stem));
if let Some(i) = s.track_index(id) {
let t = s.tracks.remove(i);
s.tracks.insert(0, t);
}
id
}
};
let cid = s.new_clip_id();
crate::edit::place_clip(s, track, Clip::video(cid, stem.clone(), vid, 0, at, len));
if !info.decodable {
e.message(format!("{stem}: {} pictures cannot be shown yet; the Video track shows the clip only", info.codec_detail));
}
if let Err(err) = &audio {
e.message(format!("{stem}: no usable audio stream ({err})"));
}
Ok(json!({
"video": {"track": track, "clip": cid, "source": vid, "length": len, "info": info},
"audio": audio.as_ref().ok(),
"audio_error": audio.as_ref().err().map(ToString::to_string),
}))
}
/// Resolve each movie referenced by the session file `session` ([`stored_path`]): keep the
/// resolved path when its file exists, else look for a file of the same name next to the session
/// (moved projects) and relink. A movie whose stored path is refused and has no such copy is
/// marked offline. Returns the movies still missing.
pub fn relink_videos(s: &mut Session, session: &Path) -> Vec<String> {
let session_dir = session.parent().unwrap_or(Path::new(""));
let mut missing = Vec::new();
for v in &mut s.videos {
v.offline = None;
let p = stored_path(&v.path, session);
// The UI opens `path` as it is: keep the checked path, not one relative to the working
// directory.
if let Ok(found) = &p
&& file_found(found)
{
v.path = found.to_string_lossy().into_owned();
continue;
}
let name = Path::new(&v.path).file_name().map(PathBuf::from).unwrap_or_default();
let near = [session_dir.join(&name), session_dir.join("Video Files").join(&name)]
.into_iter()
.find(|c| !name.as_os_str().is_empty() && file_found(c));
match (near, p) {
(Some(c), _) => v.path = c.to_string_lossy().into_owned(),
(None, Ok(p)) => missing.push(format!("{} (video)", p.display())),
(None, Err(why)) => {
missing.push(format!("{} (video): {why}", v.path));
v.offline = Some(why);
}
}
}
missing
}
/// Most meter changes a MIDI file may bring into the tempo map; scores have a handful.
const MAX_MIDI_METERS: usize = 10_000;
/// Import a Standard MIDI File as MIDI tracks (one per SMF track).
pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: bool) -> Result<Vec<TrackId>> {
let smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?;
let s = e.session_mut();
if tempo_map {
// Each meter is placed by walking the ones before it.
if smf.meters.len() > MAX_MIDI_METERS {
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_METERS} meter changes")));
}
for (tick, bpm) in &smf.tempos {
let _ = s.tempo.set_tempo(*tick, *bpm);
}
for (tick, n, d) in &smf.meters {
let _ = s.tempo.set_meter(*tick, u32::from(*n), u32::from(*d));
}
}
let base_tick = s.tempo.samples_to_ticks(at, s.sample_rate);
let mut out = Vec::new();
for (i, tr) in smf.tracks.iter().enumerate() {
if tr.sequence.notes.is_empty() && tr.sequence.ctrls.is_empty() {
continue;
}
let mut name = if tr.name.trim().is_empty() { format!("MIDI {}", i + 1) } else { tr.name.clone() };
soundcraft_model::truncate_chars(&mut name, soundcraft_model::MAX_NAME_CHARS);
let id = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some(&name));
let drums = !tr.sequence.notes.is_empty() && tr.sequence.notes.iter().all(|n| n.channel == 9);
if let Some(t) = s.track_mut(id) {
t.instrument = Some(soundcraft_model::Insert::new(if drums { "drum_synth" } else { "subtractive_synth" }));
}
// A controller on the clip's last tick would not play, so the clip runs one tick past the last one.
let past_ctrls = tr.sequence.ctrls.iter().map(|c| c.tick.saturating_add(1)).max().unwrap_or(0);
let end_tick = base_tick.saturating_add(tr.sequence.end_tick().max(past_ctrls).max(1));
let len = s.tempo.tick_to_samples(end_tick, s.sample_rate).saturating_sub(at);
if len > s.sample_rate.samples(86_400.0) {
return Err(EngineError::Io(format!("MIDI track {}: longer than 24 hours", i + 1)));
}
let cid = s.new_clip_id();
crate::edit::place_clip(s, id, Clip::midi(cid, name, at, len.max(1), tr.sequence.clone()));
out.push(id);
}
Ok(out)
}
/// Export MIDI clips as a format-1 SMF. `only` limits the file to those tracks.
pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
let s = e.session();
let mut smf = soundcraft_midi::Smf {
format: 1,
tracks: Vec::new(),
tempos: s.tempo.tempos().iter().map(|t| (t.tick, t.bpm)).collect(),
meters: s.tempo.meters().iter().map(|m| (m.tick, u8::try_from(m.numerator).unwrap_or(4), u8::try_from(m.denominator).unwrap_or(4))).collect(),
markers: s.markers.iter().map(|m| (s.tempo.samples_to_ticks(m.start, s.sample_rate), m.name.clone())).collect(),
key_sigs: Vec::new(),
};
let chosen: Vec<_> = s.tracks.iter().filter(|t| t.kind.is_midi() && only.is_none_or(|ids| ids.contains(&t.id))).collect();
if only.is_some() && chosen.is_empty() {
return Err(EngineError::BadParams("file.export_midi".into(), "none of the requested tracks hold MIDI".into()));
}
for t in chosen {
let mut seq = soundcraft_midi::Sequence::default();
for c in t.clips() {
if let ClipContent::Midi { sequence } = &c.content {
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
for n in &sequence.notes {
let mut n = *n;
n.start = n.start.saturating_add(base);
seq.notes.push(n);
}
for ev in &sequence.ctrls {
let mut ev = *ev;
ev.tick = ev.tick.saturating_add(base);
seq.ctrls.push(ev);
}
}
}
seq.sort();
smf.tracks.push(soundcraft_midi::SmfTrack { name: t.name.clone(), sequence: seq });
}
soundcraft_midi::write_smf(&smf).map_err(|err| EngineError::Io(err.to_string()))
}
/// A bounce of the main mix, or of one bus, over a range, written to its file block by block so no
/// bounce has to fit in memory, and rendered a slice at a time ([`Bounce::step`]) so a UI can show
/// progress. The file appears at its path only once complete; a bounce that fails, or is dropped
/// unfinished, leaves any older file there alone.
pub struct Bounce {
session: Arc<Session>,
path: PathBuf,
part: PathBuf,
range: Range,
bus: Option<BusId>,
fold: Option<ChannelFormat>,
normalize: bool,
/// Measuring the peak to normalize to, the first of two passes.
measuring: bool,
render: soundcraft_mix::RangeRender,
block: Vec<Vec<f32>>,
done: usize,
gain: f32,
peak: f32,
meter: soundcraft_dsp::meter::LoudnessMeter,
enc: Option<soundcraft_audio_io::FileEncoder<Out>>,
}
/// A finished bounce: the size of its file and its levels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bounced {
pub bytes: u64,
pub peak_db: f32,
pub lufs: f32,
pub true_peak_db: f32,
}
impl Bounce {
/// Starts a bounce of `s`'s main mix, or of `bus`, over `r` to `path`: every channel (SMPTE/WAV
/// order, with the WAV speaker mask of its format), or an ITU stereo fold-down when
/// `fold_stereo`, peak-normalized to -0.1 dBFS when `normalize` (which renders twice).
pub fn new(
s: Arc<Session>,
path: &Path,
r: Range,
opts: &EncodeOptions,
bus: Option<BusId>,
normalize: bool,
fold_stereo: bool,
) -> Result<Bounce> {
if r.len() > s.sample_rate.samples(MAX_BOUNCE_SECS) {
return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into()));
}
let (fmt, channels, fold) = bounce_layout(&s, bus, fold_stereo);
let name = path.file_name().map_or_else(|| "bounce".into(), |n| n.to_string_lossy().into_owned());
let part = path.with_file_name(format!("{name}.part"));
crate::file_access::check_write(path).map_err(EngineError::Io)?;
crate::file_access::check_write(&part).map_err(EngineError::Io)?;
let enc = (|| -> std::result::Result<_, soundcraft_audio_io::AudioError> {
let enc = soundcraft_audio_io::FileEncoder::new(Out::create(&part)?, opts, channels, s.sample_rate.hz(), Some(fmt.channel_mask()))?;
enc.check_room(u64::try_from(r.len()).unwrap_or(0))?;
Ok(enc)
})()
.map_err(|err| {
let _ = std::fs::remove_file(&part);
EngineError::Io(format!("{}: {err}", path.display()))
})?;
let render = soundcraft_mix::RangeRender::new(&s, bus, r, 1024);
let meter = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, channels);
Ok(Bounce {
session: s,
path: path.to_path_buf(),
part,
range: r,
bus,
fold,
normalize,
measuring: normalize,
render,
block: Vec::new(),
done: 0,
gain: 1.0,
peak: 0.0,
meter,
enc: Some(enc),
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn range(&self) -> Range {
self.range
}
/// How far along the bounce is, from 0 to 1.
pub fn progress(&self) -> f32 {
let pass = (self.done as f64 / self.range.len().max(1) as f64).min(1.0) as f32;
match (self.normalize, self.measuring) {
(false, _) => pass,
(true, true) => pass / 2.0,
(true, false) => 0.5 + pass / 2.0,
}
}
/// Renders blocks until `more` returns false (at least one per call). Returns the finished
/// bounce once its file is complete.
pub fn step(&mut self, mut more: impl FnMut() -> bool) -> Result<Option<Bounced>> {
loop {
match self.render.next_block(&self.session) {
Some((chunk, n)) => {
fill(&mut self.block, chunk, n, self.fold, self.gain);
self.done += n;
self.peak = self.peak.max(soundcraft_dsp::offline::peak(&self.block));
if !self.measuring {
self.meter.process(&self.block, n);
if let Some(enc) = &mut self.enc {
enc.write(&self.block, n).map_err(|err| EngineError::Io(format!("{}: {err}", self.path.display())))?;
}
}
}
None if self.measuring => {
self.gain = if self.peak > 0.0 { soundcraft_dsp::db_to_gain(-0.1) / self.peak } else { 1.0 };
self.peak = 0.0;
self.done = 0;
self.measuring = false;
self.render = soundcraft_mix::RangeRender::new(&self.session, self.bus, self.range, 1024);
}
None => return self.finish().map(Some),
}
if !more() {
return Ok(None);
}
}
}
/// Renders the whole bounce (see [`Bounce::step`]).
pub fn run(mut self) -> Result<Bounced> {
loop {
if let Some(done) = self.step(|| true)? {
return Ok(done);
}
}
}
fn finish(&mut self) -> Result<Bounced> {
let bytes = match self.enc.take() {
Some(enc) => enc.finish().map_err(|err| EngineError::Io(format!("{}: {err}", self.path.display())))?.complete(&self.part, &self.path)?,
None => 0,
};
Ok(Bounced {
bytes,
peak_db: soundcraft_dsp::gain_to_db(self.peak),
lufs: self.meter.integrated_lufs(),
true_peak_db: self.meter.true_peak_db(),
})
}
}
impl Drop for Bounce {
/// Removes the part file unless the bounce completed (which renamed it).
fn drop(&mut self) {
drop(self.enc.take());
let _ = std::fs::remove_file(&self.part);
}
}
/// Where a bounce is written: its part file, or memory in the browser, which has no file system.
enum Out {
File(std::io::BufWriter<std::fs::File>),
Memory(std::io::Cursor<Vec<u8>>),
}
impl Out {
fn create(part: &Path) -> std::io::Result<Out> {
if cfg!(target_arch = "wasm32") {
return Ok(Out::Memory(std::io::Cursor::new(Vec::new())));
}
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, std::fs::File::create(part)?)))
}
/// Puts the finished file at `path` and returns its size: the part file is synced and renamed
/// over it; in the browser the bytes are delivered ([`write_file`]).
fn complete(self, part: &Path, path: &Path) -> Result<u64> {
match self {
Out::File(out) => {
let mut len = 0;
let synced = out.into_inner().map_err(std::io::IntoInnerError::into_error).and_then(|f| {
f.sync_all()?;
len = f.metadata()?.len();
Ok(())
});
replace_with(part, path, synced).map(|()| len)
}
Out::Memory(m) => {
let bytes = m.into_inner();
write_file(&path.to_string_lossy(), &bytes)?;
Ok(bytes.len() as u64)
}
}
}
}
impl std::io::Write for Out {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
Out::File(f) => f.write(buf),
Out::Memory(m) => {
// A long bounce may not fit in the browser's memory: fail instead of aborting.
let end = usize::try_from(m.position()).unwrap_or(usize::MAX).saturating_add(buf.len());
let v = m.get_mut();
v.try_reserve(end.saturating_sub(v.len())).map_err(|_| std::io::ErrorKind::OutOfMemory)?;
m.write(buf)
}
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Out::File(f) => f.flush(),
Out::Memory(_) => Ok(()),
}
}
}
impl std::io::Seek for Out {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
match self {
Out::File(f) => f.seek(pos),
Out::Memory(m) => m.seek(pos),
}
}
}
const MAX_BOUNCE_SECS: f64 = 4.0 * 3600.0;
/// The format, channel count and fold-down source of a bounce of the main mix or `bus`: every
/// channel (a mono or stereo main mix as stereo), or an ITU stereo fold-down of a surround source
/// when `fold_stereo`.
fn bounce_layout(s: &Session, bus: Option<BusId>, fold_stereo: bool) -> (ChannelFormat, usize, Option<ChannelFormat>) {
let nch = soundcraft_mix::source_channels(s, bus);
let fmt = match bus {
Some(id) => s.bus(id).map_or(ChannelFormat::Stereo, |b| b.format),
None if nch <= 2 => ChannelFormat::Stereo,
None => s.main_format(),
};
if fold_stereo && nch > 2 { (ChannelFormat::Stereo, 2, Some(fmt)) } else { (fmt, nch, None) }
}
/// The first `n` frames of `chunk`, folded down from `fold` when set and scaled by `gain`.
fn fill(block: &mut Vec<Vec<f32>>, chunk: &[Vec<f32>], n: usize, fold: Option<ChannelFormat>, gain: f32) {
let folded = fold.map(|from| soundcraft_mix::fold_down_stereo(from, chunk));
let src = folded.as_deref().unwrap_or(chunk);
block.resize_with(src.len(), Vec::new);
for (d, c) in block.iter_mut().zip(src) {
d.clear();
d.extend(c.iter().take(n).map(|v| v * gain));
}
}
/// Bounce one file per audio/instrument track (each soloed, so its sends and auxes are included)
/// into `dir`. Returns the written paths.
pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Result<Vec<String>> {
bounce_stems_with(e, dir, r, opts, false)
}
/// [`bounce_stems`] in the main format, or folded down to stereo when `fold_stereo`.
pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, fold_stereo: bool) -> Result<Vec<String>> {
let s = e.session();
if r.len() > s.sample_rate.samples(MAX_BOUNCE_SECS) {
return Err(EngineError::BadParams("file.bounce_stems".into(), "bounces are limited to 4 hours".into()));
}
make_dir(dir)?;
let ext = match opts.format {
FileFormat::Aiff => "aif",
FileFormat::Flac => "flac",
_ => "wav",
};
let mut out = Vec::new();
let stems: Vec<(TrackId, String)> = s
.tracks
.iter()
.filter(|t| matches!(t.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Midi) && !t.inactive && !t.mixer.mute)
.map(|t| (t.id, t.name.clone()))
.collect();
for (id, name) in stems {
let mut solo = s.clone();
for t in &mut solo.tracks {
t.mixer.solo = t.id == id;
}
let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name)));
Bounce::new(Arc::new(solo), &path, r, opts, None, false, fold_stereo)?.run()?;
out.push(path.to_string_lossy().into_owned());
}
Ok(out)
}
/// Limit a range used for rendering to the track's material (plus a second), so a huge
/// selection never renders hours of silence.
pub fn bounded_to_track(s: &Session, t: TrackId, r: Range) -> Range {
let Some(tr) = s.track(t) else { return Range::point(r.start) };
let end = tr.clips().iter().map(Clip::end).max().unwrap_or(0).saturating_add(s.sample_rate.samples(1.0));
let start = r.start.max(0).min(end);
Range::new(start, r.end.min(end).max(start))
}
/// Transient positions (absolute) in a track's clips within `r`.
pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec<Samples> {
let r = if r.is_empty() { Range::new(0, s.content_end()) } else { r };
let r = bounded_to_track(s, t, r);
let audio = soundcraft_mix::render_clips(s, t, r);
soundcraft_dsp::offline::detect_transients(&audio, s.sample_rate.as_f64() as f32, sens).into_iter().map(|i| r.start + i as i64).collect()
}
/// Strip Silence: keep only the non-silent ranges of clips inside `r`.
pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result<usize> {
let r = bounded_to_track(s, t, r);
let keep = non_silent(s, t, r, thr, min_len, pre, post)?;
let mut cut_points = vec![r.start];
for (a, b) in &keep {
cut_points.push(*a);
cut_points.push(*b);
}
cut_points.push(r.end);
for p in &cut_points {
crate::edit::separate_at(s, t, *p);
}
// Remove pieces that fall in the gaps.
let mut gaps = Vec::new();
let mut prev = r.start;
for (a, b) in &keep {
if *a > prev {
gaps.push(Range::new(prev, *a));
}
prev = *b;
}
if prev < r.end {
gaps.push(Range::new(prev, r.end));
}
if let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) {
pl.clips.retain(|c| !gaps.iter().any(|g| c.start >= g.start && c.end() <= g.end));
}
Ok(keep.len())
}
/// Most samples (over all channels) Strip Silence renders at once.
const STRIP_WINDOW_SAMPLES: usize = 1 << 24;
/// The non-silent ranges of `t`'s clips in `r`. The audio is rendered a window at a time where
/// audio clips play, so all of `r` is scanned however long it is (one render stops at
/// `MAX_RENDER_SAMPLES`); at most a day of clips, Pro Tools' longest session.
fn non_silent(s: &Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result<Vec<(Samples, Samples)>> {
let Some(tr) = s.track(t) else { return Ok(Vec::new()) };
let mut clips: Vec<Range> = tr.clips().iter().filter(|c| c.is_audio() && !c.muted).filter_map(|c| c.range().intersect(&r)).collect();
clips.sort_by_key(|x| x.start);
let mut spans: Vec<Range> = Vec::new();
for x in clips {
match spans.last_mut() {
Some(last) if x.start <= last.end => last.end = last.end.max(x.end),
_ => spans.push(x),
}
}
if spans.iter().map(Range::len).fold(0, i64::saturating_add) > s.sample_rate.samples(86_400.0) {
return Err(EngineError::BadParams("edit.strip_silence".into(), format!("`{}` has more than a day of clips to scan", tr.name)));
}
let window = i64::try_from(STRIP_WINDOW_SAMPLES / tr.channels().max(1)).unwrap_or(i64::MAX);
let len = |n: Samples| u64::try_from(n).unwrap_or(0);
let mut scan = soundcraft_dsp::offline::SilenceScan::new(thr, len(min_len));
let mut at = r.start;
for x in spans {
scan.skip(len(x.start - at));
at = x.start;
while at < x.end {
let end = at.saturating_add(window).min(x.end);
scan.push(&soundcraft_mix::render_clips(s, t, Range::new(at, end)));
at = end;
}
}
scan.skip(len(r.end - at));
Ok(scan.finish(len(pre), len(post)).into_iter().map(|(a, b)| (r.start.saturating_add_unsigned(a), r.start.saturating_add_unsigned(b))).collect())
}
/// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the
/// whole range, silence included; a range too long to render is refused rather than shortened.
pub fn consolidate(e: &mut Engine, cmd: &str, t: TrackId, r: Range) -> Result<bool> {
let s = e.session();
let Some(tr) = s.track(t).filter(|tr| tr.kind.has_playlist() && !tr.kind.is_midi()) else { return Ok(false) };
let start = r.start.max(0);
let len = r.end.saturating_sub(start);
if len <= 0 {
return Ok(false);
}
let max = soundcraft_mix::MAX_RENDER_SAMPLES / tr.channels().max(1);
if usize::try_from(len).map_or(true, |n| n > max) {
let secs = max as f64 / s.sample_rate.as_f64();
return Err(crate::cmd::bad(cmd, format!("selection is too long to consolidate on `{}` (at most {secs:.0} s)", tr.name)));
}
let r = Range::new(start, start + len);
let audio = soundcraft_mix::render_clips(s, t, r);
if audio.iter().any(|ch| ch.len() as i64 != len) {
return Err(EngineError::Internal(cmd.to_string(), "consolidate rendered the wrong length".into()));
}
let name = format!("{}_consolidated", tr.name);
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
let s = e.session_mut();
let src = add_source(s, &name, buf, None, FileFormat::Wav);
crate::edit::clear_range(s, t, r, false);
let cid = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::audio(cid, name, src, 0, r.start, r.len()));
Ok(true)
}
/// The most frames of `channels`-channel audio one offline render holds.
pub fn render_limit(channels: usize) -> usize {
soundcraft_mix::MAX_RENDER_SAMPLES / channels.max(1)
}
/// Refuses `len` frames of `name` past `max`, where a render would cut the audio short.
pub fn refuse_longer(s: &Session, cmd: &str, name: &str, verb: &str, len: Samples, max: usize) -> Result<()> {
if len <= Samples::try_from(max).unwrap_or(Samples::MAX) {
return Ok(());
}
let secs = max as f64 / s.sample_rate.as_f64();
Err(crate::cmd::bad(cmd, format!("`{name}` is too long to {verb} (at most {secs:.0} s)")))
}
/// Render clip gain into a new source and reset the clip's gain.
pub fn render_clip_gain(e: &mut Engine, cmd: &str, id: soundcraft_model::ClipId) -> Result<bool> {
let s = e.session();
let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { return Ok(false) };
if !c.is_audio() {
return Ok(false);
}
refuse_longer(s, cmd, &c.name, "render", c.length, render_limit(s.track(t).map_or(1, |x| x.channels())))?;
let mut c2 = c.clone();
c2.fade_in = Default::default();
c2.fade_out = Default::default();
let mut tmp = s.clone();
if let Some(pl) = tmp.track_mut(t).and_then(|x| x.playlist_mut()) {
pl.clips = vec![c2];
}
let audio = soundcraft_mix::render_clips(&tmp, t, c.range());
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
let s = e.session_mut();
let src = add_source(s, &format!("{}_gain", c.name), buf, None, FileFormat::Wav);
if let Some(cl) = s.find_clip_mut(id) {
cl.content = ClipContent::Audio { source: src, offset: 0 };
cl.gain_db = 0.0;
cl.gain_env.clear();
// The new source is the clip as it already sounds, stretch included.
// Keeping the old ratio would play that audio through the stretch again.
cl.stretch = 1.0;
}
Ok(true)
}
/// Commit a track's inserts into a new audio track (Track › Commit).
pub fn commit_track(e: &mut Engine, t: TrackId) -> Result<Option<TrackId>> {
let s = e.session();
let Some(tr) = s.track(t) else { return Ok(None) };
if !tr.kind.is_audio_path() || tr.kind == TrackKind::Master {
return Ok(None);
}
let end = tr.clips().iter().map(Clip::end).max().unwrap_or(0);
if end <= 0 {
return Ok(None);
}
let range = Range::new(0, end + s.sample_rate.samples(2.0));
// The track renders through a main mix of at least two channels.
refuse_longer(s, "track.commit", &tr.name, "commit", range.len(), render_limit(tr.channels().max(2)))?;
let audio = soundcraft_mix::render_track_pre_fader(s, t, range);
let name = format!("{}.cm", tr.name);
let fmt = ChannelFormat::for_channels(audio.len().clamp(1, 2));
let mixer = tr.mixer.clone();
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
let s = e.session_mut();
let src = add_source(s, &name, buf, None, FileFormat::Wav);
let nt = s.add_track(TrackKind::Audio, fmt, Some(&name));
if let (Some(idx), Some(new_idx)) = (s.track_index(t), s.track_index(nt)) {
let tr = s.tracks.remove(new_idx);
s.tracks.insert((idx + 1).min(s.tracks.len()), tr);
}
let cid = s.new_clip_id();
if let Some(ntr) = s.track_mut(nt) {
ntr.mixer.volume_db = mixer.volume_db;
ntr.mixer.output = mixer.output.clone();
ntr.mixer.sends = mixer.sends.clone();
if let Some(pl) = ntr.playlist_mut() {
pl.clips.push(Clip::audio(cid, name, src, 0, 0, range.len()));
}
}
if let Some(old) = s.track_mut(t) {
old.inactive = true;
old.hidden = true;
}
Ok(Some(nt))
}
pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> Result<usize> {
let s = e.session();
make_dir(dir)?;
let mut n = 0;
for id in ids {
let Some((t, c)) = s.find_clip(*id) else { continue };
let audio = soundcraft_mix::render_clips(s, t, c.range());
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?;
let stem = sanitize_name(&c.name);
let mut path = Path::new(dir).join(format!("{stem}.wav"));
let mut k = 2u32;
while path.exists() {
path = Path::new(dir).join(format!("{stem}-{k}.wav"));
k = k.saturating_add(1);
if k > 10_000 {
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
}
}
write_path(&path, bytes)?;
n += 1;
}
Ok(n)
}
pub fn sanitize_name(n: &str) -> String {
let s: String = n.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect();
let s = s.trim().trim_start_matches('.').to_string();
if s.is_empty() { "untitled".into() } else { s.chars().take(100).collect() }
}
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
/// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written.
///
/// In the browser there are no folders: the session and all its audio go out as one bundle file
/// (`<name>.scraftz`, see [`session_bundle`]) through the host's delivery hook ([`set_deliver`]).
pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
if cfg!(target_arch = "wasm32") {
return save_session_bundle(e, path);
}
let (fresh, written, p) = write_session(e.session(), path, false)?;
// Keep the in-memory document in sync (paths, unsaved flags) without an undo step.
*e.session_mut() = fresh;
e.path = Some(p.to_string_lossy().into_owned());
e.mark_clean();
Ok(written)
}
/// Write a copy of the session (and any audio it needs) without touching the open document —
/// used for autosave / crash recovery. Audio already written for a source is reused.
pub fn save_copy(e: &Engine, path: &str) -> Result<usize> {
write_session(e.session(), path, true).map(|(_, n, _)| n)
}
/// File › Save Session Copy In: a normal session folder that does not rename, retarget or
/// clean the session that stays open.
pub fn save_session_copy(e: &Engine, path: &str) -> Result<usize> {
write_session(e.session(), path, false).map(|(_, n, _)| n)
}
/// Write `session` to `path`; returns the session with updated media paths, the number of audio
/// files written and the final path.
fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> {
use std::collections::{HashMap, HashSet};
let p = session_file(path);
// Before any audio is written: a session saved outside the write root changes nothing.
crate::file_access::check_write(&p).map_err(EngineError::Io)?;
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
let audio_dir = dir.join("Audio Files");
let mut s = session.clone();
let from = s.folder.clone();
let same_dir = from.as_deref().is_some_and(|f| same_path(f, &dir));
let mut written = 0;
// A file can stay put for all the sources that name it, unless their audio differs.
let shared_paths: Vec<bool> = {
let mut audio_by_path: HashMap<&str, HashSet<Option<*const SourceAudio>>> = HashMap::new();
for src in &s.sources {
audio_by_path.entry(&src.path).or_default().insert(s.pool.get(src.id).map(Arc::as_ptr));
}
s.sources.iter().map(|src| audio_by_path.get(src.path.as_str()).is_some_and(|a| a.len() > 1)).collect()
};
// Files written for audio during this save: sources playing the same audio share one.
let mut files: HashMap<*const SourceAudio, String> = HashMap::new();
for (i, src) in s.sources.iter_mut().enumerate() {
if !same_dir
&& s.pool.get(src.id).is_none()
&& Path::new(&src.path).is_relative()
&& let Some(f) = &from
{
src.path = f.join(&src.path).to_string_lossy().into_owned();
}
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
let shared = shared_paths.get(i).copied().unwrap_or(false);
if exists_rel && !src.unsaved && !shared {
continue;
}
let in_folder = Path::new(&src.path).strip_prefix(&audio_dir).ok().and_then(Path::to_str).map(str::to_string);
if let Some(f) = in_folder.filter(|_| !src.unsaved && Path::new(&src.path).is_file()) {
src.path = format!("Audio Files/{f}");
continue;
}
let Some(audio) = s.pool.get(src.id) else { continue };
if let Some(path) = files.get(&Arc::as_ptr(audio)) {
src.path.clone_from(path);
src.unsaved = false;
src.format = FileFormat::Wav;
continue;
}
make_dir(&audio_dir)?;
let mut stem = sanitize_name(&src.name);
if overwrite {
// One file per source id so autosaves never collide or pile up.
stem = format!("{stem}_{}", src.id.0);
if audio_dir.join(format!("{stem}.wav")).exists() {
src.path = format!("Audio Files/{stem}.wav");
src.unsaved = false;
files.insert(Arc::as_ptr(audio), src.path.clone());
continue;
}
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
let (name, new) = store_wav(&audio_dir, &stem, &bytes)?;
src.path = format!("Audio Files/{name}");
src.unsaved = false;
src.format = FileFormat::Wav;
files.insert(Arc::as_ptr(audio), src.path.clone());
written += usize::from(new);
}
if written > 0 {
sync_dir(&audio_dir);
}
s.folder = Some(dir.clone());
if !overwrite && let Some(stem) = p.file_stem().and_then(OsStr::to_str) {
s.name = stem.to_string();
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
make_dir(&dir)?;
// Atomic-ish write: temp file then rename.
let tmp = p.with_extension("scraft.tmp");
if !overwrite && read_file(&p).is_ok_and(|old| old != text.as_bytes()) {
back_up(&p);
}
replace_file(&tmp, &p, text.as_bytes())?;
Ok((s, written, p))
}
/// Write `bytes` to `tmp`, flush it to disk and rename it over `file`, so `file` holds either the
/// old or the new contents, never a mix.
pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> {
crate::file_access::check_write(file).map_err(EngineError::Io)?;
crate::file_access::check_write(tmp).map_err(EngineError::Io)?;
replace_with(tmp, file, write_synced(tmp, bytes))
}
/// Renames `tmp` over `file` once it is `written`, or removes it when writing or renaming failed.
/// The caller has checked both paths ([`crate::file_access::check_write`]).
fn replace_with(tmp: &Path, file: &Path, written: std::io::Result<()>) -> Result<()> {
if let Err(err) = written.and_then(|()| std::fs::rename(tmp, file)) {
let _ = std::fs::remove_file(tmp);
return Err(EngineError::Io(format!("{}: {err}", file.display())));
}
sync_dir(file.parent().unwrap_or(Path::new("")));
Ok(())
}
fn sync_dir(dir: &Path) {
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
let _ = std::fs::File::open(dir).and_then(|d| d.sync_all());
}
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
f.sync_all()
}
/// Copies `from` to `to`, which must not exist yet; an existing entry, even a link, is never followed.
fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
let mut src = std::fs::File::open(from)?;
let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?;
if let Err(err) = std::io::copy(&mut src, &mut dst) {
drop(dst);
let _ = std::fs::remove_file(to);
return Err(err);
}
Ok(())
}
/// Copy the session file about to be replaced to `Backups/<name> backup <n>.<ext>`, keeping the
/// newest ten.
fn back_up(file: &Path) {
let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(OsStr::to_str), file.extension().and_then(OsStr::to_str))
else {
return;
};
let backups = dir.join("Backups");
if let Err(err) = crate::file_access::check_write(&backups) {
log::warn!("no backup of {}: {err}", file.display());
return;
}
let prefix = format!("{stem} backup ");
let suffix = format!(".{ext}");
let numbers: Vec<u32> = std::fs::read_dir(&backups)
.into_iter()
.flatten()
.flatten()
.filter_map(|f| f.file_name().to_str()?.strip_prefix(&prefix)?.strip_suffix(&suffix)?.parse().ok())
.collect();
let Some(next) = numbers.iter().max().map_or(Some(1), |n| n.checked_add(1)) else {
log::warn!("no backup of {}: backup numbers are exhausted", file.display());
return;
};
let dest = backups.join(format!("{prefix}{next}{suffix}"));
if let Err(err) = crate::file_access::check_write(&dest) {
log::warn!("no backup of {}: {err}", file.display());
return;
}
if let Err(err) = std::fs::create_dir_all(&backups).and_then(|()| copy_new(file, &dest)) {
log::warn!("no backup of {}: {err}", file.display());
return;
}
for n in numbers.into_iter().filter(|n| n.saturating_add(10) <= next) {
let _ = std::fs::remove_file(backups.join(format!("{prefix}{n}{suffix}")));
}
}
/// The file a session saved to `path` goes to: `path`, with `.scraft` added when it has no
/// extension and isn't an existing file.
pub fn session_file(path: &str) -> PathBuf {
let p = PathBuf::from(path);
// Only a path the write root allows is probed, so a refused one can't be used to test for files.
let is_file = crate::file_access::check_write(&p).is_ok() && p.is_file();
if p.extension().is_none() && !is_file { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p }
}
/// True when `command` would write a session over the existing `file`. Save, Save As and Save
/// Copy In may write the open session's own file; other commands never ask.
pub fn replaces(e: &Engine, command: &str, file: &Path) -> bool {
match command {
"session.save" | "session.save_as" | "session.save_copy" => {
file.exists() && !e.path.as_deref().is_some_and(|own| same_path(Path::new(own), file))
}
"session.save_template" | "file.export_selected_tracks_as_session" => file.exists(),
_ => false,
}
}
fn same_path(a: &Path, b: &Path) -> bool {
let full = |p: &Path| if p.as_os_str().is_empty() { Path::new(".").canonicalize() } else { p.canonicalize() };
a == b || matches!((full(a), full(b)), (Ok(x), Ok(y)) if x == y)
}
/// Store `bytes` as the first of `<stem>.wav`, `<stem>_01.wav`, … in `dir` that is free or already
/// holds exactly these bytes, so saving never replaces audio; returns the name and whether it was
/// written.
fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
use std::io::Write;
for k in 0..10_000u32 {
let name = if k == 0 { format!("{stem}.wav") } else { format!("{stem}_{k:02}.wav") };
let path = dir.join(&name);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut f) => {
if let Err(err) = f.write_all(bytes).and_then(|()| f.sync_all()) {
let _ = std::fs::remove_file(&path);
return Err(EngineError::Io(format!("{}: {err}", path.display())));
}
return Ok((name, true));
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
if std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64) && std::fs::read(&path).is_ok_and(|b| b == bytes) {
return Ok((name, false));
}
}
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
}
}
Err(EngineError::Io(format!("{}: no free file name for {stem}", dir.display())))
}
/// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal).
pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
open_session_linked(e, path, Path::new(path))
}
/// [`open_session`], following the media paths in it as if they were stored in the session file
/// `links` (crash recovery: the session written beside the autosave, its paths from the original).
pub(crate) fn open_session_linked(e: &mut Engine, path: &str, links: &Path) -> Result<Vec<String>> {
let text = read_text(path)?;
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?;
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
let mut missing = Vec::new();
let mut files = DecodedFiles::new();
for src in &s.sources {
let decoded =
stored_path(&src.path, links).map_err(|why| format!("{}: {why}", src.path)).and_then(|p| decode_file(&mut files, &p, s.sample_rate.hz()));
match decoded {
Ok(audio) => s.pool.insert(src.id, audio),
Err(err) => missing.push(err),
}
}
missing.extend(relink_videos(&mut s, links));
s.folder = Some(dir);
e.replace_session(s);
e.path = Some(path.to_string());
for m in &missing {
e.message(format!("missing media: {m}"));
}
Ok(missing)
}
/// Decode one source's audio file at the session rate.
fn decode_source(bytes: &[u8], ext: Option<&str>, rate: u32) -> std::result::Result<Arc<SourceAudio>, String> {
let (_, buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or("too large to load")?;
Ok(Arc::new(SourceAudio::new(buf)))
}
/// Audio files decoded so far, by path: a session may name one file thousands of times.
/// The one name of the file at `p` (links followed), so the same file named two ways is read once.
pub(crate) fn file_key(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
pub(crate) type DecodedFiles = std::collections::HashMap<PathBuf, std::result::Result<Arc<SourceAudio>, String>>;
/// The audio of the file at `p` at `rate`, decoded the first time it is asked for. The error
/// names the file.
/// A file named in a session is hostile input too: a path that leaves the read root is not read.
pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std::result::Result<Arc<SourceAudio>, String> {
crate::file_access::check_read(p)?;
files
.entry(file_key(p))
.or_insert_with(|| {
let bytes = read_file(p).map_err(|_| p.display().to_string())?;
decode_source(&bytes, p.extension().and_then(OsStr::to_str), rate).map_err(|err| format!("{}: {err}", p.display()))
})
.clone()
}
// ─── Hosts without a filesystem (the browser) ─────────────────────────────────────────────────
/// Extension of a session bundle: one zip file holding the `.scraft` session and its
/// `Audio Files/`, the same layout as a saved session folder. The browser has no folders, so it
/// saves and opens sessions as bundles.
pub const BUNDLE_EXTENSION: &str = "scraftz";
/// Most entries and bytes a bundle may unpack to (bundles are hostile input).
const MAX_BUNDLE_ENTRIES: usize = 10_000;
const MAX_BUNDLE_BYTES: u64 = 1 << 30;
/// Hands a finished file (name, bytes) to the person on a host without a filesystem: the web app
/// installs one that starts a download. Native builds write files and never install it.
pub type DeliverHook = fn(&str, &[u8]) -> std::result::Result<(), String>;
static DELIVER: std::sync::OnceLock<DeliverHook> = std::sync::OnceLock::new();
/// Install the delivery hook (once, at start-up of the web app).
pub fn set_deliver(h: DeliverHook) {
let _ = DELIVER.set(h);
}
/// Write one output file: to `path` natively; in the browser, delivered under its file name.
/// Without this, every export on the web failed with «operation not supported on this platform».
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
if cfg!(target_arch = "wasm32") {
let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?;
let name = Path::new(path).file_name().and_then(OsStr::to_str).filter(|n| !n.is_empty()).unwrap_or("Untitled");
return deliver(name, bytes).map_err(EngineError::Io);
}
crate::file_access::check_write(path).map_err(EngineError::Io)?;
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
}
/// The session as a bundle: `<name>.scraft` plus every source's audio as 32-bit float WAV under
/// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the
/// session with its source paths pointing into the bundle.
pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
use std::io::Write as _;
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}"));
let mut s = session.clone();
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true);
let mut taken = std::collections::HashSet::new();
for src in &mut s.sources {
let Some(audio) = s.pool.get(src.id) else { continue };
let base = sanitize_name(&src.name);
let mut target = format!("Audio Files/{base}.wav");
let mut k = 1;
while taken.contains(&target) && k <= 9_999 {
target = format!("Audio Files/{base}_{k:02}.wav");
k += 1;
}
let opts_wav = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?;
w.start_file(target.as_str(), opts).map_err(zerr)?;
w.write_all(&bytes).map_err(ioerr)?;
taken.insert(target.clone());
src.path = target;
src.unsaved = false;
src.format = FileFormat::Wav;
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
w.write_all(text.as_bytes()).map_err(ioerr)?;
let bytes = w.finish().map_err(zerr)?.into_inner();
Ok((bytes, s))
}
/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]).
fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> {
let stem = Path::new(path).file_stem().and_then(OsStr::to_str).filter(|x| !x.is_empty()).map(str::to_string);
let mut s = e.session().clone();
if let Some(stem) = stem {
s.name = stem;
}
let (bytes, fresh) = session_bundle(&s)?;
let file = format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&fresh.name));
write_file(&file, &bytes)?;
let written = fresh.sources.iter().filter(|x| fresh.pool.get(x.id).is_some()).count();
*e.session_mut() = fresh;
e.path = Some(file);
e.mark_clean();
Ok(written)
}
/// Open a session from bytes (the browser has no paths): a bundle ([`session_bundle`]) or a bare
/// `.scraft`, whose audio is then reported missing. Returns the missing media, like
/// [`open_session`].
pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Vec<String>> {
let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") {
read_bundle(bytes)?
} else {
(
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
std::collections::HashMap::new(),
)
};
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let mut missing = Vec::new();
// Several sources may share one file (native sessions do): decode each path once and share the result.
let mut decoded: std::collections::HashMap<String, Option<Arc<SourceAudio>>> = std::collections::HashMap::new();
for src in &s.sources {
if let Some(audio) = decoded.get(&src.path) {
if let Some(audio) = audio {
s.pool.insert(src.id, Arc::clone(audio));
}
continue;
}
let audio = match files.remove(&src.path) {
Some(bytes) => match decode_source(&bytes, Path::new(&src.path).extension().and_then(OsStr::to_str), s.sample_rate.hz()) {
Ok(audio) => Some(audio),
Err(err) => {
missing.push(format!("{}: {err}", src.path));
None
}
},
None => {
missing.push(src.path.clone());
None
}
};
if let Some(audio) = &audio {
s.pool.insert(src.id, Arc::clone(audio));
}
decoded.insert(src.path.clone(), audio);
}
// Movies are referenced by path, never copied into a session: none can be found here.
missing.extend(s.videos.iter().map(|v| v.path.clone()));
let stem = Path::new(name).file_stem().and_then(OsStr::to_str).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string();
e.replace_session(s);
e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem)));
for m in &missing {
e.message(format!("missing media: {m}"));
}
Ok(missing)
}
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String, Vec<u8>>)> {
use std::io::Read as _;
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(zerr)?;
if z.len() > MAX_BUNDLE_ENTRIES {
return Err(EngineError::Io("bundle: too many files".into()));
}
let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64);
for i in 0..z.len() {
let mut f = z.by_index(i).map_err(zerr)?;
if f.is_dir() {
continue;
}
let rel = f.name().map_err(zerr)?.to_string();
let size = f.size();
total = total.saturating_add(size);
if total > MAX_BUNDLE_BYTES {
return Err(EngineError::Io("bundle: too large".into()));
}
let mut data = Vec::new();
// The declared size is hostile too: never read past it.
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
if is_session && text.is_none() {
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
} else {
files.insert(rel, data);
}
}
let text = text.ok_or_else(|| EngineError::Io("bundle: no .scraft session inside".into()))?;
Ok((text, files))
}
/// The record-enabled audio tracks, each with the input channels it records (those its input
/// route names, "In 3" → 2, wrapped to the device's `input_channels`) and a new take file
/// `<track>_<NN>.wav` in the `Audio Files` of the session's folder (`unsaved_dir` until it is saved).
pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<(TrackId, Vec<usize>, PathBuf)> {
let dir = e.path.as_deref().and_then(|p| Path::new(p).parent()).unwrap_or(unsaved_dir);
let s = e.session();
let take_no = s.sources.len() + 1;
s.tracks
.iter()
.filter(|t| t.mixer.record_arm && t.kind == TrackKind::Audio && !t.inactive)
.map(|t| {
let first = match &t.mixer.input {
soundcraft_model::Route::Hardware(h) => {
h.trim_start_matches("In ").split(['-', ' ']).next().and_then(|n| n.parse::<usize>().ok()).map_or(0, |n| n.saturating_sub(1))
}
_ => 0,
};
let inputs = (0..t.channels()).map(|k| first.saturating_add(k) % input_channels.max(1)).collect();
let file = |n: usize| dir.join("Audio Files").join(format!("{}_{n:02}.wav", sanitize_name(&t.name)));
let path = (take_no..take_no + 1_000).map(file).find(|p| !p.exists()).unwrap_or_else(|| file(take_no));
(t.id, inputs, path)
})
.collect()
}
/// A recording's plan, made when it starts: each record-enabled track's take file, where the
/// takes start on the timeline, the loop length they split into when loop recording, and the
/// punch range they're trimmed to.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Recording {
pub start: Samples,
pub pass: Option<Samples>,
pub punch: Option<Range>,
/// The input's sample rate, which the take files are written at.
pub rate: u32,
pub takes: Vec<Take>,
}
/// One track's take file.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Take {
pub track: TrackId,
pub path: PathBuf,
pub channels: usize,
}
/// Place recorded take files as `plan` says, as one undo step ("Record").
pub fn place_recording(e: &mut Engine, plan: &Recording, takes: Vec<(TrackId, PathBuf)>) -> Vec<soundcraft_model::ClipId> {
add_recording_in(e, plan.start, takes, plan.pass, plan.punch)
}
/// Turn recorded take files into clips at `start`, as one undo step ("Record"); each file is the
/// source of its clips. With `pass` (loop recording), a take becomes one clip per pass (at most
/// 1000), each pass after the first on a new playlist.
pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBuf)>, pass: Option<Samples>) -> Vec<soundcraft_model::ClipId> {
add_recording_in(e, start, takes, pass, None)
}
/// [`add_recording`] limited to the `punch` range: each clip is cut to it before it's placed, so
/// the material around the punch isn't overwritten.
fn add_recording_in(
e: &mut Engine,
start: Samples,
takes: Vec<(TrackId, PathBuf)>,
pass: Option<Samples>,
punch: Option<Range>,
) -> Vec<soundcraft_model::ClipId> {
let before = e.session_arc();
let mut out = Vec::new();
for (tid, path) in takes {
let read =
read_file(&path).and_then(|b| soundcraft_audio_io::decode(&b, None).map_err(|err| EngineError::Io(format!("{}: {err}", path.display()))));
let Ok((_, buf)) = read.inspect_err(|err| e.message(err.to_string())) else { continue };
let s = e.session_mut();
if s.track(tid).is_none() {
continue;
}
let resampled = buf.sample_rate != s.sample_rate.hz();
let name = path.file_stem().and_then(OsStr::to_str).unwrap_or("Take");
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav);
let Some(x) = s.sources.last_mut() else { continue };
x.unsaved = resampled;
let len = i64::try_from(x.frames).unwrap_or(0);
let pass = pass.unwrap_or(len).max(1);
for offset in (0..len).step_by(usize::try_from(pass).unwrap_or(usize::MAX)).take(1_000) {
let n = pass.min(len - offset);
if offset > 0 && n < pass / 8 {
break;
}
let (clip_start, cut, clip_len) = match punch {
Some(p) => {
let Some(kept) = Range::new(start, start.saturating_add(n)).intersect(&p) else { continue };
(kept.start, kept.start.saturating_sub(start), kept.len())
}
None => (start, 0, n),
};
if offset > 0
&& let Some(t) = s.track_mut(tid)
{
t.playlists.push(soundcraft_model::Playlist::new(format!("{}.{:02}", t.name, t.playlists.len() + 1)));
t.active_playlist = t.playlists.len() - 1;
}
let cid = s.new_clip_id();
crate::edit::place_clip(s, tid, Clip::audio(cid, name, src, offset.saturating_add(cut), clip_start, clip_len));
out.push(cid);
}
}
if !out.is_empty() {
e.push_undo("Record", before);
}
out
}
/// Where a take recording from `start` shows while it holds `len` samples, as it will be placed:
/// just the pass under way when loop recording in passes of `pass`, within the `punch` range.
/// Returns the timeline position of its first sample and the span it covers.
pub fn live_span(start: Samples, pass: Option<Samples>, punch: Option<Range>, len: Samples) -> Option<(Samples, Range)> {
let origin = start.saturating_sub(pass.filter(|p| *p > 0).map_or(0, |p| len / p * p));
let span = Range::new(start, origin.saturating_add(len));
Some((origin, span.intersect(&punch.unwrap_or(span))?))
}
#[cfg(test)]
mod record_tests {
use super::*;
use serde_json::json;
fn armed(e: &mut Engine, name: &str, format: &str, input: &str) -> TrackId {
let id = e.execute("track.new", &json!({"name": name, "format": format})).unwrap()["tracks"][0].as_u64().unwrap();
e.execute("track.input", &json!({"tracks": [id], "input": input})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [id], "value": true})).unwrap();
TrackId(id)
}
fn write_take(path: &Path, frames: usize) {
let mut bytes = soundcraft_audio_io::wav_float_header(1, 48_000, frames as u64).unwrap();
bytes.extend((0..frames).flat_map(|i| (i as f32 / frames as f32 - 0.5).to_le_bytes()));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
#[test]
fn armed_tracks_get_new_take_files_used_in_place() {
let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Engine::default();
let vox = armed(&mut e, "Vox", "mono", "In 2");
let gtr = armed(&mut e, "Gtr", "stereo", "In 3-4");
write_take(&dir.join("Audio Files/Vox_01.wav"), 10);
let plan = plan_takes(&e, &dir, 4);
assert_eq!(plan, vec![(vox, vec![1], dir.join("Audio Files/Vox_02.wav")), (gtr, vec![2, 3], dir.join("Audio Files/Gtr_01.wav"))]);
assert_eq!(plan_takes(&e, &dir, 2)[1].1, vec![0, 1]);
let session = dir.join("Song.scraft").to_string_lossy().into_owned();
e.execute("session.save_as", &json!({"path": session})).unwrap();
write_take(&plan[0].2, 4_800);
let clips = add_recording(&mut e, 96_000, vec![(vox, plan[0].2.clone())], None);
let clip = e.session().find_clip(clips[0]).unwrap().1.clone();
assert_eq!((clip.start, clip.length), (96_000, 4_800));
assert_eq!(save_session(&mut e, &session).unwrap(), 0);
let src = e.session().sources.last().unwrap().clone();
assert_eq!((src.name.as_str(), src.path.as_str(), src.unsaved, src.frames), ("Vox_02", "Audio Files/Vox_02.wav", false, 4_800));
e.execute("edit.undo", &json!({})).unwrap();
assert!(e.session().find_clip(clips[0]).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_loop_take_becomes_one_clip_per_pass_and_autosaves_in_place() {
let dir = std::env::temp_dir().join(format!("soundcraft-loop-take-{}", std::process::id()));
let path = dir.join("Audio Files/Vox_01.wav");
write_take(&path, 1_050);
let mut e = Engine::default();
let vox = armed(&mut e, "Vox", "mono", "In 1");
assert_eq!(add_recording(&mut e, 1_000, vec![(vox, path.clone())], Some(400)).len(), 3);
assert_eq!(save_copy(&e, &dir.join("Untitled.scraft").to_string_lossy()).unwrap(), 0);
let t = e.session().track(vox).unwrap();
let passes: Vec<_> = t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| (c.source_offset(), c.start, c.length))).collect();
assert_eq!(passes, vec![(0, 1_000, 400), (400, 1_000, 400), (800, 1_000, 250)]);
assert_eq!((t.active_playlist, t.playlists[2].name.as_str()), (2, "Vox.03"));
e.execute("edit.undo", &json!({})).unwrap();
assert_eq!(e.session().track(vox).unwrap().playlists.len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn punching_in_keeps_the_material_around_the_punch_range() {
let dir = std::env::temp_dir().join(format!("soundcraft-punch-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Engine::default();
let vox = armed(&mut e, "Vox", "mono", "In 1");
let base = dir.join("Audio Files/Base.wav");
let take = dir.join("Audio Files/Take.wav");
write_take(&base, 3_000);
write_take(&take, 1_000);
add_recording(&mut e, 0, vec![(vox, base)], None);
let plan = Recording {
start: 1_000,
pass: None,
punch: Some(Range::new(1_400, 1_600)),
rate: 48_000,
takes: vec![Take { track: vox, path: take.clone(), channels: 1 }],
};
place_recording(&mut e, &plan, vec![(vox, take)]);
let clips: Vec<_> = e.session().track(vox).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect();
assert_eq!(clips, vec![(0, 1_400, 0), (1_400, 200, 400), (1_600, 1_400, 1_600)]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_take_shows_the_pass_under_way_within_the_punch_while_it_records() {
assert_eq!(live_span(1_000, None, None, 0), None);
assert_eq!(live_span(1_000, None, None, 500), Some((1_000, Range::new(1_000, 1_500))));
assert_eq!(live_span(1_000, Some(400), None, 900), Some((200, Range::new(1_000, 1_100))));
assert_eq!(live_span(1_000, Some(400), None, 800), None);
let punch = Some(Range::new(1_200, 1_400));
assert_eq!(live_span(1_000, None, punch, 150), None);
assert_eq!(live_span(1_000, None, punch, 500), Some((1_000, Range::new(1_200, 1_400))));
}
}
#[cfg(test)]
mod save_tests {
use super::*;
#[test]
fn autosave_copy_leaves_document_untouched_and_reuses_audio() {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap();
let dir = std::env::temp_dir().join(format!("soundcraft-autosave-{}", std::process::id()));
let path = dir.join("Midnight Groove.scraft");
let p = path.to_string_lossy().into_owned();
let n1 = save_copy(&e, &p).unwrap();
let n2 = save_copy(&e, &p).unwrap();
assert!(n1 >= 5);
assert_eq!(n2, 0, "second autosave must not rewrite audio");
assert!(e.is_dirty() && e.path.is_none());
let mut r = Engine::default();
open_session(&mut r, &p).unwrap();
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
let _ = std::fs::remove_dir_all(&dir);
}
fn with_take(level: f32) -> Engine {
let mut e = Engine::default();
let s = e.session_mut();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let src = add_source(s, "Audio 1_02", AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; 4_800]] }, None, FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::audio(id, "Audio 1_02", src, 0, 0, 4_800));
e
}
fn reopen(path: &str) -> Engine {
let mut r = Engine::default();
assert_eq!(open_session(&mut r, path).unwrap(), Vec::<String>::new());
r
}
#[test]
fn saving_never_replaces_audio_of_another_session() {
let dir = std::env::temp_dir().join(format!("soundcraft-no-replace-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |p: &str| dir.join(p).to_string_lossy().into_owned();
save_session(&mut with_take(0.5), &path("A/A.scraft")).unwrap();
save_session(&mut with_take(-0.25), &path("A/A v2.scraft")).unwrap();
save_session(&mut with_take(0.75), &path("B/B.scraft")).unwrap();
save_session(&mut reopen(&path("B/B.scraft")), &path("A/B.scraft")).unwrap();
let levels = ["A/A.scraft", "A/A v2.scraft", "B/B.scraft", "A/B.scraft"].map(|p| {
let r = reopen(&path(p));
r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0]
});
assert_eq!(levels, [0.5, -0.25, 0.75, 0.75]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn consolidating_twice_keeps_both_renders() {
let dir = std::env::temp_dir().join(format!("soundcraft-consolidate-twice-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let mut e = crate::demo::demo_engine();
let kick = e.session().track_by_name("Kick").unwrap().id;
let end = e.session().content_end();
let render = |e: &Engine| soundcraft_mix::render_clips(e.session(), kick, Range::new(0, end));
e.execute("edit.consolidate", &json!({"tracks": [kick.0], "start": 500_000, "end": 560_000})).unwrap();
save_session(&mut e, &path).unwrap();
e.execute("edit.consolidate", &json!({"tracks": [kick.0], "start": 1_400_000, "end": 1_500_000})).unwrap();
save_session(&mut e, &path).unwrap();
assert!(render(&reopen(&path)) == render(&e));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_after_undo_reuses_the_saved_audio() {
let dir = std::env::temp_dir().join(format!("soundcraft-undo-resave-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let mut e = with_take(0.5);
e.execute("track.new", &json!({})).unwrap();
assert_eq!(save_session(&mut e, &path).unwrap(), 1);
e.execute("edit.undo", &json!({})).unwrap();
assert_eq!(save_session(&mut e, &path).unwrap(), 0);
assert_eq!(std::fs::read_dir(dir.join("Audio Files")).unwrap().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saved_audio_is_trusted_only_in_the_folder_it_was_saved_in() {
let dir = std::env::temp_dir().join(format!("soundcraft-own-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |p: &str| dir.join(p).to_string_lossy().into_owned();
let level = |r: &Engine| r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0];
save_session(&mut with_take(-0.25), &path("B/Other.scraft")).unwrap();
let mut e = with_take(0.5);
save_session(&mut e, &path("A/A.scraft")).unwrap();
e.execute("track.new", &json!({})).unwrap();
save_session(&mut e, &path("B/B.scraft")).unwrap();
e.execute("edit.undo", &json!({})).unwrap();
e.execute("session.save", &json!({})).unwrap();
assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5);
e.execute("session.save_copy", &json!({"path": path("A/Copy.scraft")})).unwrap();
e.execute("session.save", &json!({})).unwrap();
assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5);
std::fs::remove_file(dir.join("A/Audio Files/Audio 1_02.wav")).unwrap();
let mut missing = Engine::default();
open_session(&mut missing, &path("A/A.scraft")).unwrap();
save_session(&mut missing, &path("B/Moved.scraft")).unwrap();
let mut r = Engine::default();
assert_eq!(
open_session(&mut r, &path("B/Moved.scraft")).unwrap(),
vec![dir.join("A").join("Audio Files/Audio 1_02.wav").display().to_string()]
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_over_another_session_needs_replace() {
let dir = std::env::temp_dir().join(format!("soundcraft-replace-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let mut first = with_take(0.5);
first.execute("session.save_as", &json!({"path": path})).unwrap();
first.execute("session.save", &json!({})).unwrap();
first.execute("session.save_as", &json!({"path": dir.join("Song")})).unwrap();
assert!(first.execute("session.save_template", &json!({"path": path})).is_err());
let mut second = with_take(-0.25);
let track = second.session().tracks[0].id.0;
for id in ["session.save", "session.save_as", "session.save_copy", "session.save_template", "file.export_selected_tracks_as_session"] {
let err = second.execute(id, &json!({"path": path, "tracks": [track]})).unwrap_err();
assert!(err.to_string().contains("`replace: true`"), "{id}: {err}");
}
assert_eq!(std::fs::read_dir(dir.join("Audio Files")).unwrap().count(), 1);
Engine::default().execute("session.save_as", &json!({"path": dir.join("New/New.scraft")})).unwrap();
std::fs::copy(&path, dir.join("Song")).unwrap();
let mut bare = Engine::default();
open_session(&mut bare, &dir.join("Song").to_string_lossy()).unwrap();
bare.execute("session.save", &json!({})).unwrap();
second.execute("session.save_as", &json!({"path": path, "replace": true})).unwrap();
let r = reopen(&path);
assert_eq!(r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0], -0.25);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_keeps_the_last_ten_versions_as_backups() {
let dir = std::env::temp_dir().join(format!("soundcraft-backups-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Song.scraft");
let mut e = Engine::default();
let mut versions = Vec::new();
for i in 0..12 {
e.execute("track.new", &json!({"name": format!("T{i}")})).unwrap();
save_session(&mut e, &path.to_string_lossy()).unwrap();
save_session(&mut e, &path.to_string_lossy()).unwrap();
versions.push(std::fs::read(&path).unwrap());
}
let backup = |n: usize| std::fs::read(dir.join(format!("Backups/Song backup {n}.scraft"))).ok();
assert_eq!(std::fs::read_dir(dir.join("Backups")).unwrap().count(), 10);
assert_eq!((backup(1), backup(2), backup(11)), (None, Some(versions[1].clone()), Some(versions[10].clone())));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn backup_never_overwrites_an_existing_entry_when_numbers_run_out() {
let dir = std::env::temp_dir().join(format!("soundcraft-backups-full-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Backups")).unwrap();
let path = dir.join("Song.scraft");
std::fs::write(&path, b"session").unwrap();
let last = dir.join(format!("Backups/Song backup {}.scraft", u32::MAX));
std::fs::write(&last, b"sentinel").unwrap();
back_up(&path);
assert_eq!(std::fs::read(&last).unwrap(), b"sentinel");
let dest = dir.join("Backups/Song backup 1.scraft");
std::fs::write(&dest, b"taken").unwrap();
assert!(copy_new(&path, &dest).is_err());
assert_eq!(std::fs::read(&dest).unwrap(), b"taken");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn backup_never_writes_through_a_link_to_an_outside_file() {
let dir = std::env::temp_dir().join(format!("soundcraft-backups-link-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Project/Backups")).unwrap();
let outside = dir.join("outside.txt");
std::fs::write(&outside, b"sentinel").unwrap();
let path = dir.join("Project/Song.scraft");
std::fs::write(&path, b"session").unwrap();
std::os::unix::fs::symlink(&outside, dir.join(format!("Project/Backups/Song backup {}.scraft", u32::MAX))).unwrap();
back_up(&path);
let link = dir.join("Project/Backups/Song backup 1.scraft");
std::os::unix::fs::symlink(&outside, &link).unwrap();
assert!(copy_new(&path, &link).is_err());
assert_eq!(std::fs::read(&outside).unwrap(), b"sentinel");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_gives_sources_that_share_a_path_their_own_files() {
let mut s = Session::default();
let loud = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
let quiet = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 64]] };
add_source(&mut s, "tone", loud, Some("Audio Files/tone.wav"), FileFormat::Wav);
add_source(&mut s, "tone", quiet, Some("Audio Files/tone.wav"), FileFormat::Wav);
let mut e = Engine::new(s);
let dir = std::env::temp_dir().join(format!("soundcraft-shared-path-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Shared.scraft");
save_session(&mut e, &path.to_string_lossy()).unwrap();
let paths: Vec<_> = e.session().sources.iter().map(|src| src.path.clone()).collect();
assert_eq!(paths.len(), 2);
assert_ne!(paths[0], paths[1], "{paths:?}");
let a = std::fs::read(dir.join(&paths[0])).unwrap();
let b = std::fs::read(dir.join(&paths[1])).unwrap();
assert_ne!(a, b);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod rate_tests {
use super::*;
#[test]
fn a_tiny_sample_rate_is_refused_before_it_is_converted() {
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
assert!(at_rate(buf(1), 48_000, 1_000_000).is_none());
let ok = at_rate(buf(8_000), 48_000, 1_000_000).unwrap();
assert_eq!((ok.sample_rate, ok.frames()), (48_000, 600));
}
}
#[cfg(test)]
mod open_tests {
use super::*;
#[test]
fn a_file_named_by_many_sources_is_decoded_once() {
let dir = std::env::temp_dir().join(format!("soundcraft-shared-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] };
std::fs::write(dir.join("k.wav"), soundcraft_audio_io::encode(&audio, &EncodeOptions::default()).unwrap()).unwrap();
let mut s = Session::default();
for _ in 0..3 {
add_source(&mut s, "k", audio.clone(), Some("k.wav"), FileFormat::Wav);
}
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
std::fs::write(&path, s.to_json().unwrap()).unwrap();
let mut e = Engine::default();
assert!(open_session(&mut e, &path).unwrap().is_empty());
let (sources, pool) = (&e.session().sources, &e.session().pool);
let first = pool.get(sources[0].id).unwrap();
assert!(sources.iter().all(|src| pool.get(src.id).is_some_and(|a| Arc::ptr_eq(a, first))));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sources_playing_one_file_share_it_when_saved() {
let dir = std::env::temp_dir().join(format!("soundcraft-shared-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Audio Files")).unwrap();
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] };
std::fs::write(dir.join("Audio Files/k.wav"), soundcraft_audio_io::encode(&audio, &EncodeOptions::default()).unwrap()).unwrap();
let mut s = Session::default();
for _ in 0..3 {
add_source(&mut s, "k", audio.clone(), Some("Audio Files/k.wav"), FileFormat::Wav);
}
s.sources.iter_mut().for_each(|src| src.unsaved = false);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
std::fs::write(&path, s.to_json().unwrap()).unwrap();
let mut e = Engine::default();
open_session(&mut e, &path).unwrap();
assert_eq!(save_session(&mut e, &path).unwrap(), 0);
assert!(e.session().sources.iter().all(|src| src.path == "Audio Files/k.wav"));
assert_eq!(save_copy(&e, &dir.join("copy/Song.scraft").to_string_lossy()).unwrap(), 1);
assert_eq!(std::fs::read_dir(dir.join("copy/Audio Files")).unwrap().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn stored_paths_are_taken_from_the_session_folder() {
let session = std::env::temp_dir().join("s").join("Song.scraft");
assert_eq!(stored_path("Audio Files/k.wav", &session), Ok(session.with_file_name("Audio Files").join("k.wav")));
#[cfg(windows)]
assert_eq!(stored_path("Audio Files/take:2.wav", &session), Err("not opened: the path is not valid".into()));
#[cfg(not(windows))]
assert_eq!(stored_path(r"C:\Audio\k.wav", &session), Err("not opened: the path is not valid".into()));
}
#[test]
fn stored_media_paths_are_followed_only_on_this_computer() {
let dir = std::env::temp_dir().join(format!("soundcraft-stored-paths-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] };
std::fs::write(dir.join("k.wav"), soundcraft_audio_io::encode(&audio, &EncodeOptions::default()).unwrap()).unwrap();
let mut saved = Session::default();
let t = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let src = add_source(&mut saved, "k", audio, Some("../k.wav"), FileFormat::Wav);
let id = saved.new_clip_id();
crate::edit::place_clip(&mut saved, t, Clip::audio(id, "k", src, 0, 0, 480));
// In a folder of its own, so a movie named `k.wav` is not relinked to a file beside it.
std::fs::create_dir_all(dir.join("s")).unwrap();
let path = dir.join("s").join("Song.scraft").to_string_lossy().into_owned();
let mut stored = vec![r"\\192.0.2.1\share\x.wav".to_string(), "file://192.0.2.1/s/x.wav".to_string()];
// Linux reads `//tmp/…` as the local `/tmp/…`, so only the path check keeps it from being read.
if cfg!(unix) {
stored.push(format!("/{}", dir.join("k.wav").display()));
}
let refused = |m: &str| m.contains("not opened: the path is on another computer");
for p in stored {
let mut s = saved.clone();
s.sources[0].path.clone_from(&p);
let id = SourceId(s.alloc());
s.videos.push(soundcraft_model::VideoSource {
id,
name: "x".into(),
path: p.clone(),
width: 0,
height: 0,
frame_rate: 0.0,
duration: 0.0,
codec: String::new(),
offline: None,
});
std::fs::write(&path, s.to_json().unwrap()).unwrap();
let mut r = Engine::default();
let missing = open_session(&mut r, &path).unwrap();
assert_eq!(missing.iter().filter(|m| refused(m)).count(), 2, "{p}: {missing:?}");
assert!(r.session().pool.get(r.session().sources[0].id).is_none(), "{p}");
assert!(r.session().videos[0].offline.is_some(), "{p}");
let mut into = Engine::default();
let imported = into.execute("file.import_session_data", &json!({"path": path})).unwrap();
assert!(imported["missing"].as_array().unwrap().iter().any(|m| m.as_str().is_some_and(refused)), "{p}: {imported}");
assert!(into.session().pool.get(into.session().sources[0].id).is_none(), "{p}");
}
// A path relative to the session opens.
std::fs::write(&path, saved.to_json().unwrap()).unwrap();
let mut r = Engine::default();
assert!(open_session(&mut r, &path).unwrap().is_empty());
assert!(r.session().pool.get(r.session().sources[0].id).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod name_tests {
use super::*;
#[test]
fn a_midi_track_name_is_cut_to_a_length_that_can_be_drawn() {
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
let smf = soundcraft_midi::Smf { tracks: vec![soundcraft_midi::SmfTrack { name: "n".repeat(1_000_000), sequence }], ..Default::default() };
let mut e = Engine::default();
let ids = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, false).unwrap();
let track = e.session().track(ids[0]).unwrap();
assert_eq!((track.name.len(), track.clips()[0].name.len()), (soundcraft_model::MAX_NAME_CHARS, soundcraft_model::MAX_NAME_CHARS));
}
#[test]
fn a_controller_only_midi_track_is_imported_with_its_events() {
let ctrls = vec![
soundcraft_midi::CtrlEvent { tick: 0, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(7), value: 100 },
soundcraft_midi::CtrlEvent { tick: 480, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(7), value: 64 },
soundcraft_midi::CtrlEvent { tick: 960, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(7), value: 20 },
];
let sequence = soundcraft_midi::Sequence { ctrls: ctrls.clone(), ..Default::default() };
let smf = soundcraft_midi::Smf { tracks: vec![soundcraft_midi::SmfTrack { name: "Vol".into(), sequence }], ..Default::default() };
let mut e = Engine::default();
let ids = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, false).unwrap();
assert_eq!(ids.len(), 1);
let track = e.session().track(ids[0]).unwrap();
assert_eq!(track.instrument.as_ref().map(|i| i.plugin.as_str()), Some("subtractive_synth"));
// The clip must reach past the last event, or playback skips it.
assert!(track.clips()[0].end() > e.session().tempo.tick_to_samples(960, e.session().sample_rate));
match &track.clips()[0].content {
ClipContent::Midi { sequence } => assert_eq!(sequence.ctrls, ctrls),
_ => panic!("expected a MIDI clip"),
}
}
#[test]
fn midi_longer_than_a_day_is_refused() {
let held = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: (1 << 28) - 1 };
let sequence = soundcraft_midi::Sequence { notes: vec![held], ..Default::default() };
let smf = soundcraft_midi::Smf { tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }], ..Default::default() };
let err = import_midi_bytes(&mut Engine::default(), &soundcraft_midi::write_smf(&smf).unwrap(), 0, false).unwrap_err();
assert!(err.to_string().contains("longer than 24 hours"), "{err}");
}
#[test]
fn midi_with_more_meter_changes_than_scores_have_is_refused() {
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
let meters = (0..=MAX_MIDI_METERS as i64).map(|bar| (bar * 3840, 4, 4)).collect();
let smf = soundcraft_midi::Smf { tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }], meters, ..Default::default() };
let err = import_midi_bytes(&mut Engine::default(), &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap_err();
assert!(err.to_string().contains("meter changes"), "{err}");
}
#[test]
fn new_sources_take_the_first_free_file_name() {
let mut s = Session::default();
let buf = || AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 4]] };
add_source(&mut s, "k", buf(), Some("Audio Files/k-02.wav"), FileFormat::Wav);
let mut path = || {
let id = add_source(&mut s, "k", buf(), None, FileFormat::Wav);
s.source(id).map(|x| x.path.clone()).unwrap_or_default()
};
assert_eq!([path(), path(), path()], ["Audio Files/k.wav", "Audio Files/k-2.wav", "Audio Files/k-3.wav"]);
}
}
#[cfg(all(test, unix))]
mod pipe_tests {
use super::*;
#[test]
fn a_pipe_named_as_media_is_refused_not_waited_on() {
let dir = std::env::temp_dir().join(format!("soundcraft-pipe-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let fifo = dir.join("pipe.wav");
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
let mut s = Session::default();
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 10]] };
add_source(&mut s, "pipe", audio, Some(&fifo.to_string_lossy()), FileFormat::Wav);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
std::fs::write(&path, s.to_json().unwrap()).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let pipe = fifo.to_string_lossy().into_owned();
std::thread::spawn(move || {
let mut e = Engine::default();
let imported = e.execute("file.import_audio", &json!({"path": pipe})).is_ok();
let _ = tx.send((imported, open_session(&mut e, &path).map_err(|err| err.to_string())));
});
let (imported, opened) = rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
assert!(!imported);
assert_eq!(opened.unwrap(), [fifo.display().to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod stem_tests {
#[test]
fn stems_one_file_per_track() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-stems-{}", std::process::id()));
let r = e
.execute("file.bounce_stems", &serde_json::json!({"dir": dir.to_string_lossy(), "start": {"seconds": 10.0}, "end": {"seconds": 11.0}}))
.unwrap();
let n = r["files"].as_array().map_or(0, Vec::len);
assert_eq!(n, 7, "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod strip_tests {
use super::*;
/// A stereo track with `length`-long clips at `starts`, each playing a second of steady tone.
fn tones(starts: &[Samples], length: Samples) -> (Engine, TrackId) {
let mut e = Engine::default();
e.execute("track.new", &json!({"format": "stereo"})).unwrap();
let t = e.session().tracks[0].id;
let s = e.session_mut();
let tone = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 48_000]; 2] };
let src = add_source(s, "tone", tone, None, FileFormat::Wav);
for at in starts {
let id = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::audio(id, "tone", src, 0, *at, length));
}
(e, t)
}
#[test]
fn clips_past_what_one_render_holds_are_kept() {
// 100 minutes in: a stereo render stops at 2^28 frames, about 93 minutes.
let far = 48_000 * 6000;
let (mut e, t) = tones(&[0, far], 48_000);
let r = e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": far + 96_000})).unwrap();
assert_eq!(r["clips"], 2);
assert!(e.session().track(t).unwrap().clips().iter().any(|c| c.start >= far));
}
#[test]
fn more_than_a_day_of_clips_is_refused() {
let (mut e, t) = tones(&[0], 48_000 * 86_401);
assert!(e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": 48_000i64 * 86_402})).is_err());
assert_eq!(e.session().track(t).unwrap().clips().len(), 1);
}
}
#[cfg(test)]
mod bundle_tests {
use super::*;
#[test]
fn bundle_round_trips_session_and_audio() {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap();
let (bytes, fresh) = session_bundle(e.session()).unwrap();
assert!(bytes.starts_with(b"PK\x03\x04"));
assert!(fresh.sources.iter().all(|s| s.path.starts_with("Audio Files/") && !s.unsaved));
let mut r = Engine::default();
let missing = open_session_bytes(&mut r, "Midnight Groove.scraftz", &bytes).unwrap();
assert!(missing.is_empty(), "{missing:?}");
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
assert_eq!(r.session().tracks.len(), e.session().tracks.len());
for src in &e.session().sources {
let (a, b) = (e.session().pool.get(src.id).unwrap(), r.session().pool.get(src.id).unwrap());
assert_eq!(a.buffer.channels, b.buffer.channels, "{}: float WAV keeps every sample", src.name);
}
assert!(!r.is_dirty());
assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz"));
}
#[test]
fn sources_sharing_one_file_all_load_from_a_bundle() {
use std::io::Write as _;
let e = crate::demo::demo_engine();
let (bytes, _) = session_bundle(e.session()).unwrap();
let (text, files) = read_bundle(&bytes).unwrap();
let mut s = Session::from_json(&text).unwrap();
s.sources[1].path = s.sources[0].path.clone();
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for path in s.sources.iter().map(|x| &x.path).collect::<std::collections::BTreeSet<_>>() {
w.start_file(path.as_str(), opts).unwrap();
w.write_all(&files[path]).unwrap();
}
w.start_file("Song.scraft", opts).unwrap();
w.write_all(s.to_json().unwrap().as_bytes()).unwrap();
let bundle = w.finish().unwrap().into_inner();
let mut r = Engine::default();
let missing = open_session_bytes(&mut r, "Song.scraftz", &bundle).unwrap();
assert!(missing.is_empty(), "{missing:?}");
for src in &r.session().sources {
assert!(r.session().pool.get(src.id).is_some(), "{} has no audio", src.name);
}
}
#[test]
fn same_named_sources_get_distinct_files() {
let mut e = crate::demo::demo_engine();
let name = e.session().sources[0].name.clone();
let s = e.session_mut();
let n = s.sources.len();
s.sources[1].name = name;
let (bytes, fresh) = session_bundle(e.session()).unwrap();
let paths: std::collections::HashSet<_> = fresh.sources.iter().map(|s| s.path.clone()).collect();
assert_eq!(paths.len(), n);
let mut r = Engine::default();
assert!(open_session_bytes(&mut r, "x.scraftz", &bytes).unwrap().is_empty());
}
#[test]
fn bare_session_opens_with_its_audio_reported_missing() {
let e = crate::demo::demo_engine();
let (_, fresh) = session_bundle(e.session()).unwrap();
let text = fresh.to_json().unwrap();
let mut r = Engine::default();
let missing = open_session_bytes(&mut r, "Song.scraft", text.as_bytes()).unwrap();
assert_eq!(missing.len(), fresh.sources.len());
assert_eq!(r.session().tracks.len(), fresh.tracks.len());
}
#[test]
fn hostile_bundles_are_errors_not_panics() {
let mut r = Engine::default();
for bad in [&b"PK\x03\x04 not really a zip"[..], b"", b"{ not json", b"\xff\xfe\x00"] {
assert!(open_session_bytes(&mut r, "bad.scraftz", bad).is_err());
}
// A valid zip without a session inside.
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file("Audio Files/a.wav", zip::write::SimpleFileOptions::default()).unwrap();
let only_audio = w.finish().unwrap().into_inner();
assert!(open_session_bytes(&mut r, "a.scraftz", &only_audio).is_err());
assert_eq!(r.session().tracks.len(), 0, "a failed open leaves the document alone");
}
#[test]
fn write_file_writes_to_disk_natively() {
let p = std::env::temp_dir().join(format!("soundcraft-write-file-{}.txt", std::process::id()));
write_file(&p.to_string_lossy(), b"hello").unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"hello");
let _ = std::fs::remove_file(&p);
}
}