Files
soundcraft/crates/model/src/track.rs
T
Brandon ThomasandClaude Opus 5.5 cdefc57c91 Video track, Audio Unit hosting, plugin state and editors, Renderer and UI Customization
Video (new standalone crate soundcraft-video):
- Pure-Rust H.264 (Baseline/Main/High), ProRes (all flavours) and Motion JPEG decoding with
  ISO-BMFF demux, adapted from FilmCraft's crates; fuzzed with mutated/truncated movies
- TrackKind::Video with ClipContent::Video referencing Session.videos; file.import_video adds
  the picture track and the movie's audio; relinking on open; video.offset/online/list
- Video and Video Universe windows, thumbnails on the timeline, background decoding

Third-party plugins:
- Plugin state (CLAP state ext, VST3 component + controller state) saved in sessions
- Hosted instances created, state-loaded and dropped on the UI thread, never the audio thread
- Plugin editors: floating CLAP GUIs and VST3 views on macOS (verified with Melodyne)
- New isolated FFI crate soundcraft-au-host: Audio Units on macOS (effects and instruments,
  parameters, latency, MIDI, ClassInfo state), wired into the mixer and plugin browser

UI: Window > Renderer (speaker layout with live levels and object positions), Window > UI
Customization (interface scale, reduce motion), Window > Configurations > Auto-Update.

