Files
soundcraft/crates/engine/src/io/render.rs
T
Dylan M. Taylor 9f2be37e36 Enforce Rust coding standards via Clippy (#1129)
* chore(lints): apply strict workspace Clippy rules

* refactor(h264): split decoding work by operation

* refactor(h264): clarify prediction and filter inputs

* docs: require workspace lint inheritance

* refactor(audio): clarify host FFI imports and inputs

* docs: clarify crate lint placement

* refactor(audio): replace wildcard imports in I/O modules

* refactor(mix): extract strip and routing stages

* refactor(ui): split edit window event handlers

* refactor(ui): clarify app and dialog actions

* refactor(ui): simplify mixer and panel workflows

* fix(ui): apply track resize only once
2026-10-28 11:48:06 -04:00

211 lines
11 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.
use super::import::add_source;
#[cfg(test)]
use super::midi::{MAX_MIDI_METERS, import_midi_bytes};
use super::{AudioBuffer, Clip, ClipContent, Engine, EngineError, FileFormat, Range, Result, Samples, Session, TrackId, TrackKind};
/// 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 fmt = tr.format;
if fmt.channels() > soundcraft_mix::MAX_CHANNELS {
return Err(EngineError::BadParams(
"track.commit".into(),
format!("track commit supports at most {} channels; cannot render {}", soundcraft_mix::MAX_CHANNELS, fmt.label()),
));
}
let too_long = || EngineError::BadParams("track.commit".into(), format!("`{}` is too long to commit within the offline render limit", tr.name));
let end = end.checked_add(s.sample_rate.samples(2.0)).ok_or_else(too_long)?;
let frames = usize::try_from(end).map_err(|_| too_long())?;
// The shared renderer uses a stereo main mix for mono, then folds it back to mono.
// Reject requests it would truncate before rendering or replacing the source track.
if frames > soundcraft_mix::MAX_RENDER_SAMPLES / fmt.channels().max(2) {
return Err(too_long());
}
let range = Range::new(0, end);
let audio = soundcraft_mix::render_track_pre_fader(s, t, range);
if audio.len() != fmt.channels() || audio.iter().any(|channel| channel.len() != frames) {
return Err(EngineError::Io("track commit did not render the complete channel format and range".into()));
}
let name = format!("{}.cm", tr.name);
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 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() }
}
#[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"]);
}
}