mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-31 14:06:56 -06:00
726 lines
33 KiB
Rust
726 lines
33 KiB
Rust
//! 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::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
|
||
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
|
||
const MAX_IMPORT_SAMPLES: usize = 1 << 30;
|
||
|
||
/// 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 mut path = format!("Audio Files/{stem}.wav");
|
||
let mut n = 2u32;
|
||
while s.sources.iter().any(|src| src.path == path) {
|
||
path = format!("Audio Files/{stem}-{n}.wav");
|
||
n = n.saturating_add(1);
|
||
if n > 10_000 {
|
||
break;
|
||
}
|
||
}
|
||
path
|
||
}
|
||
|
||
/// 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(|x| x.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
|
||
}
|
||
|
||
/// 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(|x| x.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}")))?;
|
||
if buf.frames().saturating_mul(buf.num_channels().max(1)) > MAX_IMPORT_SAMPLES {
|
||
return Err(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 s = e.session_mut();
|
||
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 = bwf.time_reference;
|
||
}
|
||
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(|x| x.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}))
|
||
}
|
||
|
||
/// 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(|n| n.to_str()).unwrap_or("Video").to_string();
|
||
let ext = Path::new(path).extension().and_then(|x| x.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(|n| n.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(),
|
||
});
|
||
// 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 referenced movie: keep it when its path exists, else look for a file of the same
|
||
/// name next to the session (moved projects) and relink. Returns the movies still missing.
|
||
pub fn relink_videos(s: &mut Session, session_dir: &Path) -> Vec<String> {
|
||
let mut missing = Vec::new();
|
||
for v in &mut s.videos {
|
||
let p = if Path::new(&v.path).is_absolute() { PathBuf::from(&v.path) } else { session_dir.join(&v.path) };
|
||
if p.is_file() {
|
||
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() && c.is_file());
|
||
match near {
|
||
Some(c) => v.path = c.to_string_lossy().into_owned(),
|
||
None => missing.push(format!("{} (video)", p.display())),
|
||
}
|
||
}
|
||
missing
|
||
}
|
||
|
||
/// 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 {
|
||
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() {
|
||
continue;
|
||
}
|
||
let name = if tr.name.trim().is_empty() { format!("MIDI {}", i + 1) } else { tr.name.clone() };
|
||
let id = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some(&name));
|
||
let drums = 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" }));
|
||
}
|
||
let end_tick = base_tick + tr.sequence.end_tick().max(1);
|
||
let len = s.tempo.tick_to_samples(end_tick, s.sample_rate) - at;
|
||
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()))
|
||
}
|
||
|
||
/// Render the main mix. Returns the encoded file and (peak dBFS, integrated LUFS).
|
||
pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) -> Result<(Vec<u8>, (f32, f32, f32))> {
|
||
bounce_bytes_with(e, r, opts, normalize, false)
|
||
}
|
||
|
||
/// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV
|
||
/// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`.
|
||
pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec<u8>, (f32, f32, f32))> {
|
||
bounce_source(e, r, opts, normalize, fold_stereo, None)
|
||
}
|
||
|
||
/// [`bounce_bytes_with`] for the main mix, or for `bus` when one is named.
|
||
pub fn bounce_source(
|
||
e: &Engine,
|
||
r: Range,
|
||
opts: &EncodeOptions,
|
||
normalize: bool,
|
||
fold_stereo: bool,
|
||
bus: Option<BusId>,
|
||
) -> Result<(Vec<u8>, (f32, f32, f32))> {
|
||
let s = e.session();
|
||
if r.len() > s.sample_rate.samples(4.0 * 3600.0) {
|
||
return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into()));
|
||
}
|
||
let (mut ch, fmt) = render_bounce(s, r, fold_stereo, bus);
|
||
if normalize {
|
||
soundcraft_dsp::offline::normalize(&mut ch, -0.1, false);
|
||
}
|
||
let peak = soundcraft_dsp::offline::peak_db(&ch);
|
||
let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, ch.len());
|
||
let frames = ch.first().map_or(0, Vec::len);
|
||
lm.process(&ch, frames);
|
||
let lufs = lm.integrated_lufs();
|
||
let true_peak = lm.true_peak_db();
|
||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch };
|
||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf, opts, fmt.channel_mask()).map_err(|err| EngineError::Io(err.to_string()))?;
|
||
Ok((bytes, (peak, lufs, true_peak)))
|
||
}
|
||
|
||
/// The main mix over `r` and its format (folded to stereo on request).
|
||
fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec<Vec<f32>>, ChannelFormat) {
|
||
render_bounce(s, r, fold_stereo, None)
|
||
}
|
||
|
||
/// The main mix, or one bus, over `r`.
|
||
fn render_bounce(s: &Session, r: Range, fold_stereo: bool, bus: Option<BusId>) -> (Vec<Vec<f32>>, ChannelFormat) {
|
||
let (ch, fmt) = if let Some(id) = bus {
|
||
let fmt = s.bus(id).map(|b| b.format).unwrap_or(ChannelFormat::Stereo);
|
||
(soundcraft_mix::render_bus(s, id, r, 1024), fmt)
|
||
} else {
|
||
let ch = soundcraft_mix::render_range(s, r, 1024);
|
||
let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() };
|
||
(ch, fmt)
|
||
};
|
||
if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) }
|
||
}
|
||
|
||
/// 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();
|
||
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?;
|
||
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 (ch, fmt) = render_main(&solo, r, fold_stereo);
|
||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch };
|
||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf, opts, fmt.channel_mask()).map_err(|err| EngineError::Io(err.to_string()))?;
|
||
let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name)));
|
||
std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
|
||
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) -> usize {
|
||
let r = bounded_to_track(s, t, r);
|
||
let audio = soundcraft_mix::render_clips(s, t, r);
|
||
let keep = soundcraft_dsp::offline::non_silent_ranges(
|
||
&audio,
|
||
thr,
|
||
usize::try_from(min_len).unwrap_or(0),
|
||
usize::try_from(pre).unwrap_or(0),
|
||
usize::try_from(post).unwrap_or(0),
|
||
);
|
||
let mut cut_points = vec![r.start];
|
||
for (a, b) in &keep {
|
||
cut_points.push(r.start + *a as i64);
|
||
cut_points.push(r.start + *b as i64);
|
||
}
|
||
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 {
|
||
let a = r.start + *a as i64;
|
||
if a > prev {
|
||
gaps.push(Range::new(prev, a));
|
||
}
|
||
prev = r.start + *b as i64;
|
||
}
|
||
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));
|
||
}
|
||
keep.len()
|
||
}
|
||
|
||
/// Consolidate the selection on a track into one new clip backed by a rendered source.
|
||
pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result<bool> {
|
||
let s = e.session();
|
||
if s.track(t).is_none_or(|tr| !tr.kind.has_playlist() || tr.kind.is_midi()) {
|
||
return Ok(false);
|
||
}
|
||
let r = bounded_to_track(s, t, r);
|
||
if r.is_empty() {
|
||
return Ok(false);
|
||
}
|
||
let audio = soundcraft_mix::render_clips(s, t, r);
|
||
let name = format!("{}_consolidated", s.track(t).map_or("Audio", |x| x.name.as_str()));
|
||
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)
|
||
}
|
||
|
||
/// Render clip gain into a new source and reset the clip's gain.
|
||
pub fn render_clip_gain(e: &mut Engine, 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);
|
||
}
|
||
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));
|
||
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();
|
||
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?;
|
||
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}")));
|
||
}
|
||
}
|
||
std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
|
||
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.
|
||
pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
|
||
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 files in the target folder are overwritten in place.
|
||
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)> {
|
||
let p = PathBuf::from(path);
|
||
let p = if p.extension().is_none() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p };
|
||
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 mut written = 0;
|
||
let shared_paths: Vec<bool> =
|
||
s.sources.iter().enumerate().map(|(i, src)| s.sources.iter().enumerate().any(|(j, other)| j != i && other.path == src.path)).collect();
|
||
for (i, src) in s.sources.iter_mut().enumerate() {
|
||
let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name));
|
||
let exists_rel = !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 Some(audio) = s.pool.get(src.id) else { continue };
|
||
std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?;
|
||
let mut target = rel_target.clone();
|
||
if overwrite {
|
||
// One file per source id so autosaves never collide or pile up.
|
||
target = format!("Audio Files/{}_{}.wav", sanitize_name(&src.name), src.id.0);
|
||
if dir.join(&target).exists() {
|
||
src.path = target;
|
||
src.unsaved = false;
|
||
continue;
|
||
}
|
||
} else {
|
||
let mut k = 1;
|
||
while dir.join(&target).exists() && !(exists_rel && target == src.path && !shared) {
|
||
target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name));
|
||
k += 1;
|
||
if k > 999 {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
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()))?;
|
||
std::fs::write(dir.join(&target), bytes).map_err(|err| EngineError::Io(format!("{target}: {err}")))?;
|
||
src.path = target;
|
||
src.unsaved = false;
|
||
src.format = FileFormat::Wav;
|
||
written += 1;
|
||
}
|
||
if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) {
|
||
s.name = stem.to_string();
|
||
}
|
||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||
// Atomic-ish write: temp file then rename.
|
||
let tmp = p.with_extension("scraft.tmp");
|
||
std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?;
|
||
std::fs::rename(&tmp, &p).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?;
|
||
Ok((s, written, p))
|
||
}
|
||
|
||
/// 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>> {
|
||
let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||
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();
|
||
for src in s.sources.clone() {
|
||
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
|
||
match std::fs::read(&p) {
|
||
Ok(bytes) => match soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())) {
|
||
Ok((_, mut buf)) => {
|
||
if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 {
|
||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz());
|
||
buf.sample_rate = s.sample_rate.hz();
|
||
}
|
||
s.pool.insert(src.id, Arc::new(SourceAudio::new(buf)));
|
||
}
|
||
Err(err) => missing.push(format!("{}: {err}", p.display())),
|
||
},
|
||
Err(_) => missing.push(p.display().to_string()),
|
||
}
|
||
}
|
||
missing.extend(relink_videos(&mut s, &dir));
|
||
e.replace_session(s);
|
||
e.path = Some(path.to_string());
|
||
for m in &missing {
|
||
e.message(format!("missing media: {m}"));
|
||
}
|
||
Ok(missing)
|
||
}
|
||
|
||
/// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record").
|
||
/// `channels` is the planar input capture at `rate`; each track takes the input channels its
|
||
/// input route names ("In 3" → channel 2), defaulting to the first channels.
|
||
pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec<Vec<f32>>, rate: u32) -> Result<Vec<soundcraft_model::ClipId>> {
|
||
let frames = channels.first().map_or(0, Vec::len);
|
||
if frames == 0 {
|
||
return Ok(Vec::new());
|
||
}
|
||
let armed: Vec<(TrackId, usize, usize, String)> = e
|
||
.session()
|
||
.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,
|
||
};
|
||
(t.id, first, t.channels(), t.name.clone())
|
||
})
|
||
.collect();
|
||
if armed.is_empty() {
|
||
return Err(EngineError::BadParams("record".into(), "no tracks are record-enabled".into()));
|
||
}
|
||
let before = e.session_arc();
|
||
let mut out = Vec::new();
|
||
let take_no = e.session().sources.len() + 1;
|
||
for (tid, first, n, name) in armed {
|
||
let chans: Vec<Vec<f32>> = (0..n).map(|k| channels.get((first + k) % channels.len().max(1)).cloned().unwrap_or_default()).collect();
|
||
let buf = AudioBuffer { sample_rate: rate, channels: chans };
|
||
let clip_name = format!("{name}_{take_no:02}");
|
||
let s = e.session_mut();
|
||
let src = add_source(s, &clip_name, buf, None, FileFormat::Wav);
|
||
let len = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0));
|
||
let cid = s.new_clip_id();
|
||
crate::edit::place_clip(s, tid, Clip::audio(cid, clip_name, src, 0, start, len));
|
||
out.push(cid);
|
||
}
|
||
e.push_undo("Record", before);
|
||
Ok(out)
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
#[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 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);
|
||
}
|
||
}
|