Fix: saturating sample-index math in clip rendering (hostile stretch values overflowed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:41:05 -04:00

341 lines
10 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.
//! Tracks and playlists.
use crate::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipId, Mixer, TrackId};
use soundcraft_time::{Range, Samples};
/// Track types (Track › New).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
pub enum TrackKind {
#[default]
Audio,
Aux,
Master,
Midi,
Instrument,
Vca,
Folder,
/// Picture: clips reference a movie ([`crate::VideoSource`]); no audio path.
Video,
}
impl TrackKind {
pub const ALL: [TrackKind; 8] = [
TrackKind::Audio,
TrackKind::Aux,
TrackKind::Master,
TrackKind::Midi,
TrackKind::Instrument,
TrackKind::Vca,
TrackKind::Folder,
TrackKind::Video,
];
pub fn label(self) -> &'static str {
match self {
TrackKind::Audio => "Audio Track",
TrackKind::Aux => "Aux Input",
TrackKind::Master => "Master Fader",
TrackKind::Midi => "MIDI Track",
TrackKind::Instrument => "Instrument Track",
TrackKind::Vca => "VCA Master",
TrackKind::Folder => "Routing Folder Track",
TrackKind::Video => "Video Track",
}
}
pub fn default_name(self) -> &'static str {
match self {
TrackKind::Audio => "Audio",
TrackKind::Aux => "Aux",
TrackKind::Master => "Master",
TrackKind::Midi => "MIDI",
TrackKind::Instrument => "Inst",
TrackKind::Vca => "VCA",
TrackKind::Folder => "Folder",
TrackKind::Video => "Video",
}
}
pub fn id(self) -> &'static str {
match self {
TrackKind::Audio => "audio",
TrackKind::Aux => "aux",
TrackKind::Master => "master",
TrackKind::Midi => "midi",
TrackKind::Instrument => "instrument",
TrackKind::Vca => "vca",
TrackKind::Folder => "folder",
TrackKind::Video => "video",
}
}
pub fn from_id(s: &str) -> Option<TrackKind> {
let s = s.to_ascii_lowercase();
TrackKind::ALL.into_iter().find(|k| k.id() == s || k.label().eq_ignore_ascii_case(&s))
}
/// Track kinds that hold clips on a timeline.
pub fn has_playlist(self) -> bool {
matches!(self, TrackKind::Audio | TrackKind::Midi | TrackKind::Instrument | TrackKind::Video)
}
pub fn is_midi(self) -> bool {
matches!(self, TrackKind::Midi | TrackKind::Instrument)
}
/// Kinds that pass audio through the mixer.
pub fn is_audio_path(self) -> bool {
matches!(self, TrackKind::Audio | TrackKind::Aux | TrackKind::Master | TrackKind::Instrument | TrackKind::Folder)
}
}
/// Track heights (View › Track Height presets).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
pub enum TrackHeight {
Micro,
Mini,
Small,
#[default]
Medium,
Large,
Jumbo,
Extreme,
Fit,
}
impl TrackHeight {
pub const ALL: [TrackHeight; 7] = [
TrackHeight::Micro,
TrackHeight::Mini,
TrackHeight::Small,
TrackHeight::Medium,
TrackHeight::Large,
TrackHeight::Jumbo,
TrackHeight::Extreme,
];
pub fn points(self) -> f32 {
match self {
TrackHeight::Micro => 16.0,
TrackHeight::Mini => 24.0,
TrackHeight::Small => 46.0,
TrackHeight::Medium => 88.0,
TrackHeight::Large => 132.0,
TrackHeight::Jumbo => 220.0,
TrackHeight::Extreme => 440.0,
TrackHeight::Fit => 88.0,
}
}
pub fn label(self) -> &'static str {
match self {
TrackHeight::Micro => "micro",
TrackHeight::Mini => "mini",
TrackHeight::Small => "small",
TrackHeight::Medium => "medium",
TrackHeight::Large => "large",
TrackHeight::Jumbo => "jumbo",
TrackHeight::Extreme => "extreme",
TrackHeight::Fit => "fit to window",
}
}
pub fn from_id(s: &str) -> Option<TrackHeight> {
TrackHeight::ALL.into_iter().find(|h| h.label().eq_ignore_ascii_case(s))
}
}
/// The default track colour palette (original, chosen for SoundCraft).
pub const TRACK_COLORS: [[u8; 3]; 16] = [
[86, 132, 214],
[94, 170, 214],
[80, 190, 168],
[104, 186, 98],
[168, 196, 76],
[222, 196, 72],
[230, 152, 62],
[222, 104, 72],
[214, 82, 110],
[196, 86, 168],
[150, 98, 206],
[110, 104, 214],
[130, 150, 170],
[178, 140, 110],
[120, 170, 140],
[190, 120, 150],
];
/// A playlist: an alternative take of a track's clips.
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct Playlist {
pub name: String,
/// Clips sorted by start; non-overlapping except at crossfades.
pub clips: Vec<Clip>,
}
impl Playlist {
pub fn new(name: impl Into<String>) -> Self {
Playlist { name: name.into(), clips: Vec::new() }
}
pub fn sort(&mut self) {
self.clips.sort_by_key(|c| (c.start, c.id.0));
}
pub fn clip(&self, id: ClipId) -> Option<&Clip> {
self.clips.iter().find(|c| c.id == id)
}
pub fn clip_mut(&mut self, id: ClipId) -> Option<&mut Clip> {
self.clips.iter_mut().find(|c| c.id == id)
}
pub fn clips_in(&self, r: Range) -> impl Iterator<Item = &Clip> {
self.clips.iter().filter(move |c| c.range().overlaps(&r))
}
pub fn clip_at(&self, at: Samples) -> Option<&Clip> {
self.clips.iter().rev().find(|c| c.range().contains(at))
}
pub fn end(&self) -> Samples {
self.clips.iter().map(Clip::end).max().unwrap_or(0)
}
}
/// A track.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Track {
pub id: TrackId,
pub name: String,
pub kind: TrackKind,
pub format: ChannelFormat,
pub color: [u8; 3],
pub height: TrackHeight,
pub playlists: Vec<Playlist>,
pub active_playlist: usize,
pub mixer: Mixer,
pub automation: Vec<AutomationLane>,
/// Which lane the track shows: `waveform`/`blocks`/`notes`/`regions` or an automation param id.
pub view: String,
#[serde(default)]
pub hidden: bool,
#[serde(default)]
pub inactive: bool,
#[serde(default)]
pub frozen: bool,
#[serde(default)]
pub comments: String,
/// Folder that contains this track.
#[serde(default)]
pub folder: Option<TrackId>,
#[serde(default = "yes")]
pub folder_open: bool,
/// Instrument plugin (instrument tracks) — registry id.
#[serde(default)]
pub instrument: Option<crate::Insert>,
/// Ticks (musical) or samples timebase for clips.
#[serde(default)]
pub ticks_timebase: bool,
/// Elastic Audio algorithm, if enabled.
#[serde(default)]
pub elastic: Option<String>,
/// Pinned to top in the Edit window.
#[serde(default)]
pub pinned: bool,
}
fn yes() -> bool {
true
}
impl Track {
pub fn new(id: TrackId, name: impl Into<String>, kind: TrackKind, format: ChannelFormat, color: [u8; 3]) -> Self {
let name = name.into();
let format = if kind.is_midi() && kind == TrackKind::Midi { ChannelFormat::Mono } else { format };
let view = if kind.is_midi() {
"notes"
} else if kind.has_playlist() {
"waveform"
} else {
"volume"
};
Track {
id,
playlists: vec![Playlist::new(name.clone())],
name,
kind,
format,
color,
height: TrackHeight::Medium,
active_playlist: 0,
mixer: Mixer::new(format),
automation: Vec::new(),
view: view.into(),
hidden: false,
inactive: false,
frozen: false,
comments: String::new(),
folder: None,
folder_open: true,
instrument: None,
ticks_timebase: kind.is_midi(),
elastic: None,
pinned: false,
}
}
pub fn channels(&self) -> usize {
self.format.channels()
}
pub fn playlist(&self) -> Option<&Playlist> {
self.playlists.get(self.active_playlist)
}
pub fn playlist_mut(&mut self) -> Option<&mut Playlist> {
self.playlists.get_mut(self.active_playlist)
}
pub fn clips(&self) -> &[Clip] {
self.playlist().map_or(&[], |p| p.clips.as_slice())
}
pub fn lane(&self, p: &AutoParam) -> Option<&AutomationLane> {
self.automation.iter().find(|l| &l.param == p)
}
pub fn lane_mut(&mut self, p: &AutoParam) -> &mut AutomationLane {
let idx = match self.automation.iter().position(|l| &l.param == p) {
Some(i) => i,
None => {
self.automation.push(AutomationLane::new(p.clone()));
self.automation.len() - 1
}
};
let len = self.automation.len();
// `idx` is in range: it came from `position` or is the index of the element just pushed.
&mut self.automation[idx.min(len - 1)]
}
pub fn is_folder(&self) -> bool {
self.kind == TrackKind::Folder
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_track_defaults() {
let t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Stereo, TRACK_COLORS[0]);
assert_eq!(t.channels(), 2);
assert_eq!(t.view, "waveform");
assert_eq!(t.playlists.len(), 1);
let m = Track::new(TrackId(2), "Keys", TrackKind::Midi, ChannelFormat::Stereo, TRACK_COLORS[0]);
assert_eq!(m.format, ChannelFormat::Mono);
assert_eq!(m.view, "notes");
}
#[test]
fn lane_mut_creates_once() {
let mut t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Mono, TRACK_COLORS[0]);
t.lane_mut(&AutoParam::Volume).set_point(0, -3.0);
t.lane_mut(&AutoParam::Volume).set_point(10, -6.0);
assert_eq!(t.automation.len(), 1);
assert_eq!(t.lane(&AutoParam::Volume).map(|l| l.points.len()), Some(2));
}
#[test]
fn kind_ids() {
for k in TrackKind::ALL {
assert_eq!(TrackKind::from_id(k.id()), Some(k));
}
}
}