commit 725b18dc72c63b703ccc4426ef6aca677eecf61b Author: jess Date: Mon Mar 30 18:34:56 2026 -0700 baseline: recover workspace from iCloud damage diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..182e44c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.code-workspace +*.py +*.lock +target/ +.DS_Store +.worktree/ +.claude/ +.loki/ +.idea/ +.vscode/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8ef35bd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[workspace] +resolver = "3" +exclude = [ + "oxide-modules/input", + "oxide-modules/output", +] +default-members = ["au-o2-gui"] +members = [ + "oxforge", + "au-o2-gui", + "oxide-modules/hilbert", + "oxide-modules/region_player", + "oxide-modules/input_router", + "oxide-modules/recorder", + "oxide-modules/output_mixer", + "oxide-modules/spiral_visualizer", + "oxide-modules/passthrough", + "oxide-modules/latency", + "oxide-modules/metronome_midi", + "oxide-modules/click_instrument", + "oxide-modules/gain", + "oxide-modules/eq", + "oxide-modules/compressor", + "oxide-modules/midi_player", + "oxide-modules/phase_compressor", +] diff --git a/au-o2-gui/.gitignore b/au-o2-gui/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/au-o2-gui/.gitignore @@ -0,0 +1 @@ +/target diff --git a/au-o2-gui/Cargo.toml b/au-o2-gui/Cargo.toml new file mode 100644 index 0000000..40ad401 --- /dev/null +++ b/au-o2-gui/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "au-o2-gui" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "au-o2-gui" +path = "src/main.rs" + +[features] +debug-log = [] + +[dependencies] +# --- GUI Framework --- +iced = { version = "0.13.1", features = ["tokio", "debug", "canvas", "advanced", "multi-window", "system", "svg", "tiny-skia", "web-colors", "markdown", "image"] } + +# --- Core Audio & Hardware --- +cpal = "0.16.0" +ringbuf = "0.4.8" +crossbeam-channel = "0.5.12" +rubato = "0.14" + +# --- Module System --- +oxforge = { path = "../oxforge" } +libloading = "0.8" +rustfft = "6" + +# --- Oxide Modules --- +oxide-hilbert = { path = "../oxide-modules/hilbert" } +oxide-region-player = { path = "../oxide-modules/region_player" } +oxide-input-router = { path = "../oxide-modules/input_router" } +oxide-recorder = { path = "../oxide-modules/recorder" } +oxide-output-mixer = { path = "../oxide-modules/output_mixer" } +oxide-spiral-visualizer = { path = "../oxide-modules/spiral_visualizer" } +oxide-metronome-midi = { path = "../oxide-modules/metronome_midi" } +oxide-click-instrument = { path = "../oxide-modules/click_instrument" } +oxide-gain = { path = "../oxide-modules/gain" } +oxide-eq = { path = "../oxide-modules/eq" } +oxide-compressor = { path = "../oxide-modules/compressor" } +oxide-midi-player = { path = "../oxide-modules/midi_player" } + +# --- Codec --- +flacenc = "0.4" +claxon = "0.4" +hound = "3.5" + +# --- Configuration & Project Management --- +confy = "1.0.0" +serde = { version = "1.0", features = ["derive"] } +toml = "0.9.7" +dirs = "6.0.0" +uuid = { version = "1.18.1", features = ["v4", "serde"] } +chrono = { version = "0.4.42", features = ["serde"] } +tokio = "1.47.1" +muda = { version = "0.17.1", default-features = false } diff --git a/au-o2-gui/assets/Info.plist b/au-o2-gui/assets/Info.plist new file mode 100644 index 0000000..7986821 --- /dev/null +++ b/au-o2-gui/assets/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleName + Audio Oxide + CFBundleDisplayName + Audio Oxide + CFBundleIdentifier + org.else-if.audio-oxide + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1.0 + CFBundlePackageType + APPL + CFBundleExecutable + au-o2-gui + CFBundleIconFile + icon + NSHighResolutionCapable + + LSMinimumSystemVersion + 13.0 + NSMicrophoneUsageDescription + Audio Oxide needs microphone access for audio recording. + + diff --git a/au-o2-gui/assets/icon.icns b/au-o2-gui/assets/icon.icns new file mode 100644 index 0000000..49606c8 Binary files /dev/null and b/au-o2-gui/assets/icon.icns differ diff --git a/au-o2-gui/assets/icon.svg b/au-o2-gui/assets/icon.svg new file mode 100644 index 0000000..22e2a80 --- /dev/null +++ b/au-o2-gui/assets/icon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + Au + 2 + O + 3 + + diff --git a/au-o2-gui/assets/icons/add.svg b/au-o2-gui/assets/icons/add.svg new file mode 100644 index 0000000..04421c1 --- /dev/null +++ b/au-o2-gui/assets/icons/add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/automation.svg b/au-o2-gui/assets/icons/automation.svg new file mode 100644 index 0000000..0ab55a0 --- /dev/null +++ b/au-o2-gui/assets/icons/automation.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/au-o2-gui/assets/icons/close.svg b/au-o2-gui/assets/icons/close.svg new file mode 100644 index 0000000..bec5a18 --- /dev/null +++ b/au-o2-gui/assets/icons/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/copy.svg b/au-o2-gui/assets/icons/copy.svg new file mode 100644 index 0000000..ad20fed --- /dev/null +++ b/au-o2-gui/assets/icons/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/count-in.svg b/au-o2-gui/assets/icons/count-in.svg new file mode 100644 index 0000000..79f41eb --- /dev/null +++ b/au-o2-gui/assets/icons/count-in.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/au-o2-gui/assets/icons/cut.svg b/au-o2-gui/assets/icons/cut.svg new file mode 100644 index 0000000..1112222 --- /dev/null +++ b/au-o2-gui/assets/icons/cut.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/au-o2-gui/assets/icons/cycle.svg b/au-o2-gui/assets/icons/cycle.svg new file mode 100644 index 0000000..ad489d9 --- /dev/null +++ b/au-o2-gui/assets/icons/cycle.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/au-o2-gui/assets/icons/eq.svg b/au-o2-gui/assets/icons/eq.svg new file mode 100644 index 0000000..4a9f74a --- /dev/null +++ b/au-o2-gui/assets/icons/eq.svg @@ -0,0 +1,3 @@ + + + diff --git a/au-o2-gui/assets/icons/fast-forward.svg b/au-o2-gui/assets/icons/fast-forward.svg new file mode 100644 index 0000000..9aade41 --- /dev/null +++ b/au-o2-gui/assets/icons/fast-forward.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/folder.svg b/au-o2-gui/assets/icons/folder.svg new file mode 100644 index 0000000..49ef941 --- /dev/null +++ b/au-o2-gui/assets/icons/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/au-o2-gui/assets/icons/freeze.svg b/au-o2-gui/assets/icons/freeze.svg new file mode 100644 index 0000000..e2064b4 --- /dev/null +++ b/au-o2-gui/assets/icons/freeze.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/au-o2-gui/assets/icons/input-monitor.svg b/au-o2-gui/assets/icons/input-monitor.svg new file mode 100644 index 0000000..f6f5f7b --- /dev/null +++ b/au-o2-gui/assets/icons/input-monitor.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/au-o2-gui/assets/icons/insert.svg b/au-o2-gui/assets/icons/insert.svg new file mode 100644 index 0000000..a9d1eaf --- /dev/null +++ b/au-o2-gui/assets/icons/insert.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/au-o2-gui/assets/icons/io.svg b/au-o2-gui/assets/icons/io.svg new file mode 100644 index 0000000..85a7701 --- /dev/null +++ b/au-o2-gui/assets/icons/io.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/au-o2-gui/assets/icons/lock.svg b/au-o2-gui/assets/icons/lock.svg new file mode 100644 index 0000000..1215e63 --- /dev/null +++ b/au-o2-gui/assets/icons/lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/metronome.svg b/au-o2-gui/assets/icons/metronome.svg new file mode 100644 index 0000000..6a3d2a2 --- /dev/null +++ b/au-o2-gui/assets/icons/metronome.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/au-o2-gui/assets/icons/mute.svg b/au-o2-gui/assets/icons/mute.svg new file mode 100644 index 0000000..8e9c58c --- /dev/null +++ b/au-o2-gui/assets/icons/mute.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/au-o2-gui/assets/icons/pan.svg b/au-o2-gui/assets/icons/pan.svg new file mode 100644 index 0000000..1eb7d6f --- /dev/null +++ b/au-o2-gui/assets/icons/pan.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/au-o2-gui/assets/icons/paste.svg b/au-o2-gui/assets/icons/paste.svg new file mode 100644 index 0000000..b2b3c57 --- /dev/null +++ b/au-o2-gui/assets/icons/paste.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/pause.svg b/au-o2-gui/assets/icons/pause.svg new file mode 100644 index 0000000..0095d8f --- /dev/null +++ b/au-o2-gui/assets/icons/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/play.svg b/au-o2-gui/assets/icons/play.svg new file mode 100644 index 0000000..c30fb02 --- /dev/null +++ b/au-o2-gui/assets/icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/au-o2-gui/assets/icons/punch.svg b/au-o2-gui/assets/icons/punch.svg new file mode 100644 index 0000000..d575374 --- /dev/null +++ b/au-o2-gui/assets/icons/punch.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/au-o2-gui/assets/icons/record-arm.svg b/au-o2-gui/assets/icons/record-arm.svg new file mode 100644 index 0000000..3152108 --- /dev/null +++ b/au-o2-gui/assets/icons/record-arm.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/record.svg b/au-o2-gui/assets/icons/record.svg new file mode 100644 index 0000000..c67bf05 --- /dev/null +++ b/au-o2-gui/assets/icons/record.svg @@ -0,0 +1,3 @@ + + + diff --git a/au-o2-gui/assets/icons/redo.svg b/au-o2-gui/assets/icons/redo.svg new file mode 100644 index 0000000..37315fb --- /dev/null +++ b/au-o2-gui/assets/icons/redo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/remove.svg b/au-o2-gui/assets/icons/remove.svg new file mode 100644 index 0000000..aafb382 --- /dev/null +++ b/au-o2-gui/assets/icons/remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/au-o2-gui/assets/icons/rewind.svg b/au-o2-gui/assets/icons/rewind.svg new file mode 100644 index 0000000..e940897 --- /dev/null +++ b/au-o2-gui/assets/icons/rewind.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/rtz.svg b/au-o2-gui/assets/icons/rtz.svg new file mode 100644 index 0000000..f348f8e --- /dev/null +++ b/au-o2-gui/assets/icons/rtz.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/save.svg b/au-o2-gui/assets/icons/save.svg new file mode 100644 index 0000000..73e5407 --- /dev/null +++ b/au-o2-gui/assets/icons/save.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/au-o2-gui/assets/icons/search.svg b/au-o2-gui/assets/icons/search.svg new file mode 100644 index 0000000..895e4ae --- /dev/null +++ b/au-o2-gui/assets/icons/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/send.svg b/au-o2-gui/assets/icons/send.svg new file mode 100644 index 0000000..4598aa3 --- /dev/null +++ b/au-o2-gui/assets/icons/send.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/settings.svg b/au-o2-gui/assets/icons/settings.svg new file mode 100644 index 0000000..ba6331a --- /dev/null +++ b/au-o2-gui/assets/icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/solo.svg b/au-o2-gui/assets/icons/solo.svg new file mode 100644 index 0000000..4ca1b27 --- /dev/null +++ b/au-o2-gui/assets/icons/solo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/au-o2-gui/assets/icons/stop.svg b/au-o2-gui/assets/icons/stop.svg new file mode 100644 index 0000000..4310a07 --- /dev/null +++ b/au-o2-gui/assets/icons/stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/au-o2-gui/assets/icons/tool-eraser.svg b/au-o2-gui/assets/icons/tool-eraser.svg new file mode 100644 index 0000000..b7546e4 --- /dev/null +++ b/au-o2-gui/assets/icons/tool-eraser.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/tool-glue.svg b/au-o2-gui/assets/icons/tool-glue.svg new file mode 100644 index 0000000..8359f8c --- /dev/null +++ b/au-o2-gui/assets/icons/tool-glue.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/au-o2-gui/assets/icons/tool-pencil.svg b/au-o2-gui/assets/icons/tool-pencil.svg new file mode 100644 index 0000000..30bea73 --- /dev/null +++ b/au-o2-gui/assets/icons/tool-pencil.svg @@ -0,0 +1,3 @@ + + + diff --git a/au-o2-gui/assets/icons/tool-pointer.svg b/au-o2-gui/assets/icons/tool-pointer.svg new file mode 100644 index 0000000..3908f53 --- /dev/null +++ b/au-o2-gui/assets/icons/tool-pointer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/tool-scissors.svg b/au-o2-gui/assets/icons/tool-scissors.svg new file mode 100644 index 0000000..1112222 --- /dev/null +++ b/au-o2-gui/assets/icons/tool-scissors.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/au-o2-gui/assets/icons/tool-zoom.svg b/au-o2-gui/assets/icons/tool-zoom.svg new file mode 100644 index 0000000..895e4ae --- /dev/null +++ b/au-o2-gui/assets/icons/tool-zoom.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/track-audio.svg b/au-o2-gui/assets/icons/track-audio.svg new file mode 100644 index 0000000..c67e43b --- /dev/null +++ b/au-o2-gui/assets/icons/track-audio.svg @@ -0,0 +1,3 @@ + + + diff --git a/au-o2-gui/assets/icons/track-aux.svg b/au-o2-gui/assets/icons/track-aux.svg new file mode 100644 index 0000000..e392298 --- /dev/null +++ b/au-o2-gui/assets/icons/track-aux.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/au-o2-gui/assets/icons/track-bus.svg b/au-o2-gui/assets/icons/track-bus.svg new file mode 100644 index 0000000..31f4b10 --- /dev/null +++ b/au-o2-gui/assets/icons/track-bus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/au-o2-gui/assets/icons/track-midi.svg b/au-o2-gui/assets/icons/track-midi.svg new file mode 100644 index 0000000..387858e --- /dev/null +++ b/au-o2-gui/assets/icons/track-midi.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/au-o2-gui/assets/icons/undo.svg b/au-o2-gui/assets/icons/undo.svg new file mode 100644 index 0000000..cfe1aa9 --- /dev/null +++ b/au-o2-gui/assets/icons/undo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/view-clip-launcher.svg b/au-o2-gui/assets/icons/view-clip-launcher.svg new file mode 100644 index 0000000..0a548b0 --- /dev/null +++ b/au-o2-gui/assets/icons/view-clip-launcher.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/au-o2-gui/assets/icons/view-editor.svg b/au-o2-gui/assets/icons/view-editor.svg new file mode 100644 index 0000000..6243e2d --- /dev/null +++ b/au-o2-gui/assets/icons/view-editor.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/au-o2-gui/assets/icons/view-inspector.svg b/au-o2-gui/assets/icons/view-inspector.svg new file mode 100644 index 0000000..ad4694e --- /dev/null +++ b/au-o2-gui/assets/icons/view-inspector.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/au-o2-gui/assets/icons/view-library.svg b/au-o2-gui/assets/icons/view-library.svg new file mode 100644 index 0000000..12a97d5 --- /dev/null +++ b/au-o2-gui/assets/icons/view-library.svg @@ -0,0 +1,4 @@ + + + + diff --git a/au-o2-gui/assets/icons/view-mixer.svg b/au-o2-gui/assets/icons/view-mixer.svg new file mode 100644 index 0000000..39edbcc --- /dev/null +++ b/au-o2-gui/assets/icons/view-mixer.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/au-o2-gui/assets/icons/view-notepad.svg b/au-o2-gui/assets/icons/view-notepad.svg new file mode 100644 index 0000000..16cd7c1 --- /dev/null +++ b/au-o2-gui/assets/icons/view-notepad.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/au-o2-gui/assets/icons/view-step-seq.svg b/au-o2-gui/assets/icons/view-step-seq.svg new file mode 100644 index 0000000..6db0166 --- /dev/null +++ b/au-o2-gui/assets/icons/view-step-seq.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/au-o2-gui/assets/icons/view-toolbar.svg b/au-o2-gui/assets/icons/view-toolbar.svg new file mode 100644 index 0000000..22b7dca --- /dev/null +++ b/au-o2-gui/assets/icons/view-toolbar.svg @@ -0,0 +1,3 @@ + + + diff --git a/au-o2-gui/assets/icons/view-visualizer.svg b/au-o2-gui/assets/icons/view-visualizer.svg new file mode 100644 index 0000000..65728f7 --- /dev/null +++ b/au-o2-gui/assets/icons/view-visualizer.svg @@ -0,0 +1,5 @@ + + + + diff --git a/au-o2-gui/assets/logo-placeholder 2.svg b/au-o2-gui/assets/logo-placeholder 2.svg new file mode 100644 index 0000000..fd98181 --- /dev/null +++ b/au-o2-gui/assets/logo-placeholder 2.svg @@ -0,0 +1,26 @@ + + + + + + + + + + Au + 2 + O + 3 + + + diff --git a/au-o2-gui/assets/logo-placeholder.svg b/au-o2-gui/assets/logo-placeholder.svg new file mode 100644 index 0000000..bf51f9c --- /dev/null +++ b/au-o2-gui/assets/logo-placeholder.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + diff --git a/au-o2-gui/src/automation.rs b/au-o2-gui/src/automation.rs new file mode 100644 index 0000000..127a464 --- /dev/null +++ b/au-o2-gui/src/automation.rs @@ -0,0 +1,140 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AutomationMode { + Off, + Read, + Write, + Touch, + Latch, +} + +impl Default for AutomationMode { + fn default() -> Self { Self::Off } +} + +impl AutomationMode { + pub const ALL: [AutomationMode; 5] = [ + AutomationMode::Off, + AutomationMode::Read, + AutomationMode::Write, + AutomationMode::Touch, + AutomationMode::Latch, + ]; + + pub fn reads(&self) -> bool { + matches!(self, Self::Read | Self::Touch | Self::Latch) + } + + pub fn writes(&self) -> bool { + matches!(self, Self::Write | Self::Touch | Self::Latch) + } +} + +impl std::fmt::Display for AutomationMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Off => write!(f, "Off"), + Self::Read => write!(f, "Read"), + Self::Write => write!(f, "Write"), + Self::Touch => write!(f, "Touch"), + Self::Latch => write!(f, "Latch"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum AutomationTarget { + Volume, + Pan, + Mute, + ModuleParam { module_id: u32, key: String }, +} + +impl std::fmt::Display for AutomationTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Volume => write!(f, "Volume"), + Self::Pan => write!(f, "Pan"), + Self::Mute => write!(f, "Mute"), + Self::ModuleParam { key, .. } => write!(f, "{key}"), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct AutomationPoint { + pub sample_pos: u64, + pub value: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationLane { + pub target: AutomationTarget, + pub points: Vec, + pub visible: bool, +} + +impl AutomationLane { + pub fn new(target: AutomationTarget) -> Self { + Self { + target, + points: Vec::new(), + visible: true, + } + } + + pub fn insert_point(&mut self, sample_pos: u64, value: f32) { + let pt = AutomationPoint { sample_pos, value }; + match self.points.binary_search_by_key(&sample_pos, |p| p.sample_pos) { + Ok(idx) => self.points[idx] = pt, + Err(idx) => self.points.insert(idx, pt), + } + } + + pub fn remove_point(&mut self, index: usize) { + if index < self.points.len() { + self.points.remove(index); + } + } + + /// Linear interpolation at a given sample position + pub fn value_at(&self, sample_pos: u64) -> Option { + if self.points.is_empty() { + return None; + } + if self.points.len() == 1 { + return Some(self.points[0].value); + } + + let first = &self.points[0]; + if sample_pos <= first.sample_pos { + return Some(first.value); + } + + let last = &self.points[self.points.len() - 1]; + if sample_pos >= last.sample_pos { + return Some(last.value); + } + + // Binary search for surrounding points + let idx = match self.points.binary_search_by_key(&sample_pos, |p| p.sample_pos) { + Ok(i) => return Some(self.points[i].value), + Err(i) => i, + }; + + let a = &self.points[idx - 1]; + let b = &self.points[idx]; + let t = (sample_pos - a.sample_pos) as f32 / (b.sample_pos - a.sample_pos) as f32; + Some(a.value + (b.value - a.value) * t) + } + + pub fn value_range(&self) -> (f32, f32) { + match &self.target { + AutomationTarget::Volume => (0.0, 1.5), + AutomationTarget::Pan => (-1.0, 1.0), + AutomationTarget::Mute => (0.0, 1.0), + AutomationTarget::ModuleParam { .. } => (0.0, 1.0), + } + } +} diff --git a/au-o2-gui/src/behaviors/mod.rs b/au-o2-gui/src/behaviors/mod.rs new file mode 100644 index 0000000..cb2ff6f --- /dev/null +++ b/au-o2-gui/src/behaviors/mod.rs @@ -0,0 +1,49 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + // Time utility + TimeUtilityTapPressed, + TimeUtilityTapReleased, + + // File menu (Cmd+key) + NewProject, + OpenProject, + SaveProject, + SaveProjectAs, + CloseProject, + + // App menu + OpenSettings, + + // Edit menu (Cmd+key) + Undo, + Redo, + Cut, + Copy, + Paste, + Duplicate, + SelectAll, + Delete, + + // Editor transport + EditorTogglePlayback, + EditorStop, + EditorToggleRecord, + EditorPlayFromBeginning, + EditorRewind, + + // Editor view toggles + EditorToggleInspector, + EditorToggleBottomPanel, + EditorToggleMixer, + EditorToggleToolbar, + + // Editor mode toggles + EditorToggleCycle, + EditorToggleMetronome, + + // Editor zoom + ZoomInH, + ZoomOutH, + ZoomInV, + ZoomOutV, +} diff --git a/au-o2-gui/src/clipboard.rs b/au-o2-gui/src/clipboard.rs new file mode 100644 index 0000000..a461cc8 --- /dev/null +++ b/au-o2-gui/src/clipboard.rs @@ -0,0 +1,18 @@ +use crate::region::Region; + +#[derive(Debug, Clone)] +pub struct ClipboardEntry { + pub region: Region, + pub source_track_index: usize, +} + +#[derive(Debug, Clone, Default)] +pub struct Clipboard { + pub entries: Vec, +} + +impl Clipboard { + pub fn new() -> Self { Self { entries: Vec::new() } } + pub fn is_empty(&self) -> bool { self.entries.is_empty() } + pub fn clear(&mut self) { self.entries.clear(); } +} diff --git a/au-o2-gui/src/codec/error.rs b/au-o2-gui/src/codec/error.rs new file mode 100644 index 0000000..77ea3ab --- /dev/null +++ b/au-o2-gui/src/codec/error.rs @@ -0,0 +1,28 @@ +use std::fmt; + +#[derive(Debug)] +pub enum XtcError { + Io(std::io::Error), + Encode(String), + Decode(String), + InvalidFormat(String), +} + +impl fmt::Display for XtcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + XtcError::Io(e) => write!(f, "I/O error: {}", e), + XtcError::Encode(e) => write!(f, "encode error: {}", e), + XtcError::Decode(e) => write!(f, "decode error: {}", e), + XtcError::InvalidFormat(e) => write!(f, "invalid format: {}", e), + } + } +} + +impl std::error::Error for XtcError {} + +impl From for XtcError { + fn from(e: std::io::Error) -> Self { + XtcError::Io(e) + } +} diff --git a/au-o2-gui/src/codec/mod.rs b/au-o2-gui/src/codec/mod.rs new file mode 100644 index 0000000..c78d81c --- /dev/null +++ b/au-o2-gui/src/codec/mod.rs @@ -0,0 +1,4 @@ +mod error; +mod xtc; + +pub use xtc::{XtcDecoder, XtcEncoder}; diff --git a/au-o2-gui/src/codec/xtc.rs b/au-o2-gui/src/codec/xtc.rs new file mode 100644 index 0000000..88c78ca --- /dev/null +++ b/au-o2-gui/src/codec/xtc.rs @@ -0,0 +1,239 @@ +use std::path::Path; + +use flacenc::component::BitRepr; +use flacenc::error::Verify; + +use super::error::XtcError; + +const XTC_VERSION: &str = "1"; +const XTC_CHANNEL_LAYOUT: &str = "REAL_L,REAL_R,HILBERT_L,HILBERT_R"; +const CHANNELS: u32 = 4; + +pub struct XtcEncoder { + sample_rate: u32, + bit_depth: u16, + fft_size: u32, +} + +impl XtcEncoder { + pub fn new(sample_rate: u32, bit_depth: u16, fft_size: u32) -> Self { + Self { sample_rate, bit_depth, fft_size } + } + + pub fn encode_to_file( + &self, + path: &Path, + real_l: &[f32], + real_r: &[f32], + imag_l: &[f32], + imag_r: &[f32], + ) -> Result<(), XtcError> { + let n = real_l.len(); + if real_r.len() != n || imag_l.len() != n || imag_r.len() != n { + return Err(XtcError::Encode("channel length mismatch".into())); + } + + let interleaved = interleave_4ch(real_l, real_r, imag_l, imag_r, self.bit_depth); + + let config = flacenc::config::Encoder::default() + .into_verified() + .map_err(|e| XtcError::Encode(format!("{:?}", e)))?; + + let source = flacenc::source::MemSource::from_samples( + &interleaved, + CHANNELS as usize, + self.bit_depth as usize, + self.sample_rate as usize, + ); + + let mut stream = flacenc::encode_with_fixed_block_size(&config, source, config.block_size) + .map_err(|e| XtcError::Encode(format!("{:?}", e)))?; + + let vorbis_block = build_vorbis_comment(self.fft_size)?; + stream.add_metadata_block(vorbis_block); + + let mut sink = flacenc::bitsink::ByteSink::new(); + stream.write(&mut sink) + .map_err(|_| XtcError::Encode("failed to write stream".into()))?; + + std::fs::write(path, sink.as_slice())?; + Ok(()) + } +} + +#[allow(dead_code)] // metadata fields parsed from XTC header, used in tests + future playback +pub struct XtcDecoder { + pub sample_rate: u32, + pub bit_depth: u32, + pub fft_size: u32, + pub total_samples: u64, +} + +impl XtcDecoder { + pub fn open(path: &Path) -> Result { + let reader = claxon::FlacReader::open(path) + .map_err(|e| XtcError::Decode(format!("{}", e)))?; + + let info = reader.streaminfo(); + if info.channels != CHANNELS { + return Err(XtcError::InvalidFormat( + format!("expected {} channels, got {}", CHANNELS, info.channels), + )); + } + + let mut fft_size = 2048u32; + for val in reader.get_tag("XTC_FFT_SIZE") { + if let Ok(v) = val.parse::() { + fft_size = v; + } + } + + Ok(Self { + sample_rate: info.sample_rate, + bit_depth: info.bits_per_sample, + total_samples: info.samples.unwrap_or(0), + fft_size, + }) + } + + pub fn decode_real(&self, path: &Path) -> Result<(Vec, Vec), XtcError> { + let mut reader = claxon::FlacReader::open(path) + .map_err(|e| XtcError::Decode(format!("{}", e)))?; + + let info = reader.streaminfo(); + let scale = f32_scale(info.bits_per_sample); + let ch = info.channels as usize; + let n = info.samples.unwrap_or(0) as usize; + let total = n * ch; + let mut left = Vec::with_capacity(n); + let mut right = Vec::with_capacity(n); + + let mut idx = 0usize; + for sample in reader.samples() { + if idx >= total { break; } + let s = sample.map_err(|e| XtcError::Decode(format!("{}", e)))?; + let v = s as f32 * scale; + match idx % ch { + 0 => left.push(v), + 1 => right.push(v), + _ => {} + } + idx += 1; + } + + Ok((left, right)) + } +} + +fn f32_scale(bits_per_sample: u32) -> f32 { + let bits = bits_per_sample.clamp(1, 32); + 1.0 / ((1i64 << (bits - 1)) - 1) as f32 +} + +fn quantize(sample: f32, bit_depth: u16) -> i32 { + let bits = bit_depth.clamp(1, 32); + let max = ((1i64 << (bits - 1)) - 1) as f32; + (sample.clamp(-1.0, 1.0) * max) as i32 +} + +fn interleave_4ch( + ch0: &[f32], + ch1: &[f32], + ch2: &[f32], + ch3: &[f32], + bit_depth: u16, +) -> Vec { + let n = ch0.len(); + let mut out = Vec::with_capacity(n * 4); + for i in 0..n { + out.push(quantize(ch0[i], bit_depth)); + out.push(quantize(ch1[i], bit_depth)); + out.push(quantize(ch2[i], bit_depth)); + out.push(quantize(ch3[i], bit_depth)); + } + out +} + +fn build_vorbis_comment(fft_size: u32) -> Result { + let vendor = b"audio-oxide"; + let comments = [ + format!("XTC_VERSION={}", XTC_VERSION), + format!("XTC_CHANNEL_LAYOUT={}", XTC_CHANNEL_LAYOUT), + format!("XTC_FFT_SIZE={}", fft_size), + ]; + + let mut data = Vec::new(); + + // Vendor string (LE u32 length + bytes) + data.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); + data.extend_from_slice(vendor); + + // Number of comments (LE u32) + data.extend_from_slice(&(comments.len() as u32).to_le_bytes()); + + for comment in &comments { + let bytes = comment.as_bytes(); + data.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + data.extend_from_slice(bytes); + } + + // VORBIS_COMMENT is metadata block type 4 + flacenc::component::MetadataBlockData::new_unknown(4, &data) + .map_err(|e| XtcError::Encode(format!("vorbis comment block: {:?}", e))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_24bit() { + let n = 4096; + let real_l: Vec = (0..n).map(|i| (i as f32 / n as f32 * std::f32::consts::TAU).sin()).collect(); + let real_r: Vec = (0..n).map(|i| (i as f32 / n as f32 * std::f32::consts::TAU * 2.0).sin()).collect(); + let imag_l: Vec = (0..n).map(|i| (i as f32 / n as f32 * std::f32::consts::TAU).cos()).collect(); + let imag_r: Vec = (0..n).map(|i| (i as f32 / n as f32 * std::f32::consts::TAU * 2.0).cos()).collect(); + + let path = std::env::temp_dir().join("test_xtc_roundtrip.xtc"); + + let encoder = XtcEncoder::new(48000, 24, 2048); + encoder.encode_to_file(&path, &real_l, &real_r, &imag_l, &imag_r).unwrap(); + + let decoder = XtcDecoder::open(&path).unwrap(); + assert_eq!(decoder.sample_rate, 48000); + assert_eq!(decoder.bit_depth, 24); + assert_eq!(decoder.fft_size, 2048); + + let (left, right) = decoder.decode_real(&path).unwrap(); + assert_eq!(left.len(), n); + assert_eq!(right.len(), n); + + let tolerance = 2.0 / (1 << 23) as f32; + for i in 0..n { + assert!((left[i] - real_l[i]).abs() < tolerance, "real_l[{}]: {} vs {}", i, left[i], real_l[i]); + assert!((right[i] - real_r[i]).abs() < tolerance, "real_r[{}]", i); + } + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn decode_real_only() { + let n = 1024; + let real_l: Vec = (0..n).map(|i| (i as f32 / n as f32 * std::f32::consts::TAU).sin() * 0.5).collect(); + let real_r = real_l.clone(); + let imag_l = vec![0.0f32; n]; + let imag_r = vec![0.0f32; n]; + + let path = std::env::temp_dir().join("test_xtc_real_only.xtc"); + let encoder = XtcEncoder::new(44100, 16, 1024); + encoder.encode_to_file(&path, &real_l, &real_r, &imag_l, &imag_r).unwrap(); + + let decoder = XtcDecoder::open(&path).unwrap(); + let (left, right) = decoder.decode_real(&path).unwrap(); + assert_eq!(left.len(), n); + assert_eq!(right.len(), n); + + let _ = std::fs::remove_file(&path); + } +} diff --git a/au-o2-gui/src/config.rs b/au-o2-gui/src/config.rs new file mode 100644 index 0000000..6c61bfb --- /dev/null +++ b/au-o2-gui/src/config.rs @@ -0,0 +1,179 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AudioOxideConfig { + pub first_run: bool, + pub project_dir: PathBuf, + + // Audio defaults + #[serde(default = "default_sample_rate")] + pub default_sample_rate: u32, + #[serde(default = "default_buffer_size", alias = "default_buffer_size")] + pub default_output_buffer_size: u32, + #[serde(default = "default_buffer_size")] + pub default_input_buffer_size: u32, + #[serde(default = "default_audio_device")] + pub default_audio_device: String, + #[serde(default = "default_audio_device")] + pub default_input_device: String, + #[serde(default = "default_recording_format")] + pub recording_format: RecordingFormat, + #[serde(default = "default_bit_depth")] + pub recording_bit_depth: u16, + #[serde(default = "default_true")] + pub auto_oversample: bool, + #[serde(default = "default_true")] + pub auto_undersample: bool, + #[serde(default = "default_fft_size")] + pub hilbert_fft_size: u32, + #[serde(default = "default_viz_buffer_size")] + pub visualizer_buffer_size: u32, + + // General + #[serde(default = "default_true")] + pub auto_save: bool, + #[serde(default = "default_auto_save_interval")] + pub auto_save_interval_secs: u32, + #[serde(default = "default_true")] + pub ask_to_save_on_close: bool, + + // Display + #[serde(default = "default_track_height")] + pub default_track_height: f32, + #[serde(default = "default_true")] + pub show_toolbar_on_open: bool, + #[serde(default)] + pub show_inspector_on_open: bool, + + #[serde(default)] + pub zoom_mode: ZoomMode, +} + +impl Default for AudioOxideConfig { + fn default() -> Self { + Self { + first_run: true, + project_dir: dirs::home_dir().unwrap_or_default().join("Oxide/Projects"), + default_sample_rate: 48000, + default_output_buffer_size: 512, + default_input_buffer_size: 512, + default_audio_device: "Default".to_string(), + default_input_device: "Default".to_string(), + recording_format: RecordingFormat::Wav, + recording_bit_depth: 24, + auto_oversample: true, + auto_undersample: true, + hilbert_fft_size: 2048, + visualizer_buffer_size: 4096, + auto_save: true, + auto_save_interval_secs: 300, + ask_to_save_on_close: true, + default_track_height: 160.0, + show_toolbar_on_open: true, + show_inspector_on_open: false, + zoom_mode: ZoomMode::default(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecordingFormat { + Wav, + Aiff, + Caf, + Xtc, +} + +impl RecordingFormat { + pub const ALL: [RecordingFormat; 4] = [ + RecordingFormat::Wav, + RecordingFormat::Aiff, + RecordingFormat::Caf, + RecordingFormat::Xtc, + ]; +} + +impl std::fmt::Display for RecordingFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RecordingFormat::Wav => write!(f, "WAV"), + RecordingFormat::Aiff => write!(f, "AIFF"), + RecordingFormat::Caf => write!(f, "CAF"), + RecordingFormat::Xtc => write!(f, "XTC"), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ProjectConfig { + pub name: String, + pub sample_rate: u32, + #[serde(default = "default_buffer_size", alias = "buffer_size")] + pub output_buffer_size: u32, + #[serde(default = "default_buffer_size")] + pub input_buffer_size: u32, + pub audio_device: String, + #[serde(default = "default_audio_device")] + pub audio_input_device: String, + #[serde(default = "default_true")] + pub auto_oversample: bool, + #[serde(default = "default_true")] + pub auto_undersample: bool, + pub tempo: f32, + pub time_signature_numerator: u8, + pub time_signature_denominator: u8, + #[serde(default)] + pub tracks: Vec, + #[serde(default)] + pub markers: Vec, + #[serde(default)] + pub tempo_points: Vec, + #[serde(default)] + pub groups: Vec, +} + +impl Default for ProjectConfig { + fn default() -> Self { + Self { + name: "Untitled".to_string(), + sample_rate: 48000, + output_buffer_size: 512, + input_buffer_size: 512, + audio_device: "Default".to_string(), + audio_input_device: "Default".to_string(), + auto_oversample: true, + auto_undersample: true, + tempo: 120.0, + time_signature_numerator: 4, + time_signature_denominator: 4, + tracks: Vec::new(), + markers: Vec::new(), + tempo_points: Vec::new(), + groups: Vec::new(), + } + } +} + +fn default_sample_rate() -> u32 { 48000 } +fn default_buffer_size() -> u32 { 512 } +fn default_audio_device() -> String { "Default".to_string() } +fn default_recording_format() -> RecordingFormat { RecordingFormat::Wav } +fn default_bit_depth() -> u16 { 24 } +fn default_fft_size() -> u32 { 2048 } +fn default_viz_buffer_size() -> u32 { 4096 } +fn default_true() -> bool { true } +fn default_auto_save_interval() -> u32 { 300 } +fn default_track_height() -> f32 { 160.0 } + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +pub enum ZoomMode { + Keyboard, + Scientific, +} + +impl Default for ZoomMode { + fn default() -> Self { + ZoomMode::Keyboard + } +} diff --git a/au-o2-gui/src/debug.rs b/au-o2-gui/src/debug.rs new file mode 100644 index 0000000..89f0ae5 --- /dev/null +++ b/au-o2-gui/src/debug.rs @@ -0,0 +1,70 @@ +#[cfg(feature = "debug-log")] +pub mod enabled { + use std::fs::{self, File}; + use std::io::{BufWriter, Write}; + use std::sync::{Mutex, OnceLock}; + + static LOGGER: OnceLock>>> = OnceLock::new(); + + pub fn init() { + LOGGER.get_or_init(|| { + let home = dirs::home_dir().expect("no home directory"); + let log_dir = home.join("audio-oxide"); + let _ = fs::create_dir_all(&log_dir); + let log_path = log_dir.join("debug.log"); + + match File::create(&log_path) { + Ok(f) => { + eprintln!("[debug] logging to {}", log_path.display()); + let writer: Box = Box::new(f); + let mut bw = BufWriter::new(writer); + let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); + let _ = writeln!(bw, "--- session start: {} ---", now); + let _ = bw.flush(); + Mutex::new(bw) + } + Err(e) => { + eprintln!("[debug] failed to open {}: {}, using stderr", log_path.display(), e); + let writer: Box = Box::new(std::io::stderr()); + Mutex::new(BufWriter::new(writer)) + } + } + }); + } + + pub fn write_log(file: &str, line: u32, args: std::fmt::Arguments<'_>) { + if let Some(logger) = LOGGER.get() { + if let Ok(mut w) = logger.lock() { + let now = chrono::Local::now().format("%H:%M:%S%.3f"); + let _ = writeln!(w, "[DEBUG] {} {}:{} {}", now, file, line, args); + let _ = w.flush(); + } + } + } +} + +#[cfg(feature = "debug-log")] +pub fn init() { + enabled::init(); +} + +#[cfg(not(feature = "debug-log"))] +pub fn init() {} + +pub fn is_debug_mode() -> bool { + cfg!(feature = "debug-log") +} + +#[cfg(feature = "debug-log")] +#[macro_export] +macro_rules! debug_log { + ($($arg:tt)*) => { + $crate::debug::enabled::write_log(file!(), line!(), format_args!($($arg)*)) + }; +} + +#[cfg(not(feature = "debug-log"))] +#[macro_export] +macro_rules! debug_log { + ($($arg:tt)*) => {()}; +} diff --git a/au-o2-gui/src/editor/automation.rs b/au-o2-gui/src/editor/automation.rs new file mode 100644 index 0000000..52ba540 --- /dev/null +++ b/au-o2-gui/src/editor/automation.rs @@ -0,0 +1,150 @@ +use super::{Editor, Message}; +use crate::engine::EngineCommand; + +impl Editor { + pub(crate) fn handle_automation(&mut self, message: Message) { + match message { + Message::SetTrackAutomationMode(track_index, mode) => { + if let Some(track) = self.tracks.get_mut(track_index) { + track.automation_mode = mode; + track.show_automation = mode != crate::automation::AutomationMode::Off; + if track.show_automation && track.automation_lanes.is_empty() { + use crate::automation::{AutomationLane, AutomationTarget}; + track.automation_lanes.push(AutomationLane::new(AutomationTarget::Volume)); + } + self.dirty = true; + let bus_name = track.bus_name.clone(); + if let Some(ref engine) = self.engine { + let flag = match mode { + crate::automation::AutomationMode::Off => crate::engine::AutomationModeFlag::Off, + crate::automation::AutomationMode::Read => crate::engine::AutomationModeFlag::Read, + crate::automation::AutomationMode::Write => crate::engine::AutomationModeFlag::Write, + crate::automation::AutomationMode::Touch => crate::engine::AutomationModeFlag::Touch, + crate::automation::AutomationMode::Latch => crate::engine::AutomationModeFlag::Latch, + }; + engine.send(EngineCommand::SetAutomationMode { bus_name, mode: flag }); + } + } + } + Message::AddAutomationLane(track_index, target) => { + if let Some(track) = self.tracks.get_mut(track_index) { + use crate::automation::AutomationLane; + if !track.automation_lanes.iter().any(|l| l.target == target) { + track.automation_lanes.push(AutomationLane::new(target)); + track.show_automation = true; + self.dirty = true; + } + } + } + Message::AddAutomationPoint { track_index, lane_index, sample_pos, value } => { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(lane) = track.automation_lanes.get_mut(lane_index) { + lane.insert_point(sample_pos, value); + self.dirty = true; + self.sync_automation_to_engine(track_index); + } + } + } + Message::RemoveAutomationPoint { track_index, lane_index, point_index } => { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(lane) = track.automation_lanes.get_mut(lane_index) { + lane.remove_point(point_index); + self.dirty = true; + self.sync_automation_to_engine(track_index); + } + } + } + Message::MoveAutomationPoint { track_index, lane_index, point_index, sample_pos, value } => { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(lane) = track.automation_lanes.get_mut(lane_index) { + if point_index < lane.points.len() { + lane.points.remove(point_index); + lane.insert_point(sample_pos, value); + self.dirty = true; + self.sync_automation_to_engine(track_index); + } + } + } + } + Message::AddTempoPoint { sample_pos, tempo } => { + self.tempo_map.insert_point(sample_pos, tempo); + self.dirty = true; + self.sync_tempo_to_engine(); + } + Message::RemoveTempoPoint(index) => { + self.tempo_map.remove_point(index); + self.dirty = true; + self.sync_tempo_to_engine(); + } + Message::MoveTempoPoint { index, sample_pos, tempo } => { + self.tempo_map.remove_point(index); + self.tempo_map.insert_point(sample_pos, tempo); + self.dirty = true; + self.sync_tempo_to_engine(); + } + _ => {} + } + } + + pub(crate) fn record_automation_point(&mut self, track_index: usize, target: crate::automation::AutomationTarget, sample_pos: u64, value: f32) { + let Some(track) = self.tracks.get_mut(track_index) else { return }; + let lane_idx = if let Some(idx) = track.automation_lanes.iter().position(|l| l.target == target) { + idx + } else { + track.automation_lanes.push(crate::automation::AutomationLane::new(target)); + track.automation_lanes.len() - 1 + }; + track.automation_lanes[lane_idx].insert_point(sample_pos, value); + self.sync_automation_to_engine(track_index); + } + + pub(crate) fn sync_automation_to_engine(&self, track_index: usize) { + let Some(ref engine) = self.engine else { return }; + let Some(track) = self.tracks.get(track_index) else { return }; + let bus_name = &track.bus_name; + for lane in &track.automation_lanes { + let target = match &lane.target { + crate::automation::AutomationTarget::Volume => crate::engine::AutomationTarget::Volume, + crate::automation::AutomationTarget::Pan => crate::engine::AutomationTarget::Pan, + crate::automation::AutomationTarget::Mute => crate::engine::AutomationTarget::Mute, + crate::automation::AutomationTarget::ModuleParam { module_id, key } => { + crate::engine::AutomationTarget::ModuleParam { module_id: *module_id, key: key.clone() } + } + }; + let points: Vec<(u64, f32)> = lane.points.iter().map(|p| (p.sample_pos, p.value)).collect(); + engine.send(EngineCommand::SetAutomationData { + bus_name: bus_name.clone(), + target, + points, + }); + } + } + + pub(crate) fn sync_tempo_to_engine(&self) { + let Some(ref engine) = self.engine else { return }; + let points: Vec<(u64, f32)> = self.tempo_map.points.iter() + .map(|p| (p.sample_pos, p.tempo)) + .collect(); + engine.send(EngineCommand::SetTempoCurve { points }); + } + + pub(crate) fn sync_midi_region_to_engine(&self, track: &crate::track::Track, region: &crate::region::Region) { + let Some(ref engine) = self.engine else { return }; + let start_beat = region.start_time.to_total_beats(self.time_signature_numerator as u32); + let notes: Vec = region.midi_notes.iter().map(|n| { + oxforge::mdk::MidiPlaybackNote { + start_tick: n.start_tick, + duration_ticks: n.duration_ticks, + note: n.note, + velocity: n.velocity, + channel: n.channel, + } + }).collect(); + engine.send(EngineCommand::LoadMidiRegion { + bus_name: track.bus_name.clone(), + region_id: region.id, + start_beat, + notes, + }); + } +} diff --git a/au-o2-gui/src/editor/clip_launcher.rs b/au-o2-gui/src/editor/clip_launcher.rs new file mode 100644 index 0000000..d493195 --- /dev/null +++ b/au-o2-gui/src/editor/clip_launcher.rs @@ -0,0 +1,78 @@ +use iced::Task; + +use super::{decode_region_audio, Editor, Message}; +use crate::engine::EngineCommand; + +impl Editor { + pub(crate) fn handle_clip_launcher(&mut self, message: Message) -> Task { + match message { + Message::TriggerClip { track_index, region_id } => { + if let Some(track) = self.tracks.get(track_index) { + let track_clip_ids: Vec = track.regions.iter() + .filter(|r| self.active_clips.contains(&r.id)) + .map(|r| r.id) + .collect(); + for cid in track_clip_ids { + self.active_clips.remove(&cid); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: cid }); + } + } + } + if let Some(track) = self.tracks.get(track_index) { + if let Some(region) = track.regions.iter().find(|r| r.id == region_id) { + if let Some(ref audio_file) = region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, self.project_config.sample_rate) { + let s = (region.start_sample as usize).min(audio_l.len()); + let e = (s + region.length_samples as usize).min(audio_l.len()); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: track.bus_name.clone(), + region_id, + start_sample: region.start_sample, + audio_l: audio_l[s..e].to_vec(), + audio_r: audio_r[s.min(audio_r.len())..e.min(audio_r.len())].to_vec(), + fade_in_samples: region.fade_in_samples, + fade_out_samples: region.fade_out_samples, + }); + } + } + } else if region.is_midi() { + self.sync_midi_region_to_engine(track, region); + } + self.active_clips.insert(region_id); + } + } + } + Message::StopClip { region_id } => { + self.active_clips.remove(®ion_id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id }); + engine.send(EngineCommand::UnloadMidiRegion { region_id }); + } + } + Message::TriggerScene(scene_index) => { + let trigger_list: Vec<(usize, uuid::Uuid)> = self.tracks.iter().enumerate() + .filter_map(|(ti, track)| { + track.regions.get(scene_index).map(|r| (ti, r.id)) + }) + .collect(); + if let Some((ti, rid)) = trigger_list.into_iter().next() { + return self.update(Message::TriggerClip { track_index: ti, region_id: rid }); + } + } + Message::StopAllClips => { + let clip_ids: Vec = self.active_clips.drain().collect(); + if let Some(ref engine) = self.engine { + for rid in clip_ids { + engine.send(EngineCommand::UnloadRegionAudio { region_id: rid }); + engine.send(EngineCommand::UnloadMidiRegion { region_id: rid }); + } + } + } + _ => {} + } + Task::none() + } +} diff --git a/au-o2-gui/src/editor/clipboard.rs b/au-o2-gui/src/editor/clipboard.rs new file mode 100644 index 0000000..f23b9f1 --- /dev/null +++ b/au-o2-gui/src/editor/clipboard.rs @@ -0,0 +1,133 @@ +use super::{decode_region_audio, Editor}; +use crate::clipboard::ClipboardEntry; +use crate::engine::EngineCommand; +use crate::history::EditCommand; +use crate::region::Region; +use crate::timing::MusicalTime; +use crate::waveform::WaveformPeaks; + +impl Editor { + pub(crate) fn clipboard_copy(&mut self) { + self.clipboard.clear(); + for (ti, track) in self.tracks.iter().enumerate() { + for region in &track.regions { + if region.selected { + self.clipboard.entries.push(ClipboardEntry { + region: region.clone(), + source_track_index: ti, + }); + } + } + } + } + + pub(crate) fn clipboard_cut(&mut self) { + let mut cut_entries = Vec::new(); + for ti in 0..self.tracks.len() { + let mut ri = 0; + while ri < self.tracks[ti].regions.len() { + if self.tracks[ti].regions[ri].selected { + let region = self.tracks[ti].regions.remove(ri); + self.waveform_cache.remove(®ion.id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: region.id }); + } + cut_entries.push((ti, region)); + } else { + ri += 1; + } + } + } + if !cut_entries.is_empty() { + self.history.push(EditCommand::CutRegions { entries: cut_entries }); + } + } + + pub(crate) fn clipboard_paste(&mut self) { + if self.clipboard.is_empty() || self.tracks.is_empty() { + return; + } + + let paste_time = self.current_position; + let paste_sample = paste_time.to_samples_mapped( + &self.tempo_map, + self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + + let anchor_track = self.selected_track + .unwrap_or_else(|| { + self.clipboard.entries.first() + .map(|e| e.source_track_index) + .unwrap_or(0) + }); + let source_anchor = self.clipboard.entries.first() + .map(|e| e.source_track_index) + .unwrap_or(0); + + let earliest_sample = self.clipboard.entries.iter() + .map(|e| e.region.start_sample) + .min() + .unwrap_or(0); + + let mut pasted = Vec::new(); + + for entry in &self.clipboard.entries { + let track_offset = entry.source_track_index as isize - source_anchor as isize; + let target_track = ((anchor_track as isize + track_offset).max(0) as usize) + .min(self.tracks.len().saturating_sub(1)); + + let sample_offset = entry.region.start_sample.saturating_sub(earliest_sample); + let new_start_sample = paste_sample + sample_offset; + let new_start_time = MusicalTime::from_samples_mapped( + new_start_sample, + &self.tempo_map, + self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + + let new_id = uuid::Uuid::new_v4(); + let new_region = Region { + id: new_id, + start_time: new_start_time, + duration: entry.region.duration, + audio_file: entry.region.audio_file.clone(), + start_sample: new_start_sample, + length_samples: entry.region.length_samples, + selected: false, + fade_in_samples: entry.region.fade_in_samples, + fade_out_samples: entry.region.fade_out_samples, + midi_notes: Vec::new(), + playback_rate: entry.region.playback_rate, + }; + + if let Some(ref audio_file) = new_region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, self.project_config.sample_rate) { + self.waveform_cache.insert( + new_id, + WaveformPeaks::from_stereo(&audio_l, &audio_r), + ); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: self.tracks[target_track].bus_name.clone(), + region_id: new_id, + start_sample: new_start_sample, + audio_l, + audio_r, + fade_in_samples: new_region.fade_in_samples, + fade_out_samples: new_region.fade_out_samples, + }); + } + } + } + + self.tracks[target_track].regions.push(new_region.clone()); + pasted.push((target_track, new_region)); + } + + if !pasted.is_empty() { + self.history.push(EditCommand::PasteRegions { entries: pasted }); + } + } +} diff --git a/au-o2-gui/src/editor/edit_actions.rs b/au-o2-gui/src/editor/edit_actions.rs new file mode 100644 index 0000000..207e98a --- /dev/null +++ b/au-o2-gui/src/editor/edit_actions.rs @@ -0,0 +1,77 @@ +use iced::Task; + +use super::{Editor, Message}; +use crate::behaviors; +use crate::engine::EngineCommand; +use crate::history::EditCommand; + +impl Editor { + pub(crate) fn handle_edit_actions(&mut self, message: Message) -> Task { + if let Message::EditAction(action) = message { + use behaviors::Action::*; + match action { + Undo => { + self.perform_undo(); + self.mark_dirty(); + } + Redo => { + self.perform_redo(); + self.mark_dirty(); + } + SelectAll => { + for t in &mut self.tracks { + t.selected = true; + for r in &mut t.regions { + r.selected = true; + } + } + } + Delete => { + self.delete_selected(); + self.mark_dirty(); + } + Duplicate => { + if let Some(i) = self.selected_track { + if let Some(track) = self.tracks.get(i) { + let mut dup = track.clone(); + dup.id = uuid::Uuid::new_v4(); + dup.name = format!("{} Copy", dup.name); + dup.bus_name = format!("track_{}", dup.id.as_simple()); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::CreateBus { + name: dup.bus_name.clone(), + is_midi: dup.track_type == crate::track::TrackType::Midi, + }); + } + self.tracks.insert(i + 1, dup); + self.track_count += 1; + self.history.push(EditCommand::DuplicateTrack { + source_index: i, + new_index: i + 1, + }); + self.mark_dirty(); + } + } + } + Cut => { + self.clipboard_copy(); + self.clipboard_cut(); + self.mark_dirty(); + } + Copy => { + self.clipboard_copy(); + } + Paste => { + self.clipboard_paste(); + self.mark_dirty(); + } + Quantize => { + self.quantize_selected(); + self.mark_dirty(); + } + _ => {} + } + } + Task::none() + } +} diff --git a/au-o2-gui/src/editor/engine_tick.rs b/au-o2-gui/src/editor/engine_tick.rs new file mode 100644 index 0000000..e04383c --- /dev/null +++ b/au-o2-gui/src/editor/engine_tick.rs @@ -0,0 +1,215 @@ +use std::time::Instant; + +use iced::Task; + +use super::{decode_region_audio, Editor, Message, StatusLevel}; +use crate::engine::{EngineCommand, EngineEvent}; +use crate::region::{Region, TakeFolder}; +use crate::waveform::WaveformPeaks; + +impl Editor { + pub(crate) fn handle_engine_tick(&mut self) -> Task { + if let Some((ref msg, level, t)) = self.status_message { + if t.elapsed().as_secs() >= 5 { + self.last_status = Some((msg.clone(), level)); + self.status_message = None; + } + } + if let Some(ref engine) = self.engine { + for event in engine.poll_events() { + match event { + EngineEvent::TransportPosition(pos) => { + self.current_position = pos; + let sample_pos = pos.to_samples_mapped( + &self.tempo_map, self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + for track in &mut self.tracks { + if track.automation_mode.reads() { + for lane in &track.automation_lanes { + if let Some(val) = lane.value_at(sample_pos) { + match &lane.target { + crate::automation::AutomationTarget::Volume => track.volume = val, + crate::automation::AutomationTarget::Pan => track.pan = val, + crate::automation::AutomationTarget::Mute => track.muted = val > 0.5, + crate::automation::AutomationTarget::ModuleParam { module_id, key } => { + self.module_params.values.insert((*module_id, key.clone()), val); + } + } + } + } + } + } + } + EngineEvent::Error(e) => { + debug_log!("engine error: {}", e); + self.status_message = Some((e, StatusLevel::Error, Instant::now())); + } + EngineEvent::BusCreated => {} + EngineEvent::GraphRebuilt => {} + EngineEvent::ModuleLoaded { bus_name, module_id, module_type, plugin_name, has_gui, gui_descriptor } => { + if self.routing.handle_module_loaded(&bus_name, module_id, module_type, plugin_name, &mut self.tracks) { + self.dirty = true; + } + self.module_gui.handle_module_loaded(module_id, has_gui, gui_descriptor); + } + EngineEvent::ContractViolation { module_id: _module_id, module_name: _module_name, avg_ns: _avg_ns, budget_ns: _budget_ns } => { + debug_log!("contract violation: module {}({}) {}ns / {}ns budget", + _module_name, _module_id, _avg_ns, _budget_ns); + } + EngineEvent::BufferAutoIncreased { new_size, latency_ms: _latency_ms, reason: _reason } => { + debug_log!("buffer auto-increased to {} ({:.1}ms): {}", + new_size, _latency_ms, _reason); + self.project_config.output_buffer_size = new_size as u32; + self.dirty = true; + } + EngineEvent::BufferNegotiation { module_id: _module_id, required_samples: _required_samples, required_ms: _required_ms, current_samples: _current_samples, current_ms: _current_ms } => { + debug_log!("buffer negotiation: module {} needs {} samples ({:.1}ms), current {} ({:.1}ms)", + _module_id, _required_samples, _required_ms, _current_samples, _current_ms); + } + EngineEvent::ModuleDisabled { module_id: _module_id, reason: _reason } => { + debug_log!("module {} disabled: {}", _module_id, _reason); + } + EngineEvent::AudioConfigResolved { + output_device: _output_device, input_device: _input_device, sample_rate: _sample_rate, + } => { + debug_log!("[audio] output='{}' input='{}' rate={}Hz", + _output_device, + if _input_device.is_empty() { "none" } else { &_input_device }, + _sample_rate); + } + EngineEvent::RecordingComplete { + bus_name, file_path, start_sample, + length_samples, start_time, duration, + } => { + let region = Region::with_audio( + start_time, + duration, + file_path.clone(), + start_sample, + length_samples, + ); + let region_id = region.id; + + let abs_path = self.project_path.join(&file_path); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, self.project_config.sample_rate) { + self.waveform_cache.insert( + region_id, + WaveformPeaks::from_stereo(&audio_l, &audio_r), + ); + engine.send(EngineCommand::LoadRegionAudio { + bus_name: bus_name.clone(), + region_id, + start_sample, + audio_l, + audio_r, + fade_in_samples: 0, + fade_out_samples: 0, + }); + } + + for track in &mut self.tracks { + if track.bus_name == bus_name { + track.regions.push(region); + self.dirty = true; + break; + } + } + } + EngineEvent::ModuleParamDescriptors { module_id, descriptors } => { + for desc in &descriptors { + let default_val = match &desc.kind { + oxforge::mdk::ParamKind::Float { default, .. } => *default, + oxforge::mdk::ParamKind::Bool { default } => if *default { 1.0 } else { 0.0 }, + oxforge::mdk::ParamKind::Choice { default, .. } => *default as f32, + oxforge::mdk::ParamKind::Int { default, .. } => *default as f32, + }; + self.module_params.values + .entry((module_id, desc.key.clone())) + .or_insert(default_val); + } + self.module_params.descriptors.insert(module_id, descriptors); + } + EngineEvent::ModuleParamChanged { module_id, key, value } => { + self.module_params.values.insert((module_id, key), value); + } + EngineEvent::PluginsDiscovered { plugins } => { + debug_log!("[plugins] discovered {} plugins", plugins.len()); + self.discovered_plugins = plugins; + } + EngineEvent::TakeRecordingComplete { bus_name, takes } => { + let mut region_ids = Vec::new(); + for (i, take) in takes.iter().enumerate() { + let region = Region::with_audio( + take.start_time, + take.duration, + take.file_path.clone(), + take.start_sample, + take.length_samples, + ); + let region_id = region.id; + region_ids.push(region_id); + + let abs_path = self.project_path.join(&take.file_path); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, self.project_config.sample_rate) { + self.waveform_cache.insert( + region_id, + WaveformPeaks::from_stereo(&audio_l, &audio_r), + ); + if i == takes.len() - 1 { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: bus_name.clone(), + region_id, + start_sample: take.start_sample, + audio_l, + audio_r, + fade_in_samples: 0, + fade_out_samples: 0, + }); + } + } + + for track in &mut self.tracks { + if track.bus_name == bus_name { + track.regions.push(region); + break; + } + } + } + if region_ids.len() > 1 { + let folder = TakeFolder::new(region_ids); + for track in &mut self.tracks { + if track.bus_name == bus_name { + track.take_folders.push(folder); + self.dirty = true; + break; + } + } + } + } + EngineEvent::MeterUpdate { bus_peaks, master_peak } => { + for (name, l, r) in bus_peaks { + self.meter_levels.insert(name, (l, r)); + } + self.master_meter = master_peak; + } + EngineEvent::ModuleGuiDescriptorReady { module_id, descriptor } => { + self.module_gui.handle_gui_descriptor_ready(module_id, descriptor); + } + EngineEvent::ModuleGuiReady => {} + EngineEvent::ModuleErrorReport { .. } => {} + } + } + } + + let gui_tasks = self.module_gui.tick( + self.engine.as_ref(), + &mut self.module_params, + &self.routing.module_names, + ); + if !gui_tasks.is_empty() { + return Task::batch(gui_tasks); + } + Task::none() + } +} diff --git a/au-o2-gui/src/editor/export.rs b/au-o2-gui/src/editor/export.rs new file mode 100644 index 0000000..b96d7c1 --- /dev/null +++ b/au-o2-gui/src/editor/export.rs @@ -0,0 +1,144 @@ +use iced::widget::{button, column, container, pick_list, row, text, text_input, checkbox, Space}; +use iced::{Alignment, Background, Color, Element, Length, Theme}; + +use super::{Editor, Message, ModalState}; +use crate::export::{self, ExportConfig, ExportFormat}; +use crate::gui::theme as ui_theme; + +impl Editor { + pub(crate) fn handle_export(&mut self, message: Message) { + match message { + Message::ShowExportDialog => { + self.modal_state = Some(ModalState::ExportDialog(ExportConfig { + sample_rate: self.project_config.sample_rate, + ..ExportConfig::default() + })); + } + Message::ExportFormatSelected(fmt) => { + if let Some(ModalState::ExportDialog(ref mut cfg)) = self.modal_state { + cfg.format = fmt; + } + } + Message::ExportBitDepthSelected(bd) => { + if let Some(ModalState::ExportDialog(ref mut cfg)) = self.modal_state { + cfg.bit_depth = bd; + } + } + Message::ExportNormalizeToggled => { + if let Some(ModalState::ExportDialog(ref mut cfg)) = self.modal_state { + cfg.normalize = !cfg.normalize; + } + } + Message::ExportFilenameChanged(name) => { + if let Some(ModalState::ExportDialog(ref mut cfg)) = self.modal_state { + cfg.filename = name; + } + } + Message::ExportConfirm => { + if let Some(ModalState::ExportDialog(ref cfg)) = self.modal_state { + self.perform_export(cfg.clone()); + } + self.modal_state = None; + } + _ => {} + } + } + + pub(crate) fn perform_export(&self, config: ExportConfig) { + let bounced = export::bounce_offline( + &self.tracks, + &self.project_path, + self.tempo, + self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + + let (mut mix_l, mut mix_r) = match bounced { + Some(data) => data, + None => { + debug_log!("nothing to export"); + return; + } + }; + + if config.normalize { + export::normalize(&mut mix_l, &mut mix_r); + } + + let export_dir = self.project_path.join("exports"); + let _ = std::fs::create_dir_all(&export_dir); + let filename = format!("{}.{}", config.filename, config.format.extension()); + let path = export_dir.join(&filename); + + let result = match config.format { + ExportFormat::Wav => export::export_wav( + &path, &mix_l, &mix_r, config.sample_rate, config.bit_depth, + ), + ExportFormat::Flac => export::export_flac( + &path, &mix_l, &mix_r, config.sample_rate, config.bit_depth, + ), + ExportFormat::Xtc => export::export_xtc( + &path, &mix_l, &mix_r, config.sample_rate, config.bit_depth, + self.analysis_fft_size as u32, + ), + }; + + match result { + Ok(()) => debug_log!("[export] wrote {}", path.display()), + Err(_e) => debug_log!("[export] failed: {}", _e), + } + } + + pub(crate) fn export_dialog_view(&self, config: &ExportConfig) -> Element<'_, Message> { + let format_picker = pick_list( + ExportFormat::ALL.as_slice(), + Some(config.format), + Message::ExportFormatSelected, + ); + + let bit_depths: Vec = vec![16, 24, 32]; + let bit_depth_picker = pick_list( + bit_depths, + Some(config.bit_depth), + Message::ExportBitDepthSelected, + ); + + let normalize_check = checkbox("Normalize", config.normalize) + .on_toggle(|_| Message::ExportNormalizeToggled); + + let filename_input = text_input("filename", &config.filename) + .on_input(Message::ExportFilenameChanged) + .width(200); + + let export_btn = button(text("Export").size(ui_theme::TS_MD)) + .on_press(Message::ExportConfirm); + let cancel_btn = button(text("Cancel").size(ui_theme::TS_MD)) + .on_press(Message::CloseModal); + + container( + column![ + text("Export / Bounce").size(ui_theme::TS_XL), + Space::new(0, ui_theme::SP_LG), + row![text("Format:").size(ui_theme::TS_MD).width(80), format_picker].spacing(ui_theme::SP_MD).align_y(Alignment::Center), + row![text("Bit Depth:").size(ui_theme::TS_MD).width(80), bit_depth_picker].spacing(ui_theme::SP_MD).align_y(Alignment::Center), + row![text("Filename:").size(ui_theme::TS_MD).width(80), filename_input].spacing(ui_theme::SP_MD).align_y(Alignment::Center), + normalize_check, + Space::new(0, ui_theme::SP_LG), + row![cancel_btn, Space::new(Length::Fill, 0), export_btn].spacing(ui_theme::SP_MD), + ] + .spacing(ui_theme::SP_MD) + .padding(ui_theme::SP_XXL) + .width(400), + ) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8(0x38, 0x31, 0x2A))), + border: iced::Border { + color: Color::from_rgb8(0x58, 0x4E, 0x44), + width: 1.0, + radius: 10.0.into(), + }, + ..container::Style::default() + }) + .into() + } +} diff --git a/au-o2-gui/src/editor/freeze.rs b/au-o2-gui/src/editor/freeze.rs new file mode 100644 index 0000000..1527f41 --- /dev/null +++ b/au-o2-gui/src/editor/freeze.rs @@ -0,0 +1,96 @@ +use super::{Editor, Message}; +use crate::engine::EngineCommand; +use crate::region::Region; +use crate::waveform::WaveformPeaks; + +impl Editor { + pub(crate) fn handle_freeze(&mut self, message: Message) { + if let Message::FreezeTrack(track_index) = message { + if let Some(track) = self.tracks.get(track_index) { + if track.frozen { + let module_ids: Vec = track.module_chain.clone(); + for mid in &module_ids { + self.routing.disabled_modules.remove(mid); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetModuleDisabled { + module_id: *mid, + disabled: false, + }); + } + } + if let Some(track) = self.tracks.get_mut(track_index) { + track.frozen = false; + track.frozen_file = None; + self.mark_dirty(); + } + } else { + let bus_name = track.bus_name.clone(); + if let Some((audio_l, audio_r, start_sample, length_samples)) = + crate::export::bounce_track(track, &self.project_path) + { + let audio_dir = self.project_path.join("audio"); + let _ = std::fs::create_dir_all(&audio_dir); + let safe = bus_name.replace(|c: char| !c.is_alphanumeric() && c != '_', "_"); + let filename = format!("{}_frozen.xtc", safe); + let file_path = audio_dir.join(&filename); + let relative_path = format!("audio/{}", filename); + + let sr = self.project_config.sample_rate; + let encoder = crate::codec::XtcEncoder::new(sr, 24, 2048); + let imag_l = vec![0.0f32; audio_l.len()]; + let imag_r = vec![0.0f32; audio_r.len()]; + if encoder.encode_to_file(&file_path, &audio_l, &audio_r, &imag_l, &imag_r).is_ok() { + let start_time = crate::timing::MusicalTime::from_samples_mapped( + start_sample, &self.tempo_map, sr, self.time_signature_numerator as u32, + ); + let end_time = crate::timing::MusicalTime::from_samples_mapped( + start_sample + length_samples, &self.tempo_map, sr, self.time_signature_numerator as u32, + ); + let duration = end_time - start_time; + + let frozen_region = Region::with_audio( + start_time, duration, relative_path.clone(), start_sample, length_samples, + ); + let region_id = frozen_region.id; + + self.waveform_cache.insert( + region_id, + WaveformPeaks::from_stereo(&audio_l, &audio_r), + ); + + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: bus_name.clone(), + region_id, + start_sample, + audio_l, + audio_r, + fade_in_samples: 0, + fade_out_samples: 0, + }); + } + + if let Some(track) = self.tracks.get_mut(track_index) { + track.regions.push(frozen_region); + track.frozen = true; + track.frozen_file = Some(relative_path); + + let module_ids: Vec = track.module_chain.clone(); + for mid in &module_ids { + self.routing.disabled_modules.insert(*mid); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetModuleDisabled { + module_id: *mid, + disabled: true, + }); + } + } + self.mark_dirty(); + } + } + } + } + } + } + } +} diff --git a/au-o2-gui/src/editor/groups.rs b/au-o2-gui/src/editor/groups.rs new file mode 100644 index 0000000..73ec619 --- /dev/null +++ b/au-o2-gui/src/editor/groups.rs @@ -0,0 +1,88 @@ +use super::{Editor, Message}; +use crate::engine::EngineCommand; + +impl Editor { + pub(crate) fn handle_groups(&mut self, message: Message) { + match message { + Message::CreateGroup => { + let idx = self.groups.len(); + let group = crate::track::TrackGroup::new( + format!("Group {}", idx + 1), + idx + 6, + ); + self.groups.push(group); + self.mark_dirty(); + } + Message::DeleteGroup(group_id) => { + for track in &mut self.tracks { + if track.group_id == Some(group_id) { + track.group_id = None; + } + } + self.groups.retain(|g| g.id != group_id); + self.mark_dirty(); + } + Message::SetGroupVolume { group_id, volume } => { + if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) { + group.volume = volume; + } + self.sync_group_volumes(group_id); + self.mark_dirty(); + } + Message::SetGroupMute(group_id) => { + if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) { + group.muted = !group.muted; + } + self.sync_group_mutes(group_id); + self.mark_dirty(); + } + Message::SetGroupSolo(group_id) => { + if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) { + group.soloed = !group.soloed; + } + self.mark_dirty(); + } + Message::AssignTrackToGroup { track_index, group_id } => { + if let Some(track) = self.tracks.get_mut(track_index) { + track.group_id = group_id; + self.mark_dirty(); + } + } + _ => {} + } + } + + pub(crate) fn sync_group_volumes(&self, group_id: uuid::Uuid) { + let group_vol = self.groups.iter() + .find(|g| g.id == group_id) + .map(|g| g.volume) + .unwrap_or(1.0); + if let Some(ref engine) = self.engine { + for track in &self.tracks { + if track.group_id == Some(group_id) { + engine.send(EngineCommand::SetBusVolume { + bus_name: track.bus_name.clone(), + volume: track.volume * group_vol, + }); + } + } + } + } + + pub(crate) fn sync_group_mutes(&self, group_id: uuid::Uuid) { + let group_muted = self.groups.iter() + .find(|g| g.id == group_id) + .map(|g| g.muted) + .unwrap_or(false); + if let Some(ref engine) = self.engine { + for track in &self.tracks { + if track.group_id == Some(group_id) { + engine.send(EngineCommand::SetBusMute { + bus_name: track.bus_name.clone(), + muted: track.muted || group_muted, + }); + } + } + } + } +} diff --git a/au-o2-gui/src/editor/helpers.rs b/au-o2-gui/src/editor/helpers.rs new file mode 100644 index 0000000..f696634 --- /dev/null +++ b/au-o2-gui/src/editor/helpers.rs @@ -0,0 +1,20 @@ +use std::path::Path; + +pub(crate) fn decode_region_audio(path: &Path, project_rate: u32) -> Option<(Vec, Vec)> { + let decoder = crate::codec::XtcDecoder::open(path).ok()?; + let (audio_l, audio_r) = decoder.decode_real(path).ok()?; + if decoder.sample_rate != project_rate && decoder.sample_rate > 0 { + let l = crate::engine::resample::resample_mono(&audio_l, decoder.sample_rate, project_rate); + let r = crate::engine::resample::resample_mono(&audio_r, decoder.sample_rate, project_rate); + Some((l, r)) + } else { + Some((audio_l, audio_r)) + } +} + +pub(crate) fn apply_flex(audio_l: Vec, audio_r: Vec, rate: f32) -> (Vec, Vec) { + if (rate - 1.0).abs() < 0.001 { + return (audio_l, audio_r); + } + crate::engine::resample::stretch_stereo(&audio_l, &audio_r, rate) +} diff --git a/au-o2-gui/src/editor/init.rs b/au-o2-gui/src/editor/init.rs new file mode 100644 index 0000000..fb46948 --- /dev/null +++ b/au-o2-gui/src/editor/init.rs @@ -0,0 +1,258 @@ +use std::path::PathBuf; + +use iced::widget::scrollable; +use iced::Task; + +use super::{ + apply_flex, decode_region_audio, BottomPanelMode, Editor, Message, ModuleParamState, + Tool, +}; +use crate::clipboard::Clipboard; +use crate::config::ProjectConfig; +use crate::engine::session_player::SessionPlayerConfig; +use crate::engine::{EngineCommand, EngineConfig, EngineHandle}; +use crate::gui::editor::score; +use crate::gui::icons::IconSet; +use crate::gui::theme as ui_theme; +use crate::history::History; +use crate::module_gui_manager::ModuleGuiManager; +use crate::routing::RoutingManager; +use crate::timing::MusicalTime; +use crate::waveform::{WaveformCache, WaveformPeaks}; + +impl Editor { + pub fn new(project_path: PathBuf) -> (Self, Task) { + let config_path = project_path.join("project.toml"); + let project_config: ProjectConfig = std::fs::read_to_string(&config_path) + .ok() + .and_then(|content| toml::from_str(&content).ok()) + .unwrap_or_default(); + + let tempo = project_config.tempo; + let mut tempo_map = crate::timing::TempoMap::new(tempo); + for pt in &project_config.tempo_points { + tempo_map.insert_point(pt.sample_pos, pt.tempo); + } + let ts_num = project_config.time_signature_numerator; + let ts_den = project_config.time_signature_denominator; + + let engine_config = EngineConfig { + sample_rate: project_config.sample_rate, + output_buffer_size: project_config.output_buffer_size, + input_buffer_size: project_config.input_buffer_size, + output_device: project_config.audio_device.clone(), + input_device: project_config.audio_input_device.clone(), + auto_oversample: project_config.auto_oversample, + auto_undersample: project_config.auto_undersample, + hilbert_fft_size: 2048, + }; + let engine = Some(EngineHandle::spawn(&engine_config)); + + let tracks = project_config.tracks.clone(); + let track_count = tracks.len(); + let markers = project_config.markers.clone(); + let groups = project_config.groups.clone(); + let next_marker_id = markers.iter().map(|m| m.id).max().unwrap_or(0) + 1; + let mut waveform_cache = WaveformCache::new(); + + if let Some(ref engine_handle) = engine { + for track in &tracks { + engine_handle.send(EngineCommand::CreateBus { + name: track.bus_name.clone(), + is_midi: track.track_type == crate::track::TrackType::Midi, + }); + engine_handle.send(EngineCommand::SetBusVolume { + bus_name: track.bus_name.clone(), volume: track.volume, + }); + engine_handle.send(EngineCommand::SetBusPan { + bus_name: track.bus_name.clone(), pan: track.pan, + }); + if track.muted { + engine_handle.send(EngineCommand::SetBusMute { + bus_name: track.bus_name.clone(), muted: true, + }); + } + if track.soloed { + engine_handle.send(EngineCommand::SetBusSolo { + bus_name: track.bus_name.clone(), soloed: true, + }); + } + if track.record_armed { + engine_handle.send(EngineCommand::ArmTrack { + bus_name: track.bus_name.clone(), + }); + } + let visible_ids: std::collections::HashSet = + track.visible_regions().iter().map(|r| r.id).collect(); + for region in &track.regions { + if let Some(ref audio_file) = region.audio_file { + let abs_path = project_path.join(audio_file); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, project_config.sample_rate) { + waveform_cache.insert( + region.id, + WaveformPeaks::from_stereo(&audio_l, &audio_r), + ); + if visible_ids.contains(®ion.id) { + let (sl, sr) = apply_flex(audio_l, audio_r, region.playback_rate); + engine_handle.send(EngineCommand::LoadRegionAudio { + bus_name: track.bus_name.clone(), + region_id: region.id, + start_sample: region.start_sample, + audio_l: sl, + audio_r: sr, + fade_in_samples: region.fade_in_samples, + fade_out_samples: region.fade_out_samples, + }); + } + } + } + if !region.midi_notes.is_empty() && visible_ids.contains(®ion.id) { + let start_beat = region.start_time.to_total_beats(project_config.time_signature_numerator as u32); + let notes: Vec = region.midi_notes.iter().map(|n| { + oxforge::mdk::MidiPlaybackNote { + start_tick: n.start_tick, + duration_ticks: n.duration_ticks, + note: n.note, + velocity: n.velocity, + channel: n.channel, + } + }).collect(); + engine_handle.send(EngineCommand::LoadMidiRegion { + bus_name: track.bus_name.clone(), + region_id: region.id, + start_beat, + notes, + }); + } + } + for send in &track.sends { + if send.enabled { + engine_handle.send(EngineCommand::SetSend { + source_bus: track.bus_name.clone(), + aux_bus: send.aux_bus_name.clone(), + level: send.level, + }); + } + } + { + let flag = match track.automation_mode { + crate::automation::AutomationMode::Off => crate::engine::AutomationModeFlag::Off, + crate::automation::AutomationMode::Read => crate::engine::AutomationModeFlag::Read, + crate::automation::AutomationMode::Write => crate::engine::AutomationModeFlag::Write, + crate::automation::AutomationMode::Touch => crate::engine::AutomationModeFlag::Touch, + crate::automation::AutomationMode::Latch => crate::engine::AutomationModeFlag::Latch, + }; + engine_handle.send(EngineCommand::SetAutomationMode { + bus_name: track.bus_name.clone(), + mode: flag, + }); + } + for lane in &track.automation_lanes { + let target = match &lane.target { + crate::automation::AutomationTarget::Volume => crate::engine::AutomationTarget::Volume, + crate::automation::AutomationTarget::Pan => crate::engine::AutomationTarget::Pan, + crate::automation::AutomationTarget::Mute => crate::engine::AutomationTarget::Mute, + crate::automation::AutomationTarget::ModuleParam { module_id, key } => { + crate::engine::AutomationTarget::ModuleParam { module_id: *module_id, key: key.clone() } + } + }; + let points: Vec<(u64, f32)> = lane.points.iter().map(|p| (p.sample_pos, p.value)).collect(); + engine_handle.send(EngineCommand::SetAutomationData { + bus_name: track.bus_name.clone(), + target, + points, + }); + } + } + if !tempo_map.points.is_empty() { + let points: Vec<(u64, f32)> = tempo_map.points.iter() + .map(|p| (p.sample_pos, p.tempo)) + .collect(); + engine_handle.send(EngineCommand::SetTempoCurve { points }); + } + engine_handle.send(EngineCommand::ScanPlugins); + } + + ( + Self { + project_path, + project_config, + tracks, + modal_state: None, + engine, + dirty: false, + transport: crate::engine::TransportState::Stopped, + record_armed: false, + current_position: MusicalTime::new(1, 1, 0), + tempo, + tempo_map, + show_tempo_lane: false, + time_signature_numerator: ts_num, + time_signature_denominator: ts_den, + active_tool: Tool::Pointer, + show_inspector: false, + show_bottom_panel: false, + bottom_panel_mode: BottomPanelMode::Editor, + header_width: 200.0, + inspector_width: ui_theme::INSPECTOR_WIDTH, + bottom_panel_height: 250.0, + resize_dragging: false, + resize_last_y: 0.0, + tracklist_resize_dragging: false, + tracklist_resize_last_x: 0.0, + inspector_resize_dragging: false, + inspector_resize_last_x: 0.0, + lcd_editing: false, + lcd_bar_input: String::new(), + lcd_beat_input: String::new(), + lcd_tick_input: String::new(), + track_list_scrollable_id: scrollable::Id::unique(), + timeline_scrollable_id: scrollable::Id::unique(), + scroll_offset_y: 0.0, + scroll_source: None, + master_volume: 1.0, + master_pan: 0.0, + cycle_enabled: false, + cycle_start_bar: 1, + cycle_end_bar: 5, + metronome_enabled: false, + count_in_enabled: false, + punch_enabled: false, + selected_track: None, + track_count, + h_zoom: 100.0, + v_zoom: 1.0, + routing: RoutingManager::new(), + analysis_fft_size: 2048, + waveform_cache, + clipboard: Clipboard::new(), + markers, + next_marker_id, + history: History::new(), + status_message: None, + last_status: None, + groups, + active_clips: std::collections::HashSet::new(), + score_note_duration: score::ScoreNoteDuration::Quarter, + icons: IconSet::load(), + meter_levels: std::collections::HashMap::new(), + master_meter: (0.0, 0.0), + session_player_config: SessionPlayerConfig::default(), + session_player_bars: 4, + discovered_plugins: Vec::new(), + spatial_mode: crate::engine::atmos::SpatialRenderMode::default(), + mono_lane: crate::engine::atmos::MonoLane::default(), + module_params: ModuleParamState::new(), + pattern_length: 16, + module_gui: ModuleGuiManager::new(), + inspector_signal_open: true, + inspector_sends_open: true, + inspector_automation_open: false, + inspector_spatial_open: false, + inspector_analysis_open: false, + show_network_view: false, + }, + Task::none(), + ) + } +} diff --git a/au-o2-gui/src/editor/layout.rs b/au-o2-gui/src/editor/layout.rs new file mode 100644 index 0000000..05b70db --- /dev/null +++ b/au-o2-gui/src/editor/layout.rs @@ -0,0 +1,162 @@ +use iced::Task; + +use super::{Editor, Message}; +use crate::gui::theme as ui_theme; +use crate::engine::EngineCommand; +use crate::timing::MusicalTime; + +impl Editor { + pub(crate) fn handle_layout(&mut self, message: Message) -> Task { + match message { + Message::ToolSelected(tool) => self.active_tool = tool, + Message::ToggleInspector => self.show_inspector = !self.show_inspector, + Message::ToggleBottomPanel => self.show_bottom_panel = !self.show_bottom_panel, + Message::ToggleTempoLane => self.show_tempo_lane = !self.show_tempo_lane, + Message::SetBottomPanelMode(mode) => { + if self.bottom_panel_mode == mode && self.show_bottom_panel { + self.show_bottom_panel = false; + } else { + self.bottom_panel_mode = mode; + self.show_bottom_panel = true; + } + } + Message::ResizeHandlePressed => { + self.resize_dragging = true; + debug_log!("resize: drag start, height={:.0}", self.bottom_panel_height); + } + Message::ResizeHandleMoved(point) => { + if self.resize_dragging { + let delta = self.resize_last_y - point.y; + self.bottom_panel_height = (self.bottom_panel_height + delta) + .clamp(ui_theme::BOTTOM_PANEL_MIN, ui_theme::BOTTOM_PANEL_MAX); + } + self.resize_last_y = point.y; + } + Message::ResizeHandleReleased => { + self.resize_dragging = false; + debug_log!("resize: drag end, height={:.0}", self.bottom_panel_height); + } + Message::TrackListResizePressed => { + self.tracklist_resize_dragging = true; + } + Message::TrackListResizeMoved(point) => { + if self.tracklist_resize_dragging { + let delta = point.x - self.tracklist_resize_last_x; + self.header_width = (self.header_width + delta) + .clamp(ui_theme::TRACKLIST_WIDTH_MIN, ui_theme::TRACKLIST_WIDTH_MAX); + } + self.tracklist_resize_last_x = point.x; + } + Message::TrackListResizeReleased => { + self.tracklist_resize_dragging = false; + } + Message::InspectorResizePressed => { + self.inspector_resize_dragging = true; + } + Message::InspectorResizeMoved(point) => { + if self.inspector_resize_dragging { + let delta = point.x - self.inspector_resize_last_x; + self.inspector_width = (self.inspector_width + delta) + .clamp(ui_theme::INSPECTOR_WIDTH_MIN, ui_theme::INSPECTOR_WIDTH_MAX); + } + self.inspector_resize_last_x = point.x; + } + Message::InspectorResizeReleased => { + self.inspector_resize_dragging = false; + } + Message::EscapePressed => { + if self.lcd_editing { + self.lcd_editing = false; + } else { + self.modal_state = None; + } + } + Message::CloseModal => { + self.modal_state = None; + } + Message::LcdClicked => { + self.lcd_editing = true; + self.lcd_bar_input = self.current_position.bar.to_string(); + self.lcd_beat_input = self.current_position.beat.to_string(); + self.lcd_tick_input = self.current_position.tick.to_string(); + } + Message::LcdBarChanged(s) => { self.lcd_bar_input = s; } + Message::LcdBeatChanged(s) => { self.lcd_beat_input = s; } + Message::LcdTickChanged(s) => { self.lcd_tick_input = s; } + Message::LcdConfirm => { + let bar = self.lcd_bar_input.parse::().unwrap_or(self.current_position.bar).max(1); + let beat = self.lcd_beat_input.parse::().unwrap_or(self.current_position.beat).clamp(1, self.time_signature_numerator as u32); + let tick = self.lcd_tick_input.parse::().unwrap_or(self.current_position.tick).min(959); + let pos = MusicalTime::new(bar, beat, tick); + self.current_position = pos; + let sample_pos = pos.to_samples_mapped( + &self.tempo_map, + self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::Seek { sample_pos }); + } + self.lcd_editing = false; + } + Message::ZoomToFit => { + if !self.tracks.is_empty() { + let max_sample = self.tracks.iter() + .flat_map(|t| t.regions.iter()) + .map(|r| r.start_sample + r.length_samples) + .max() + .unwrap_or(self.project_config.sample_rate as u64 * 60); + let total_beats = max_sample as f32 / (self.project_config.sample_rate as f32 * 60.0 / self.tempo); + let target_width = 1200.0; + self.h_zoom = (target_width / total_beats.max(1.0)).clamp(10.0, 1000.0); + } + } + Message::ZoomH(factor) => { + self.h_zoom = (self.h_zoom * factor).clamp(10.0, 1000.0); + } + Message::ZoomV(factor) => { + self.v_zoom = (self.v_zoom * factor).clamp(0.3, 5.0); + } + _ => {} + } + Task::none() + } + + pub(crate) fn handle_scroll(&mut self, message: Message) -> Task { + use iced::widget::scrollable; + match message { + Message::TrackListScrolled(viewport) => { + if self.scroll_source == Some(super::ScrollSource::Timeline) { + self.scroll_source = None; + return Task::none(); + } + self.scroll_source = Some(super::ScrollSource::TrackList); + self.scroll_offset_y = viewport.absolute_offset().y; + return scrollable::scroll_to( + self.timeline_scrollable_id.clone(), + scrollable::AbsoluteOffset { + x: 0.0, + y: self.scroll_offset_y, + }, + ); + } + Message::TimelineScrolled(viewport) => { + if self.scroll_source == Some(super::ScrollSource::TrackList) { + self.scroll_source = None; + return Task::none(); + } + self.scroll_source = Some(super::ScrollSource::Timeline); + self.scroll_offset_y = viewport.absolute_offset().y; + return scrollable::scroll_to( + self.track_list_scrollable_id.clone(), + scrollable::AbsoluteOffset { + x: 0.0, + y: self.scroll_offset_y, + }, + ); + } + _ => {} + } + Task::none() + } +} diff --git a/au-o2-gui/src/editor/markers.rs b/au-o2-gui/src/editor/markers.rs new file mode 100644 index 0000000..0f23e7a --- /dev/null +++ b/au-o2-gui/src/editor/markers.rs @@ -0,0 +1,36 @@ +use super::{Editor, Message}; +use crate::engine::EngineCommand; + +impl Editor { + pub(crate) fn handle_markers(&mut self, message: Message) { + match message { + Message::AddMarker(position) => { + let id = self.next_marker_id; + self.next_marker_id += 1; + let name = format!("Marker {}", id); + self.markers.push(crate::timing::Marker { id, name, position }); + self.markers.sort_by(|a, b| a.position.cmp(&b.position)); + self.mark_dirty(); + } + Message::DeleteMarker(id) => { + self.markers.retain(|m| m.id != id); + self.mark_dirty(); + } + Message::JumpToMarker(id) => { + if let Some(marker) = self.markers.iter().find(|m| m.id == id) { + let pos = marker.position; + self.current_position = pos; + let sample_pos = pos.to_samples_mapped( + &self.tempo_map, + self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::Seek { sample_pos }); + } + } + } + _ => {} + } + } +} diff --git a/au-o2-gui/src/editor/midi.rs b/au-o2-gui/src/editor/midi.rs new file mode 100644 index 0000000..ea8e5de --- /dev/null +++ b/au-o2-gui/src/editor/midi.rs @@ -0,0 +1,292 @@ +use super::{decode_region_audio, Editor, Message}; +use crate::engine::EngineCommand; +use crate::region::Region; +use crate::timing::MusicalTime; +use crate::waveform::WaveformPeaks; + +impl Editor { + pub(crate) fn handle_midi(&mut self, message: Message) { + match message { + Message::AddMidiNote { track_index, region_id, note } => { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(region) = track.regions.iter_mut().find(|r| r.id == region_id) { + region.midi_notes.push(note); + self.mark_dirty(); + } + } + if let Some(track) = self.tracks.get(track_index) { + if let Some(region) = track.regions.iter().find(|r| r.id == region_id) { + self.sync_midi_region_to_engine(track, region); + } + } + } + Message::RemoveMidiNote { track_index, region_id, note_index } => { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(region) = track.regions.iter_mut().find(|r| r.id == region_id) { + if note_index < region.midi_notes.len() { + region.midi_notes.remove(note_index); + self.mark_dirty(); + } + } + } + if let Some(track) = self.tracks.get(track_index) { + if let Some(region) = track.regions.iter().find(|r| r.id == region_id) { + self.sync_midi_region_to_engine(track, region); + } + } + } + Message::MoveMidiNote { track_index, region_id, note_index, start_tick, note } => { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(region) = track.regions.iter_mut().find(|r| r.id == region_id) { + if let Some(mn) = region.midi_notes.get_mut(note_index) { + mn.start_tick = start_tick; + mn.note = note; + self.dirty = true; + } + } + } + if let Some(track) = self.tracks.get(track_index) { + if let Some(region) = track.regions.iter().find(|r| r.id == region_id) { + self.sync_midi_region_to_engine(track, region); + } + } + } + Message::SetNoteVelocity { track_index, region_id, note_index, velocity } => { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(region) = track.regions.iter_mut().find(|r| r.id == region_id) { + if let Some(mn) = region.midi_notes.get_mut(note_index) { + mn.velocity = velocity; + self.dirty = true; + } + } + } + if let Some(track) = self.tracks.get(track_index) { + if let Some(region) = track.regions.iter().find(|r| r.id == region_id) { + self.sync_midi_region_to_engine(track, region); + } + } + } + Message::ResizeMidiNote { track_index, region_id, note_index, duration_ticks } => { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(region) = track.regions.iter_mut().find(|r| r.id == region_id) { + if let Some(mn) = region.midi_notes.get_mut(note_index) { + mn.duration_ticks = duration_ticks; + self.dirty = true; + } + } + } + if let Some(track) = self.tracks.get(track_index) { + if let Some(region) = track.regions.iter().find(|r| r.id == region_id) { + self.sync_midi_region_to_engine(track, region); + } + } + } + Message::SetPatternLength(len) => { + self.pattern_length = len; + } + Message::SetScoreNoteDuration(dur) => { + self.score_note_duration = dur; + } + Message::QuantizeMidiNotes { track_index, region_id, grid_ticks } => { + if grid_ticks > 0 { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(region) = track.regions.iter_mut().find(|r| r.id == region_id) { + for mn in &mut region.midi_notes { + let half = grid_ticks / 2; + mn.start_tick = ((mn.start_tick + half) / grid_ticks) * grid_ticks; + } + self.mark_dirty(); + } + } + if let Some(track) = self.tracks.get(track_index) { + if let Some(region) = track.regions.iter().find(|r| r.id == region_id) { + self.sync_midi_region_to_engine(track, region); + } + } + } + } + _ => {} + } + } + + pub(crate) fn quantize_selected(&mut self) { + use crate::timing::TICKS_PER_BEAT; + + let grid_ticks = TICKS_PER_BEAT as u64 / 4; + let sample_rate = self.project_config.sample_rate; + let tempo = self.tempo; + let bpb = self.time_signature_numerator as u32; + + let mut targets: Vec<(usize, uuid::Uuid, bool)> = Vec::new(); + for (ti, track) in self.tracks.iter().enumerate() { + for region in &track.regions { + if region.selected { + targets.push((ti, region.id, region.is_midi())); + } + } + } + + for (track_index, region_id, is_midi) in targets { + if is_midi { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(region) = track.regions.iter_mut().find(|r| r.id == region_id) { + let half = grid_ticks / 2; + for mn in &mut region.midi_notes { + mn.start_tick = ((mn.start_tick + half) / grid_ticks) * grid_ticks; + } + } + } + if let Some(track) = self.tracks.get(track_index) { + if let Some(region) = track.regions.iter().find(|r| r.id == region_id) { + self.sync_midi_region_to_engine(track, region); + } + } + } else { + self.audio_quantize_region(track_index, region_id, grid_ticks, sample_rate, tempo, bpb); + } + } + } + + pub(crate) fn audio_quantize_region( + &mut self, + track_index: usize, + region_id: uuid::Uuid, + grid_ticks: u64, + sample_rate: u32, + tempo: f32, + beats_per_bar: u32, + ) { + let track = match self.tracks.get(track_index) { + Some(t) => t, + None => return, + }; + let region = match track.regions.iter().find(|r| r.id == region_id) { + Some(r) => r, + None => return, + }; + + let audio_file = match ®ion.audio_file { + Some(f) => f.clone(), + None => return, + }; + + let abs_path = self.project_path.join(&audio_file); + let (audio_l, audio_r) = match decode_region_audio(&abs_path, sample_rate) { + Some(a) => a, + None => return, + }; + + let region_start = region.start_sample as usize; + let region_end = (region_start + region.length_samples as usize).min(audio_l.len()); + if region_end <= region_start { + return; + } + + let slice_l = &audio_l[region_start..region_end]; + let slice_r = &audio_r[region_start.min(audio_r.len())..region_end.min(audio_r.len())]; + + let onsets = crate::engine::onset::detect_onsets(slice_l, slice_r, sample_rate); + if onsets.is_empty() { + return; + } + + let original_region = region.clone(); + + let beats_per_second = tempo as f64 / 60.0; + let samples_per_beat = sample_rate as f64 / beats_per_second; + let samples_per_tick = samples_per_beat / crate::timing::TICKS_PER_BEAT as f64; + let grid_samples = (grid_ticks as f64 * samples_per_tick) as u64; + + let mut split_points: Vec = onsets + .iter() + .map(|&o| region.start_sample + o as u64) + .filter(|&s| s > region.start_sample && s < region.start_sample + region.length_samples) + .collect(); + split_points.sort(); + split_points.dedup(); + + let mut boundaries = vec![region.start_sample]; + boundaries.extend_from_slice(&split_points); + boundaries.push(region.start_sample + region.length_samples); + + let mut new_regions: Vec = Vec::new(); + for i in 0..boundaries.len() - 1 { + let seg_start = boundaries[i]; + let seg_end = boundaries[i + 1]; + let seg_len = seg_end - seg_start; + if seg_len == 0 { + continue; + } + + let snapped = if grid_samples > 0 { + let half = grid_samples / 2; + ((seg_start + half) / grid_samples) * grid_samples + } else { + seg_start + }; + + let snapped_time = MusicalTime::from_samples_mapped(snapped, &self.tempo_map, sample_rate, beats_per_bar); + let dur_time = MusicalTime::from_samples_mapped(seg_len, &self.tempo_map, sample_rate, beats_per_bar); + + let new_region = Region { + id: uuid::Uuid::new_v4(), + start_time: snapped_time, + duration: dur_time, + audio_file: Some(audio_file.clone()), + start_sample: seg_start, + length_samples: seg_len, + selected: false, + fade_in_samples: if i == 0 { original_region.fade_in_samples } else { 0 }, + fade_out_samples: if i == boundaries.len() - 2 { original_region.fade_out_samples } else { 0 }, + midi_notes: Vec::new(), + playback_rate: original_region.playback_rate, + }; + + let s = seg_start as usize; + let e = seg_end as usize; + if s < audio_l.len() && e <= audio_l.len() { + let sl = &audio_l[s..e]; + let sr = &audio_r[s.min(audio_r.len())..e.min(audio_r.len())]; + self.waveform_cache.insert(new_region.id, WaveformPeaks::from_stereo(sl, sr)); + + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: self.tracks[track_index].bus_name.clone(), + region_id: new_region.id, + start_sample: snapped, + audio_l: sl.to_vec(), + audio_r: sr.to_vec(), + fade_in_samples: new_region.fade_in_samples, + fade_out_samples: new_region.fade_out_samples, + }); + } + } + + new_regions.push(new_region); + } + + if new_regions.is_empty() { + return; + } + + if let Some(track) = self.tracks.get_mut(track_index) { + track.regions.retain(|r| r.id != region_id); + } + self.waveform_cache.remove(®ion_id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id }); + } + + if let Some(track) = self.tracks.get_mut(track_index) { + for r in &new_regions { + track.regions.push(r.clone()); + } + } + + self.history.push(crate::history::EditCommand::AudioQuantize { + track_index, + original_region, + result_regions: new_regions, + }); + } +} diff --git a/au-o2-gui/src/editor/mod.rs b/au-o2-gui/src/editor/mod.rs new file mode 100644 index 0000000..7b5ed16 --- /dev/null +++ b/au-o2-gui/src/editor/mod.rs @@ -0,0 +1,675 @@ +mod transport; +mod markers; +mod layout; +mod tracks; +mod edit_actions; +mod timeline_events; +mod modules; +mod module_gui; +mod export; +mod sends; +mod automation; +mod midi; +mod regions; +mod takes; +mod freeze; +mod groups; +mod spatial; +mod stems; +mod clip_launcher; +mod session_player; +mod engine_tick; +mod undo; +mod redo; +mod clipboard; +mod tempo_detect; +mod helpers; +mod session; +mod init; +mod view; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Instant; + +pub(crate) use helpers::{decode_region_audio, apply_flex}; + +use crate::modules::plugin_host::PluginInfo; + +use iced::widget::scrollable; +use iced::window; +use iced::{Element, Task}; + +use crate::behaviors; +use crate::clipboard::Clipboard; +use crate::config::ProjectConfig; +use crate::engine::session_player::{PlayerStyle, ScaleType, SessionPlayerConfig}; +use crate::engine::atmos::ObjectPosition; +use crate::engine::{EngineCommand, EngineHandle, TransportState}; +use crate::export::{ExportConfig, ExportFormat}; +use crate::gui::editor::{new_track_wizard, score, timeline, track_header}; +use crate::gui::icons::IconSet; +use crate::module_gui_manager::ModuleGuiManager; +use crate::routing::RoutingManager; +use crate::history::History; +use crate::timing::MusicalTime; +use crate::track::Track; +use crate::waveform::WaveformCache; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tool { + Pointer, + Eraser, + Scissors, + Zoom, +} + +impl Tool { + pub const ALL: [Tool; 4] = [ + Tool::Pointer, + Tool::Eraser, + Tool::Scissors, + Tool::Zoom, + ]; + + pub fn hint(&self) -> &'static str { + match self { + Tool::Pointer => "Pointer", + Tool::Eraser => "Eraser", + Tool::Scissors => "Scissors", + Tool::Zoom => "Zoom", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BottomPanelMode { + Editor, + Mixer, + StepSequencer, + ScoreEditor, + ClipLauncher, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusLevel { + Info, + Warning, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ScrollSource { + TrackList, + Timeline, +} + +#[derive(Debug, Clone)] +pub enum ModalState { + NewTrackWizard(new_track_wizard::State), + ExportDialog(ExportConfig), +} + +pub struct ModuleParamState { + pub expanded: Option, + pub descriptors: HashMap>, + pub values: HashMap<(u32, String), f32>, +} + +impl ModuleParamState { + pub fn new() -> Self { + Self { + expanded: None, + descriptors: HashMap::new(), + values: HashMap::new(), + } + } + + pub fn remove_module(&mut self, module_id: u32) { + self.descriptors.remove(&module_id); + self.values.retain(|(id, _), _| *id != module_id); + if self.expanded == Some(module_id) { + self.expanded = None; + } + } +} + +pub struct Editor { + pub(crate) project_path: PathBuf, + pub(crate) project_config: ProjectConfig, + pub(crate) tracks: Vec, + pub(crate) modal_state: Option, + pub(crate) engine: Option, + pub(crate) dirty: bool, + + pub(crate) transport: TransportState, + pub(crate) record_armed: bool, + pub(crate) current_position: MusicalTime, + pub(crate) tempo: f32, + pub(crate) tempo_map: crate::timing::TempoMap, + pub(crate) show_tempo_lane: bool, + pub(crate) time_signature_numerator: u8, + pub(crate) time_signature_denominator: u8, + + pub(crate) active_tool: Tool, + + pub(crate) show_inspector: bool, + pub(crate) show_bottom_panel: bool, + pub(crate) bottom_panel_mode: BottomPanelMode, + + pub(crate) header_width: f32, + pub(crate) inspector_width: f32, + pub(crate) bottom_panel_height: f32, + pub(crate) resize_dragging: bool, + pub(crate) resize_last_y: f32, + pub(crate) tracklist_resize_dragging: bool, + pub(crate) tracklist_resize_last_x: f32, + pub(crate) inspector_resize_dragging: bool, + pub(crate) inspector_resize_last_x: f32, + + pub(crate) lcd_editing: bool, + pub(crate) lcd_bar_input: String, + pub(crate) lcd_beat_input: String, + pub(crate) lcd_tick_input: String, + + pub(crate) track_list_scrollable_id: scrollable::Id, + pub(crate) timeline_scrollable_id: scrollable::Id, + pub(crate) scroll_offset_y: f32, + pub(crate) scroll_source: Option, + + pub(crate) cycle_enabled: bool, + pub(crate) cycle_start_bar: u32, + pub(crate) cycle_end_bar: u32, + pub(crate) metronome_enabled: bool, + pub(crate) count_in_enabled: bool, + pub(crate) punch_enabled: bool, + + pub(crate) master_volume: f32, + pub(crate) master_pan: f32, + + pub(crate) selected_track: Option, + pub(crate) track_count: usize, + + pub(crate) h_zoom: f32, + pub(crate) v_zoom: f32, + + pub(crate) routing: RoutingManager, + pub(crate) analysis_fft_size: usize, + + pub(crate) waveform_cache: WaveformCache, + pub(crate) clipboard: Clipboard, + pub(crate) markers: Vec, + pub(crate) next_marker_id: u32, + pub(crate) history: History, + + pub(crate) status_message: Option<(String, StatusLevel, Instant)>, + pub(crate) last_status: Option<(String, StatusLevel)>, + + pub(crate) groups: Vec, + pub(crate) active_clips: std::collections::HashSet, + pub(crate) score_note_duration: score::ScoreNoteDuration, + pub(crate) icons: IconSet, + pub(crate) meter_levels: HashMap, + pub(crate) master_meter: (f32, f32), + + pub(crate) session_player_config: SessionPlayerConfig, + pub(crate) session_player_bars: u32, + pub(crate) discovered_plugins: Vec, + pub(crate) spatial_mode: crate::engine::atmos::SpatialRenderMode, + pub(crate) mono_lane: crate::engine::atmos::MonoLane, + + pub module_params: ModuleParamState, + pub(crate) pattern_length: usize, + + pub(crate) module_gui: ModuleGuiManager, + + pub inspector_signal_open: bool, + pub inspector_sends_open: bool, + pub inspector_automation_open: bool, + pub inspector_spatial_open: bool, + pub inspector_analysis_open: bool, + + pub show_network_view: bool, +} + +#[derive(Debug, Clone)] +pub enum Message { + PlayPressed, + StopPressed, + RecordPressed, + RewindPressed, + + CycleToggled, + MetronomeToggled, + CountInToggled, + PunchToggled, + + AddMarker(MusicalTime), + DeleteMarker(u32), + JumpToMarker(u32), + + MasterVolumeChanged(f32), + MasterPanChanged(f32), + + ToolSelected(Tool), + + ToggleInspector, + ToggleBottomPanel, + ToggleTempoLane, + SetBottomPanelMode(BottomPanelMode), + ResizeHandlePressed, + ResizeHandleMoved(iced::Point), + ResizeHandleReleased, + TrackListResizePressed, + TrackListResizeMoved(iced::Point), + TrackListResizeReleased, + InspectorResizePressed, + InspectorResizeMoved(iced::Point), + InspectorResizeReleased, + + ShowNewTrackWizard, + NewTrackWizard(new_track_wizard::Message), + TrackHeader(usize, track_header::Message), + + Timeline(timeline::Message), + + TrackListScrolled(scrollable::Viewport), + TimelineScrolled(scrollable::Viewport), + + EscapePressed, + CloseModal, + + EditAction(behaviors::Action), + + ZoomH(f32), + ZoomV(f32), + + AddModuleToTrack(usize, String), + RemoveModuleFromTrack(usize, u32), + ToggleModuleDisabled(usize, u32), + MoveModuleUp(usize, u32), + MoveModuleDown(usize, u32), + ShowModulePicker(usize), + HideModulePicker, + LoadPluginOnTrack(usize, PathBuf), + + SetAnalysisFftSize(usize), + + ShowExportDialog, + ExportFormatSelected(ExportFormat), + ExportBitDepthSelected(u16), + ExportNormalizeToggled, + ExportFilenameChanged(String), + ExportConfirm, + + ShowSendPicker(usize), + HideSendPicker, + AddSend { track_index: usize, aux_bus_name: String }, + RemoveSend { track_index: usize, send_index: usize }, + SetSendLevel { track_index: usize, send_index: usize, level: f32 }, + + SetTrackAutomationMode(usize, crate::automation::AutomationMode), + AddAutomationLane(usize, crate::automation::AutomationTarget), + AddAutomationPoint { track_index: usize, lane_index: usize, sample_pos: u64, value: f32 }, + RemoveAutomationPoint { track_index: usize, lane_index: usize, point_index: usize }, + MoveAutomationPoint { track_index: usize, lane_index: usize, point_index: usize, sample_pos: u64, value: f32 }, + + AddTempoPoint { sample_pos: u64, tempo: f32 }, + RemoveTempoPoint(usize), + MoveTempoPoint { index: usize, sample_pos: u64, tempo: f32 }, + + AddMidiNote { track_index: usize, region_id: uuid::Uuid, note: crate::region::MidiNote }, + RemoveMidiNote { track_index: usize, region_id: uuid::Uuid, note_index: usize }, + MoveMidiNote { track_index: usize, region_id: uuid::Uuid, note_index: usize, start_tick: u64, note: u8 }, + SetNoteVelocity { track_index: usize, region_id: uuid::Uuid, note_index: usize, velocity: u8 }, + ResizeMidiNote { track_index: usize, region_id: uuid::Uuid, note_index: usize, duration_ticks: u64 }, + + SetPatternLength(usize), + SetScoreNoteDuration(score::ScoreNoteDuration), + QuantizeMidiNotes { track_index: usize, region_id: uuid::Uuid, grid_ticks: u64 }, + + SetRegionPlaybackRate { track_index: usize, region_id: uuid::Uuid, rate: f32 }, + + SetActiveTake { track_index: usize, folder_id: uuid::Uuid, take_index: usize }, + DeleteTake { track_index: usize, folder_id: uuid::Uuid, take_index: usize }, + + FreezeTrack(usize), + + CreateGroup, + DeleteGroup(uuid::Uuid), + SetGroupVolume { group_id: uuid::Uuid, volume: f32 }, + SetGroupMute(uuid::Uuid), + SetGroupSolo(uuid::Uuid), + AssignTrackToGroup { track_index: usize, group_id: Option }, + + DetectTempo, + DetectTempoCurve, + + SetSpatialMode(crate::engine::atmos::SpatialRenderMode), + SetMonoLane(crate::engine::atmos::MonoLane), + SetTrackSpatialPosition { track_index: usize, x: f32, y: f32 }, + SetTrackSpatialElevation { track_index: usize, z: f32 }, + SetTrackObjectSize { track_index: usize, size: f32 }, + + SplitStems { track_index: usize, region_id: uuid::Uuid }, + + TriggerClip { track_index: usize, region_id: uuid::Uuid }, + StopClip { region_id: uuid::Uuid }, + TriggerScene(usize), + StopAllClips, + + SetSessionPlayerStyle(PlayerStyle), + SetSessionPlayerRoot(u8), + SetSessionPlayerScale(ScaleType), + SetSessionPlayerDensity(f32), + SetSessionPlayerSwing(f32), + SetSessionPlayerBars(u32), + GenerateSessionPattern, + + ExpandModule(u32), + CollapseModule, + SetModuleParam { module_id: u32, key: String, value: f32 }, + SelectTrackAndExpandModule { track_index: usize, module_id: u32 }, + + OpenModuleGui(u32), + CloseModuleGui(u32), + FramebufferMouseDown(u32), + FramebufferMouseUp(u32), + FramebufferResize(u32, u32, u32), + + ToggleInspectorSignal, + ToggleInspectorSends, + ToggleInspectorAutomation, + ToggleInspectorSpatial, + ToggleInspectorAnalysis, + + ToggleNetworkView, + CreatePortConnection { from_module: u32, from_port: u32, to_module: u32, to_port: u32 }, + DeletePortConnection { from_module: u32, from_port: u32, to_module: u32, to_port: u32 }, + + SetTrackColor(usize, crate::track::TrackColor), + SetMonitorMode(usize, crate::track::MonitorMode), + + LcdClicked, + LcdBarChanged(String), + LcdBeatChanged(String), + LcdTickChanged(String), + LcdConfirm, + + ZoomToFit, + + EngineTick, +} + +impl Editor { + pub fn update(&mut self, message: Message) -> Task { + match message { + // Transport + Message::PlayPressed + | Message::StopPressed + | Message::RecordPressed + | Message::RewindPressed + | Message::CycleToggled + | Message::MetronomeToggled + | Message::CountInToggled + | Message::PunchToggled + | Message::MasterVolumeChanged(_) + | Message::MasterPanChanged(_) => return self.handle_transport(message), + + // Markers + Message::AddMarker(_) + | Message::DeleteMarker(_) + | Message::JumpToMarker(_) => self.handle_markers(message), + + // Layout / panels + Message::ToolSelected(_) + | Message::ToggleInspector + | Message::ToggleBottomPanel + | Message::ToggleTempoLane + | Message::SetBottomPanelMode(_) + | Message::ResizeHandlePressed + | Message::ResizeHandleMoved(_) + | Message::ResizeHandleReleased + | Message::TrackListResizePressed + | Message::TrackListResizeMoved(_) + | Message::TrackListResizeReleased + | Message::InspectorResizePressed + | Message::InspectorResizeMoved(_) + | Message::InspectorResizeReleased + | Message::EscapePressed + | Message::CloseModal + | Message::LcdClicked + | Message::LcdBarChanged(_) + | Message::LcdBeatChanged(_) + | Message::LcdTickChanged(_) + | Message::LcdConfirm + | Message::ZoomToFit + | Message::ZoomH(_) + | Message::ZoomV(_) => return self.handle_layout(message), + + // Scroll sync + Message::TrackListScrolled(_) + | Message::TimelineScrolled(_) => return self.handle_scroll(message), + + // Track management + Message::ShowNewTrackWizard + | Message::NewTrackWizard(_) + | Message::TrackHeader(_, _) + | Message::SetTrackColor(_, _) + | Message::SetMonitorMode(_, _) => return self.handle_tracks(message), + + // Edit actions + Message::EditAction(_) => return self.handle_edit_actions(message), + + // Timeline events + Message::Timeline(_) => return self.handle_timeline(message), + + // Modules + Message::AddModuleToTrack(_, _) + | Message::RemoveModuleFromTrack(_, _) + | Message::ToggleModuleDisabled(_, _) + | Message::MoveModuleUp(_, _) + | Message::MoveModuleDown(_, _) + | Message::ShowModulePicker(_) + | Message::HideModulePicker + | Message::LoadPluginOnTrack(_, _) + | Message::SetAnalysisFftSize(_) + | Message::ExpandModule(_) + | Message::CollapseModule + | Message::SetModuleParam { .. } + | Message::SelectTrackAndExpandModule { .. } => return self.handle_modules(message), + + // Module GUI + Message::OpenModuleGui(_) + | Message::CloseModuleGui(_) + | Message::FramebufferMouseDown(_) + | Message::FramebufferMouseUp(_) + | Message::FramebufferResize(_, _, _) => return self.handle_module_gui(message), + + // Export + Message::ShowExportDialog + | Message::ExportFormatSelected(_) + | Message::ExportBitDepthSelected(_) + | Message::ExportNormalizeToggled + | Message::ExportFilenameChanged(_) + | Message::ExportConfirm => self.handle_export(message), + + // Sends + Message::ShowSendPicker(_) + | Message::HideSendPicker + | Message::AddSend { .. } + | Message::RemoveSend { .. } + | Message::SetSendLevel { .. } => self.handle_sends(message), + + // Automation + Message::SetTrackAutomationMode(_, _) + | Message::AddAutomationLane(_, _) + | Message::AddAutomationPoint { .. } + | Message::RemoveAutomationPoint { .. } + | Message::MoveAutomationPoint { .. } + | Message::AddTempoPoint { .. } + | Message::RemoveTempoPoint(_) + | Message::MoveTempoPoint { .. } => self.handle_automation(message), + + // MIDI + Message::AddMidiNote { .. } + | Message::RemoveMidiNote { .. } + | Message::MoveMidiNote { .. } + | Message::SetNoteVelocity { .. } + | Message::ResizeMidiNote { .. } + | Message::SetPatternLength(_) + | Message::SetScoreNoteDuration(_) + | Message::QuantizeMidiNotes { .. } => self.handle_midi(message), + + // Regions / flex + Message::SetRegionPlaybackRate { .. } => self.handle_regions(message), + + // Takes + Message::SetActiveTake { .. } + | Message::DeleteTake { .. } => self.handle_takes(message), + + // Freeze + Message::FreezeTrack(_) => self.handle_freeze(message), + + // Groups + Message::CreateGroup + | Message::DeleteGroup(_) + | Message::SetGroupVolume { .. } + | Message::SetGroupMute(_) + | Message::SetGroupSolo(_) + | Message::AssignTrackToGroup { .. } => self.handle_groups(message), + + // Tempo detection + Message::DetectTempo + | Message::DetectTempoCurve => self.handle_tempo_detect(message), + + // Spatial + Message::SetSpatialMode(_) + | Message::SetMonoLane(_) + | Message::SetTrackSpatialPosition { .. } + | Message::SetTrackSpatialElevation { .. } + | Message::SetTrackObjectSize { .. } => self.handle_spatial(message), + + // Stems + Message::SplitStems { .. } => self.handle_stems(message), + + // Clip launcher + Message::TriggerClip { .. } + | Message::StopClip { .. } + | Message::TriggerScene(_) + | Message::StopAllClips => return self.handle_clip_launcher(message), + + // Session player + Message::SetSessionPlayerStyle(_) + | Message::SetSessionPlayerRoot(_) + | Message::SetSessionPlayerScale(_) + | Message::SetSessionPlayerDensity(_) + | Message::SetSessionPlayerSwing(_) + | Message::SetSessionPlayerBars(_) + | Message::GenerateSessionPattern => self.handle_session_player(message), + + // Inspector toggles + Message::ToggleInspectorSignal + | Message::ToggleInspectorSends + | Message::ToggleInspectorAutomation + | Message::ToggleInspectorSpatial + | Message::ToggleInspectorAnalysis => self.handle_inspector_toggles(message), + + // Port network + Message::ToggleNetworkView => { + self.show_network_view = !self.show_network_view; + } + Message::CreatePortConnection { from_module, from_port, to_module, to_port } => { + debug_log!( + "port connect: {}:{} -> {}:{}", + from_module, from_port, to_module, to_port, + ); + self.set_status(format!( + "Connection: module {} port {} -> module {} port {}", + from_module, from_port, to_module, to_port, + )); + } + Message::DeletePortConnection { from_module, from_port, to_module, to_port } => { + debug_log!( + "port disconnect: {}:{} -> {}:{}", + from_module, from_port, to_module, to_port, + ); + self.set_status(format!( + "Disconnected: module {} port {} -> module {} port {}", + from_module, from_port, to_module, to_port, + )); + } + + // Engine tick + Message::EngineTick => return self.handle_engine_tick(), + } + Task::none() + } + + fn handle_inspector_toggles(&mut self, message: Message) { + match message { + Message::ToggleInspectorSignal => { self.inspector_signal_open = !self.inspector_signal_open; } + Message::ToggleInspectorSends => { self.inspector_sends_open = !self.inspector_sends_open; } + Message::ToggleInspectorAutomation => { self.inspector_automation_open = !self.inspector_automation_open; } + Message::ToggleInspectorSpatial => { self.inspector_spatial_open = !self.inspector_spatial_open; } + Message::ToggleInspectorAnalysis => { self.inspector_analysis_open = !self.inspector_analysis_open; } + _ => {} + } + } + + pub fn module_for_window(&self, window_id: window::Id) -> Option { + self.module_gui.module_for_window(window_id) + } + + pub fn module_window_view(&self, window_id: window::Id) -> Option> { + self.module_gui.module_window_view(window_id) + } + + pub fn module_window_title(&self, window_id: window::Id) -> Option { + self.module_gui.module_window_title(window_id) + } + + pub fn close_module_window_by_id(&mut self, window_id: window::Id) -> Task { + self.module_gui.close_module_window_by_id(window_id, self.engine.as_ref()) + } + + pub fn tracks_ref(&self) -> &[Track] { + &self.tracks + } + + pub fn set_status(&mut self, msg: String) { + self.last_status = None; + self.status_message = Some((msg, StatusLevel::Info, Instant::now())); + } + + pub fn is_dirty(&self) -> bool { + self.dirty + } + + pub fn project_name(&self) -> &str { + &self.project_config.name + } + + pub fn has_engine(&self) -> bool { + self.engine.is_some() + } + + pub(crate) fn mark_dirty(&mut self) { + if !self.dirty { + debug_log!("project marked dirty"); + } + self.dirty = true; + } + + pub(crate) fn send_spatial_update(engine: &EngineHandle, track: &crate::track::Track) { + engine.send(EngineCommand::SetObjectPosition { + bus_name: track.bus_name.clone(), + position: ObjectPosition { + x: track.spatial_x, + y: track.spatial_y, + z: track.spatial_z, + size: track.object_size, + }, + }); + } +} diff --git a/au-o2-gui/src/editor/module_gui.rs b/au-o2-gui/src/editor/module_gui.rs new file mode 100644 index 0000000..fe533f4 --- /dev/null +++ b/au-o2-gui/src/editor/module_gui.rs @@ -0,0 +1,29 @@ +use iced::Task; + +use super::{Editor, Message}; + +impl Editor { + pub(crate) fn handle_module_gui(&mut self, message: Message) -> Task { + match message { + Message::OpenModuleGui(module_id) => { + return self.module_gui.handle_open_gui( + module_id, self.engine.as_ref(), &self.module_params, &self.routing.module_names, + ); + } + Message::CloseModuleGui(module_id) => { + return self.module_gui.handle_close_gui(module_id, self.engine.as_ref()); + } + Message::FramebufferMouseDown(module_id) => { + self.module_gui.handle_framebuffer_mouse_down(module_id); + } + Message::FramebufferMouseUp(module_id) => { + self.module_gui.handle_framebuffer_mouse_up(module_id); + } + Message::FramebufferResize(module_id, width, height) => { + self.module_gui.handle_framebuffer_resize(module_id, width, height); + } + _ => {} + } + Task::none() + } +} diff --git a/au-o2-gui/src/editor/modules.rs b/au-o2-gui/src/editor/modules.rs new file mode 100644 index 0000000..5a8d638 --- /dev/null +++ b/au-o2-gui/src/editor/modules.rs @@ -0,0 +1,82 @@ +use iced::Task; + +use super::{Editor, Message}; +use crate::engine::EngineCommand; + +impl Editor { + pub(crate) fn handle_modules(&mut self, message: Message) -> Task { + match message { + Message::AddModuleToTrack(track_idx, module_type) => { + self.routing.handle_add_module(track_idx, module_type, &self.tracks, self.engine.as_ref()); + } + Message::RemoveModuleFromTrack(track_idx, module_id) => { + let module_gui = &mut self.module_gui; + if self.routing.handle_remove_module( + track_idx, module_id, &mut self.tracks, self.engine.as_ref(), + &mut self.module_params, + &mut |mid| module_gui.handle_module_removed(mid), + ) { + self.mark_dirty(); + } + } + Message::ToggleModuleDisabled(track_idx, module_id) => { + if self.routing.handle_toggle_disabled(track_idx, module_id, &self.tracks, self.engine.as_ref()) { + self.mark_dirty(); + } + } + Message::MoveModuleUp(track_idx, module_id) => { + if self.routing.handle_move_module_up(track_idx, module_id, &mut self.tracks, self.engine.as_ref()) { + self.mark_dirty(); + } + } + Message::MoveModuleDown(track_idx, module_id) => { + if self.routing.handle_move_module_down(track_idx, module_id, &mut self.tracks, self.engine.as_ref()) { + self.mark_dirty(); + } + } + Message::ShowModulePicker(track_idx) => { + self.routing.module_picker_track = Some(track_idx); + } + Message::HideModulePicker => { + self.routing.module_picker_track = None; + } + Message::LoadPluginOnTrack(track_idx, plugin_path) => { + self.routing.handle_load_plugin(track_idx, plugin_path, &self.tracks, self.engine.as_ref()); + } + Message::SetAnalysisFftSize(size) => { + self.analysis_fft_size = size; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetHilbertFftSize { size }); + } + } + Message::ExpandModule(module_id) => { + if self.module_params.expanded == Some(module_id) { + self.module_params.expanded = None; + } else { + self.module_params.expanded = Some(module_id); + if !self.module_params.descriptors.contains_key(&module_id) { + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::QueryModuleParams { module_id }); + } + } + } + } + Message::CollapseModule => { + self.module_params.expanded = None; + } + Message::SetModuleParam { module_id, key, value } => { + self.module_params.values.insert((module_id, key.clone()), value); + self.module_gui.write_param(module_id, &key, value); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetParam { module_id, key, value }); + } + } + Message::SelectTrackAndExpandModule { track_index, module_id } => { + self.selected_track = Some(track_index); + return self.update(Message::ExpandModule(module_id)); + } + _ => {} + } + Task::none() + } +} diff --git a/au-o2-gui/src/editor/redo.rs b/au-o2-gui/src/editor/redo.rs new file mode 100644 index 0000000..cd28891 --- /dev/null +++ b/au-o2-gui/src/editor/redo.rs @@ -0,0 +1,338 @@ +use super::{decode_region_audio, Editor}; +use crate::engine::EngineCommand; +use crate::history::EditCommand; +use crate::region::Region; +use crate::timing::MusicalTime; +use crate::waveform::WaveformPeaks; + +impl Editor { + pub(crate) fn perform_redo(&mut self) { + let cmd = match self.history.pop_redo() { + Some(c) => c, + None => return, + }; + match cmd { + EditCommand::MoveRegion { track_index, region_id, old_start, new_start, old_start_sample, new_start_sample } => { + debug_log!("redo MoveRegion: region={} track={}", region_id, track_index); + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(region) = track.regions.iter_mut().find(|r| r.id == region_id) { + region.start_time = new_start; + region.start_sample = new_start_sample; + } + } + self.history.push(EditCommand::MoveRegion { + track_index, region_id, old_start, new_start, old_start_sample, new_start_sample, + }); + } + EditCommand::MoveRegionAcrossTracks { region_id, old_track, new_track, old_start, new_start, old_start_sample, new_start_sample } => { + debug_log!("redo MoveRegionAcrossTracks: region={} track {}->{}", + region_id, old_track, new_track); + if old_track < self.tracks.len() { + if let Some(pos) = self.tracks[old_track].regions.iter().position(|r| r.id == region_id) { + let mut region = self.tracks[old_track].regions.remove(pos); + region.start_time = new_start; + region.start_sample = new_start_sample; + + if new_track < self.tracks.len() { + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id }); + if let Some(ref audio_file) = region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Ok(decoder) = crate::codec::XtcDecoder::open(&abs_path) { + if let Ok((audio_l, audio_r)) = decoder.decode_real(&abs_path) { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: self.tracks[new_track].bus_name.clone(), + region_id, + start_sample: new_start_sample, + audio_l, + audio_r, + fade_in_samples: region.fade_in_samples, + fade_out_samples: region.fade_out_samples, + }); + } + } + } + } + self.tracks[new_track].regions.push(region); + } + } + } + self.history.push(EditCommand::MoveRegionAcrossTracks { + region_id, old_track, new_track, old_start, new_start, old_start_sample, new_start_sample, + }); + } + EditCommand::DeleteRegion { track_index, region } => { + debug_log!("redo DeleteRegion: region={} track={}", region.id, track_index); + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(pos) = track.regions.iter().position(|r| r.id == region.id) { + track.regions.remove(pos); + self.waveform_cache.remove(®ion.id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: region.id }); + } + } + } + self.history.push(EditCommand::DeleteRegion { track_index, region }); + } + EditCommand::SplitRegion { track_index, original_id, original_region, left_id, right_id, split_sample } => { + debug_log!("redo SplitRegion: original={} track={} split_sample={}", + original_id, track_index, split_sample); + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(pos) = track.regions.iter().position(|r| r.id == original_id) { + let region = track.regions.remove(pos); + let region_start = region.start_sample; + let region_end = region_start + region.length_samples; + let left_samples = split_sample - region_start; + let right_samples = region_end - split_sample; + + let left_duration = MusicalTime::from_samples_mapped( + left_samples, &self.tempo_map, + self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + let right_start_time = MusicalTime::from_samples_mapped( + split_sample, &self.tempo_map, + self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + let right_duration = MusicalTime::from_samples_mapped( + right_samples, &self.tempo_map, + self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + + let left_region = Region { + id: left_id, + start_time: region.start_time, + duration: left_duration, + audio_file: region.audio_file.clone(), + start_sample: region_start, + length_samples: left_samples, + selected: false, + fade_in_samples: region.fade_in_samples, + fade_out_samples: 0, + midi_notes: Vec::new(), + playback_rate: region.playback_rate, + }; + let right_region = Region { + id: right_id, + start_time: right_start_time, + duration: right_duration, + audio_file: region.audio_file.clone(), + start_sample: split_sample, + length_samples: right_samples, + selected: false, + fade_in_samples: 0, + fade_out_samples: region.fade_out_samples, + midi_notes: Vec::new(), + playback_rate: region.playback_rate, + }; + + self.waveform_cache.remove(&original_id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: original_id }); + } + + if let Some(ref audio_file) = region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Ok(decoder) = crate::codec::XtcDecoder::open(&abs_path) { + if let Ok((audio_l, audio_r)) = decoder.decode_real(&abs_path) { + let offset_l = (region_start as usize).min(audio_l.len()); + let end_l = (split_sample as usize).min(audio_l.len()); + let end_r = (region_end as usize).min(audio_l.len()); + + if end_l > offset_l { + self.waveform_cache.insert( + left_id, + WaveformPeaks::from_stereo( + &audio_l[offset_l..end_l], + &audio_r[offset_l..end_l.min(audio_r.len())], + ), + ); + if let Some(ref engine) = self.engine { + let bus = track.bus_name.clone(); + engine.send(EngineCommand::LoadRegionAudio { + bus_name: bus, + region_id: left_id, + start_sample: region_start, + audio_l: audio_l[offset_l..end_l].to_vec(), + audio_r: audio_r[offset_l..end_l.min(audio_r.len())].to_vec(), + fade_in_samples: region.fade_in_samples, + fade_out_samples: 0, + }); + } + } + if end_r > end_l { + self.waveform_cache.insert( + right_id, + WaveformPeaks::from_stereo( + &audio_l[end_l..end_r], + &audio_r[end_l..end_r.min(audio_r.len())], + ), + ); + if let Some(ref engine) = self.engine { + let bus = track.bus_name.clone(); + engine.send(EngineCommand::LoadRegionAudio { + bus_name: bus, + region_id: right_id, + start_sample: split_sample, + audio_l: audio_l[end_l..end_r].to_vec(), + audio_r: audio_r[end_l..end_r.min(audio_r.len())].to_vec(), + fade_in_samples: 0, + fade_out_samples: region.fade_out_samples, + }); + } + } + } + } + } + + track.regions.insert(pos, right_region); + track.regions.insert(pos, left_region); + } + } + self.history.push(EditCommand::SplitRegion { + track_index, original_id, original_region, left_id, right_id, split_sample, + }); + } + EditCommand::DeleteTrack { index, track } => { + debug_log!("redo DeleteTrack: index={}", index); + if index < self.tracks.len() { + let removed = self.tracks.remove(index); + for region in &removed.regions { + self.waveform_cache.remove(®ion.id); + } + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::RemoveBus { name: removed.bus_name.clone() }); + } + } + self.history.push(EditCommand::DeleteTrack { index, track }); + } + EditCommand::CreateTrack { index } => { + debug_log!("redo CreateTrack: index={}", index); + self.history.push(EditCommand::CreateTrack { index }); + } + EditCommand::DuplicateTrack { source_index, new_index } => { + debug_log!("redo DuplicateTrack: source={} new={}", source_index, new_index); + if source_index < self.tracks.len() { + let source = &self.tracks[source_index]; + let mut dup = source.clone(); + dup.id = uuid::Uuid::new_v4(); + dup.name = format!("{} Copy", dup.name); + dup.bus_name = format!("track_{}", dup.id.as_simple()); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::CreateBus { + name: dup.bus_name.clone(), + is_midi: dup.track_type == crate::track::TrackType::Midi, + }); + } + let insert_at = new_index.min(self.tracks.len()); + self.tracks.insert(insert_at, dup); + self.track_count += 1; + } + self.history.push(EditCommand::DuplicateTrack { source_index, new_index }); + } + EditCommand::PasteRegions { entries } => { + debug_log!("redo PasteRegions: {} regions", entries.len()); + let entries_clone = entries.clone(); + for (track_index, region) in &entries { + if *track_index < self.tracks.len() { + if let Some(ref audio_file) = region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Ok(decoder) = crate::codec::XtcDecoder::open(&abs_path) { + if let Ok((audio_l, audio_r)) = decoder.decode_real(&abs_path) { + self.waveform_cache.insert( + region.id, + WaveformPeaks::from_stereo(&audio_l, &audio_r), + ); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: self.tracks[*track_index].bus_name.clone(), + region_id: region.id, + start_sample: region.start_sample, + audio_l, + audio_r, + fade_in_samples: region.fade_in_samples, + fade_out_samples: region.fade_out_samples, + }); + } + } + } + } + self.tracks[*track_index].regions.push(region.clone()); + } + } + self.history.push(EditCommand::PasteRegions { entries: entries_clone }); + } + EditCommand::CutRegions { entries } => { + debug_log!("redo CutRegions: {} regions", entries.len()); + let entries_clone = entries.clone(); + for (track_index, region) in &entries { + if *track_index < self.tracks.len() { + self.tracks[*track_index].regions.retain(|r| r.id != region.id); + self.waveform_cache.remove(®ion.id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: region.id }); + } + } + } + self.history.push(EditCommand::CutRegions { entries: entries_clone }); + } + EditCommand::AudioQuantize { track_index, original_region, result_regions } => { + if track_index < self.tracks.len() { + self.tracks[track_index].regions.retain(|r| r.id != original_region.id); + self.waveform_cache.remove(&original_region.id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: original_region.id }); + } + if let Some(ref audio_file) = original_region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, self.project_config.sample_rate) { + for r in &result_regions { + let s = (r.start_sample as usize).min(audio_l.len()); + let e = (s + r.length_samples as usize).min(audio_l.len()); + let sl = &audio_l[s..e]; + let sr = &audio_r[s.min(audio_r.len())..e.min(audio_r.len())]; + self.waveform_cache.insert(r.id, WaveformPeaks::from_stereo(sl, sr)); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: self.tracks[track_index].bus_name.clone(), + region_id: r.id, + start_sample: r.start_sample, + audio_l: sl.to_vec(), + audio_r: sr.to_vec(), + fade_in_samples: r.fade_in_samples, + fade_out_samples: r.fade_out_samples, + }); + } + } + } + } + for r in &result_regions { + self.tracks[track_index].regions.push(r.clone()); + } + } + self.history.push(EditCommand::AudioQuantize { + track_index, + original_region, + result_regions, + }); + } + EditCommand::SetTempo { old_tempo, new_tempo, old_tempo_map, new_tempo_map } => { + self.tempo = new_tempo; + self.project_config.tempo = new_tempo; + self.tempo_map = new_tempo_map.clone(); + self.sync_tempo_to_engine(); + self.history.push(EditCommand::SetTempo { + old_tempo, + new_tempo, + old_tempo_map, + new_tempo_map, + }); + } + EditCommand::SplitStems { track_indices } => { + self.history.push(EditCommand::SplitStems { track_indices }); + } + } + } +} diff --git a/au-o2-gui/src/editor/regions.rs b/au-o2-gui/src/editor/regions.rs new file mode 100644 index 0000000..ec53aab --- /dev/null +++ b/au-o2-gui/src/editor/regions.rs @@ -0,0 +1,241 @@ +use super::{apply_flex, decode_region_audio, Editor, Message}; +use crate::engine::EngineCommand; +use crate::history::EditCommand; +use crate::region::Region; +use crate::timing::MusicalTime; +use crate::waveform::WaveformPeaks; + +impl Editor { + pub(crate) fn handle_regions(&mut self, message: Message) { + if let Message::SetRegionPlaybackRate { track_index, region_id, rate } = message { + let clamped = rate.clamp(0.25, 4.0); + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(region) = track.regions.iter_mut().find(|r| r.id == region_id) { + region.playback_rate = clamped; + self.mark_dirty(); + } + } + if let (Some(track), Some(engine)) = (self.tracks.get(track_index), &self.engine) { + if let Some(region) = track.regions.iter().find(|r| r.id == region_id) { + if let Some(ref audio_file) = region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, self.project_config.sample_rate) { + let (sl, sr) = apply_flex(audio_l, audio_r, clamped); + engine.send(EngineCommand::LoadRegionAudio { + bus_name: track.bus_name.clone(), + region_id: region.id, + start_sample: region.start_sample, + audio_l: sl, + audio_r: sr, + fade_in_samples: region.fade_in_samples, + fade_out_samples: region.fade_out_samples, + }); + } + } + } + } + } + } + + pub(crate) fn delete_selected(&mut self) { + let mut deleted_any_region = false; + for ti in 0..self.tracks.len() { + let mut ri = 0; + while ri < self.tracks[ti].regions.len() { + if self.tracks[ti].regions[ri].selected { + let region = self.tracks[ti].regions.remove(ri); + self.waveform_cache.remove(®ion.id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: region.id }); + } + self.history.push(EditCommand::DeleteRegion { + track_index: ti, + region, + }); + deleted_any_region = true; + } else { + ri += 1; + } + } + } + + if !deleted_any_region { + if let Some(i) = self.selected_track { + if i < self.tracks.len() { + let removed = self.tracks.remove(i); + for region in &removed.regions { + self.waveform_cache.remove(®ion.id); + } + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::RemoveBus { + name: removed.bus_name.clone(), + }); + } + self.history.push(EditCommand::DeleteTrack { + index: i, + track: removed, + }); + self.selected_track = None; + } + } + } + } + + pub(crate) fn delete_region(&mut self, track_index: usize, region_id: uuid::Uuid) { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(pos) = track.regions.iter().position(|r| r.id == region_id) { + let region = track.regions.remove(pos); + self.waveform_cache.remove(®ion.id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id }); + } + self.history.push(EditCommand::DeleteRegion { + track_index, + region, + }); + } + } + } + + pub(crate) fn split_region(&mut self, track_index: usize, region_id: uuid::Uuid, split_sample: u64) { + let track = match self.tracks.get_mut(track_index) { + Some(t) => t, + None => return, + }; + let region_idx = match track.regions.iter().position(|r| r.id == region_id) { + Some(i) => i, + None => return, + }; + + let region = &track.regions[region_idx]; + let region_start_sample = region.start_sample; + let region_end_sample = region_start_sample + region.length_samples; + + if split_sample <= region_start_sample || split_sample >= region_end_sample { + return; + } + + let left_samples = split_sample - region_start_sample; + let right_samples = region_end_sample - split_sample; + + let left_duration = MusicalTime::from_samples_mapped( + left_samples, &self.tempo_map, + self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + let right_start = MusicalTime::from_samples_mapped( + split_sample, &self.tempo_map, + self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + let right_duration = MusicalTime::from_samples_mapped( + right_samples, &self.tempo_map, + self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + + let original_region = region.clone(); + let audio_file = region.audio_file.clone(); + + let left_id = uuid::Uuid::new_v4(); + let left_region = Region { + id: left_id, + start_time: original_region.start_time, + duration: left_duration, + audio_file: audio_file.clone(), + start_sample: region_start_sample, + length_samples: left_samples, + selected: false, + fade_in_samples: original_region.fade_in_samples, + fade_out_samples: 0, + midi_notes: Vec::new(), + playback_rate: original_region.playback_rate, + }; + + let right_id = uuid::Uuid::new_v4(); + let right_region = Region { + id: right_id, + start_time: right_start, + duration: right_duration, + audio_file: audio_file.clone(), + start_sample: split_sample, + length_samples: right_samples, + selected: false, + fade_in_samples: 0, + fade_out_samples: original_region.fade_out_samples, + midi_notes: Vec::new(), + playback_rate: original_region.playback_rate, + }; + + if let Some(ref audio_file) = audio_file { + let abs_path = self.project_path.join(audio_file); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, self.project_config.sample_rate) { + let offset_l = (region_start_sample as usize).min(audio_l.len()); + let end_l = (split_sample as usize).min(audio_l.len()); + let end_r = (region_end_sample as usize).min(audio_l.len()); + + if end_l > offset_l { + self.waveform_cache.insert( + left_id, + WaveformPeaks::from_stereo( + &audio_l[offset_l..end_l], + &audio_r[offset_l..end_l.min(audio_r.len())], + ), + ); + } + if end_r > end_l { + self.waveform_cache.insert( + right_id, + WaveformPeaks::from_stereo( + &audio_l[end_l..end_r], + &audio_r[end_l..end_r.min(audio_r.len())], + ), + ); + } + + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id }); + let bus_name = track.bus_name.clone(); + + if end_l > offset_l { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: bus_name.clone(), + region_id: left_id, + start_sample: region_start_sample, + audio_l: audio_l[offset_l..end_l].to_vec(), + audio_r: audio_r[offset_l..end_l.min(audio_r.len())].to_vec(), + fade_in_samples: original_region.fade_in_samples, + fade_out_samples: 0, + }); + } + if end_r > end_l { + engine.send(EngineCommand::LoadRegionAudio { + bus_name, + region_id: right_id, + start_sample: split_sample, + audio_l: audio_l[end_l..end_r].to_vec(), + audio_r: audio_r[end_l..end_r.min(audio_r.len())].to_vec(), + fade_in_samples: 0, + fade_out_samples: original_region.fade_out_samples, + }); + } + } + } + } + + self.waveform_cache.remove(®ion_id); + let track = &mut self.tracks[track_index]; + track.regions.remove(region_idx); + track.regions.insert(region_idx, right_region); + track.regions.insert(region_idx, left_region); + + self.history.push(EditCommand::SplitRegion { + track_index, + original_id: region_id, + original_region, + left_id, + right_id, + split_sample, + }); + } +} diff --git a/au-o2-gui/src/editor/sends.rs b/au-o2-gui/src/editor/sends.rs new file mode 100644 index 0000000..bcdc3b4 --- /dev/null +++ b/au-o2-gui/src/editor/sends.rs @@ -0,0 +1,30 @@ +use super::{Editor, Message}; + +impl Editor { + pub(crate) fn handle_sends(&mut self, message: Message) { + match message { + Message::ShowSendPicker(track_idx) => { + self.routing.send_picker_track = Some(track_idx); + } + Message::HideSendPicker => { + self.routing.send_picker_track = None; + } + Message::AddSend { track_index, aux_bus_name } => { + if self.routing.handle_add_send(track_index, aux_bus_name, &mut self.tracks, self.engine.as_ref()) { + self.dirty = true; + } + } + Message::RemoveSend { track_index, send_index } => { + if self.routing.handle_remove_send(track_index, send_index, &mut self.tracks, self.engine.as_ref()) { + self.dirty = true; + } + } + Message::SetSendLevel { track_index, send_index, level } => { + if self.routing.handle_set_send_level(track_index, send_index, level, &mut self.tracks, self.engine.as_ref()) { + self.dirty = true; + } + } + _ => {} + } + } +} diff --git a/au-o2-gui/src/editor/session.rs b/au-o2-gui/src/editor/session.rs new file mode 100644 index 0000000..90d40ec --- /dev/null +++ b/au-o2-gui/src/editor/session.rs @@ -0,0 +1,69 @@ +use std::path::PathBuf; + +use super::Editor; + +impl Editor { + pub(crate) fn sync_config(&mut self) { + self.project_config.tracks = self.tracks.clone(); + self.project_config.markers = self.markers.clone(); + self.project_config.tempo = self.tempo; + self.project_config.tempo_points = self.tempo_map.points.clone(); + self.project_config.time_signature_numerator = self.time_signature_numerator; + self.project_config.time_signature_denominator = self.time_signature_denominator; + self.project_config.groups = self.groups.clone(); + } + + pub fn save_project(&mut self) { + debug_log!("save_project: {}", self.project_path.display()); + self.sync_config(); + let config_path = self.project_path.join("project.toml"); + match toml::to_string_pretty(&self.project_config) { + Ok(content) => { + match std::fs::write(&config_path, content) { + Ok(()) => { + self.dirty = false; + debug_log!(" saved, dirty cleared"); + } + Err(_e) => { + debug_log!(" ERROR writing project.toml: {}", _e); + } + } + } + Err(_e) => { + debug_log!(" ERROR serializing project: {}", _e); + } + } + } + + #[allow(dead_code)] + pub fn save_project_as(&mut self, new_path: PathBuf) { + debug_log!("save_project_as: {} -> {}", self.project_path.display(), new_path.display()); + + if let Err(_e) = std::fs::create_dir_all(&new_path) { + debug_log!(" ERROR creating directory: {}", _e); + return; + } + + if let Ok(entries) = std::fs::read_dir(&self.project_path) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("xtc") && path.is_file() { + let dest = new_path.join(path.file_name().unwrap()); + if let Err(_e) = std::fs::copy(&path, &dest) { + debug_log!(" ERROR copying {}: {}", path.display(), _e); + } + } + } + } + + let new_name = new_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Untitled") + .to_string(); + + self.project_path = new_path; + self.project_config.name = new_name; + self.save_project(); + } +} diff --git a/au-o2-gui/src/editor/session_player.rs b/au-o2-gui/src/editor/session_player.rs new file mode 100644 index 0000000..046e5d4 --- /dev/null +++ b/au-o2-gui/src/editor/session_player.rs @@ -0,0 +1,86 @@ +use super::{Editor, Message}; +use crate::engine::session_player; +use crate::region::Region; +use crate::timing::MusicalTime; + +impl Editor { + pub(crate) fn handle_session_player(&mut self, message: Message) { + match message { + Message::SetSessionPlayerStyle(style) => { + self.session_player_config.style = style; + } + Message::SetSessionPlayerRoot(root) => { + self.session_player_config.root_note = root; + } + Message::SetSessionPlayerScale(scale) => { + self.session_player_config.scale = scale; + } + Message::SetSessionPlayerDensity(d) => { + self.session_player_config.density = d; + } + Message::SetSessionPlayerSwing(s) => { + self.session_player_config.swing = s; + } + Message::SetSessionPlayerBars(b) => { + self.session_player_bars = b; + } + Message::GenerateSessionPattern => { + self.generate_session_pattern(); + } + _ => {} + } + } + + fn generate_session_pattern(&mut self) { + let Some(track_idx) = self.selected_track else { return }; + let Some(track) = self.tracks.get(track_idx) else { return }; + if track.track_type != crate::track::TrackType::Midi { return } + + let beats_per_bar = self.time_signature_numerator as u32; + let notes = session_player::generate_pattern( + &self.session_player_config, + self.session_player_bars, + beats_per_bar, + ); + + let ticks_per_beat = crate::timing::TICKS_PER_BEAT as f64; + let midi_notes: Vec = notes.iter().map(|n| { + crate::region::MidiNote { + start_tick: (n.beat_offset * ticks_per_beat) as u64, + duration_ticks: (n.duration_beats * ticks_per_beat) as u64, + note: n.note, + velocity: n.velocity, + channel: 0, + } + }).collect(); + + let total_beats = self.session_player_bars as f64 * beats_per_bar as f64; + let total_ticks = (total_beats * ticks_per_beat) as u64; + let ticks_per_bar = beats_per_bar as u64 * crate::timing::TICKS_PER_BEAT as u64; + let duration_bars = (total_ticks / ticks_per_bar) as u32; + + let start = self.current_position; + let duration = MusicalTime::new(duration_bars, 1, 0); + let start_sample = start.to_samples_mapped(&self.tempo_map, self.project_config.sample_rate, beats_per_bar); + let midi_events = session_player::notes_to_midi_events( + ¬es, self.tempo as f64, self.project_config.sample_rate, + ); + let length_samples = midi_events.last() + .map(|(pos, _, _, _)| *pos) + .unwrap_or_else(|| duration.to_samples_mapped(&self.tempo_map, self.project_config.sample_rate, beats_per_bar)); + + let region = Region::with_midi( + start, duration, start_sample, length_samples, midi_notes, + ); + + let track = self.tracks.get_mut(track_idx).unwrap(); + track.regions.push(region); + self.mark_dirty(); + + let track = &self.tracks[track_idx]; + let region = track.regions.last().unwrap(); + self.sync_midi_region_to_engine(track, region); + + self.session_player_config.seed = self.session_player_config.seed.wrapping_add(1); + } +} diff --git a/au-o2-gui/src/editor/spatial.rs b/au-o2-gui/src/editor/spatial.rs new file mode 100644 index 0000000..9ed96a5 --- /dev/null +++ b/au-o2-gui/src/editor/spatial.rs @@ -0,0 +1,61 @@ +use super::{Editor, Message}; +use crate::engine::EngineCommand; + +impl Editor { + pub(crate) fn handle_spatial(&mut self, message: Message) { + match message { + Message::SetSpatialMode(mode) => { + self.spatial_mode = mode; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetSpatialMode(mode)); + } + } + Message::SetMonoLane(lane) => { + self.mono_lane = lane; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetMonoLane(lane)); + } + } + Message::SetTrackSpatialPosition { track_index, x, y } => { + if let Some(track) = self.tracks.get_mut(track_index) { + track.spatial_x = x.clamp(-1.0, 1.0); + track.spatial_y = y.clamp(-1.0, 1.0); + let pan = x.clamp(-1.0, 1.0); + track.pan = pan; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetBusPan { + bus_name: track.bus_name.clone(), + pan, + }); + let depth_atten = 1.0 - y.clamp(0.0, 1.0) * 0.5; + engine.send(EngineCommand::SetBusVolume { + bus_name: track.bus_name.clone(), + volume: track.volume * depth_atten, + }); + Self::send_spatial_update(engine, track); + } + self.mark_dirty(); + } + } + Message::SetTrackSpatialElevation { track_index, z } => { + if let Some(track) = self.tracks.get_mut(track_index) { + track.spatial_z = z.clamp(-1.0, 1.0); + if let Some(ref engine) = self.engine { + Self::send_spatial_update(engine, track); + } + self.mark_dirty(); + } + } + Message::SetTrackObjectSize { track_index, size } => { + if let Some(track) = self.tracks.get_mut(track_index) { + track.object_size = size.clamp(0.0, 1.0); + if let Some(ref engine) = self.engine { + Self::send_spatial_update(engine, track); + } + self.mark_dirty(); + } + } + _ => {} + } + } +} diff --git a/au-o2-gui/src/editor/stems.rs b/au-o2-gui/src/editor/stems.rs new file mode 100644 index 0000000..009dd05 --- /dev/null +++ b/au-o2-gui/src/editor/stems.rs @@ -0,0 +1,129 @@ +use std::time::Instant; + +use super::{decode_region_audio, Editor, Message, StatusLevel}; +use crate::engine::EngineCommand; +use crate::history::EditCommand; +use crate::region::Region; +use crate::track::Track; +use crate::waveform::WaveformPeaks; + +impl Editor { + pub(crate) fn handle_stems(&mut self, message: Message) { + if let Message::SplitStems { track_index, region_id } = message { + self.split_stems_from_region(track_index, region_id); + } + } + + fn split_stems_from_region(&mut self, track_index: usize, region_id: uuid::Uuid) { + let sample_rate = self.project_config.sample_rate; + + let (audio_file, start_sample, length_samples, start_time, duration, playback_rate, region_label) = { + let track = match self.tracks.get(track_index) { + Some(t) => t, + None => return, + }; + let region = match track.regions.iter().find(|r| r.id == region_id) { + Some(r) => r, + None => return, + }; + if region.is_midi() || region.audio_file.is_none() { + self.status_message = Some(("Cannot split stems from MIDI region".into(), StatusLevel::Warning, Instant::now())); + return; + } + let label = region.audio_file.as_ref() + .and_then(|f| std::path::Path::new(f).file_stem()) + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| track.name.clone()); + ( + region.audio_file.clone().unwrap(), + region.start_sample, + region.length_samples, + region.start_time, + region.duration, + region.playback_rate, + label, + ) + }; + + self.status_message = Some(("Splitting stems\u{2026}".into(), StatusLevel::Info, Instant::now())); + + let abs_path = self.project_path.join(&audio_file); + let (audio_l, audio_r) = match decode_region_audio(&abs_path, sample_rate) { + Some(a) => a, + None => { + self.status_message = Some(("Stem split failed: could not decode audio".into(), StatusLevel::Error, Instant::now())); + return; + } + }; + + let s = (start_sample as usize).min(audio_l.len()); + let e = (s + length_samples as usize).min(audio_l.len()); + let slice_l = &audio_l[s..e]; + let slice_r = &audio_r[s.min(audio_r.len())..e.min(audio_r.len())]; + + let stems = crate::engine::stems::split_stems(slice_l, slice_r); + + let stem_names = ["Bass", "Drums", "Vocals", "Other"]; + let stem_data = [stems.bass, stems.drums, stems.vocals, stems.other]; + let mut created_indices = Vec::with_capacity(4); + + for (name, (stem_l, stem_r)) in stem_names.iter().zip(stem_data.into_iter()) { + let config = crate::track::TrackConfig { + name: format!("{} - {}", region_label, name), + track_type: crate::track::TrackType::Audio, + }; + let track = Track::new(config, self.track_count); + self.track_count += 1; + + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::CreateBus { name: track.bus_name.clone(), is_midi: false }); + engine.send(EngineCommand::SetBusVolume { + bus_name: track.bus_name.clone(), + volume: track.volume, + }); + } + + let region = Region { + id: uuid::Uuid::new_v4(), + start_time, + duration, + audio_file: Some(audio_file.clone()), + start_sample, + length_samples, + selected: false, + fade_in_samples: 0, + fade_out_samples: 0, + midi_notes: Vec::new(), + playback_rate, + }; + + self.waveform_cache.insert(region.id, WaveformPeaks::from_stereo(&stem_l, &stem_r)); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: track.bus_name.clone(), + region_id: region.id, + start_sample, + audio_l: stem_l, + audio_r: stem_r, + fade_in_samples: 0, + fade_out_samples: 0, + }); + } + + let mut track = track; + track.regions.push(region); + let idx = self.tracks.len(); + self.tracks.push(track); + created_indices.push(idx); + } + + self.history.push(EditCommand::SplitStems { track_indices: created_indices }); + self.mark_dirty(); + self.status_message = Some(( + format!("Stems split: 4 tracks created ({} - Bass/Drums/Vocals/Other)", region_label), + StatusLevel::Info, + Instant::now(), + )); + } +} diff --git a/au-o2-gui/src/editor/takes.rs b/au-o2-gui/src/editor/takes.rs new file mode 100644 index 0000000..2a0cd1f --- /dev/null +++ b/au-o2-gui/src/editor/takes.rs @@ -0,0 +1,67 @@ +use super::{decode_region_audio, Editor, Message}; +use crate::engine::EngineCommand; + +impl Editor { + pub(crate) fn handle_takes(&mut self, message: Message) { + match message { + Message::SetActiveTake { track_index, folder_id, take_index } => { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(folder) = track.take_folders.iter_mut().find(|f| f.id == folder_id) { + if take_index < folder.take_ids.len() { + if let Some(prev_id) = folder.active_take_id() { + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: prev_id }); + } + } + folder.active_index = take_index; + let new_id = folder.take_ids[take_index]; + if let Some(region) = track.regions.iter().find(|r| r.id == new_id) { + if let Some(ref audio_file) = region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, self.project_config.sample_rate) { + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: track.bus_name.clone(), + region_id: new_id, + start_sample: region.start_sample, + audio_l, + audio_r, + fade_in_samples: region.fade_in_samples, + fade_out_samples: region.fade_out_samples, + }); + } + } + } + } + self.mark_dirty(); + } + } + } + } + Message::DeleteTake { track_index, folder_id, take_index } => { + let mut removed_id = None; + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(folder) = track.take_folders.iter_mut().find(|f| f.id == folder_id) { + if take_index < folder.take_ids.len() { + let rid = folder.take_ids.remove(take_index); + track.regions.retain(|r| r.id != rid); + if folder.active_index >= folder.take_ids.len() && !folder.take_ids.is_empty() { + folder.active_index = folder.take_ids.len() - 1; + } + removed_id = Some(rid); + } + } + track.take_folders.retain(|f| f.take_ids.len() > 1); + } + if let Some(rid) = removed_id { + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: rid }); + } + self.waveform_cache.remove(&rid); + self.mark_dirty(); + } + } + _ => {} + } + } +} diff --git a/au-o2-gui/src/editor/tempo_detect.rs b/au-o2-gui/src/editor/tempo_detect.rs new file mode 100644 index 0000000..239c49c --- /dev/null +++ b/au-o2-gui/src/editor/tempo_detect.rs @@ -0,0 +1,112 @@ +use std::time::Instant; + +use super::{decode_region_audio, Editor, Message, StatusLevel}; +use crate::history::EditCommand; + +impl Editor { + pub(crate) fn handle_tempo_detect(&mut self, message: Message) { + match message { + Message::DetectTempo => { + self.detect_tempo_from_regions(false); + } + Message::DetectTempoCurve => { + self.detect_tempo_from_regions(true); + } + _ => {} + } + } + + fn detect_tempo_from_regions(&mut self, detect_curve: bool) { + let sample_rate = self.project_config.sample_rate; + + let mut all_l: Vec = Vec::new(); + let mut all_r: Vec = Vec::new(); + + for track in &self.tracks { + for region in &track.regions { + if region.is_midi() { continue; } + if let Some(ref audio_file) = region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, sample_rate) { + let s = (region.start_sample as usize).min(audio_l.len()); + let e = (s + region.length_samples as usize).min(audio_l.len()); + let needed = region.start_sample as usize + (e - s); + if all_l.len() < needed { + all_l.resize(needed, 0.0); + all_r.resize(needed, 0.0); + } + for i in s..e { + let dst = region.start_sample as usize + (i - s); + if dst < all_l.len() { + all_l[dst] += audio_l[i]; + all_r[dst] += audio_r[i.min(audio_r.len().saturating_sub(1))]; + } + } + } + } + } + } + + if all_l.is_empty() { + self.status_message = Some(("No audio to analyze".into(), StatusLevel::Warning, Instant::now())); + return; + } + + let old_tempo = self.tempo; + let old_tempo_map = self.tempo_map.clone(); + + if detect_curve { + let curve = crate::engine::onset::estimate_tempo_curve( + &all_l, &all_r, sample_rate, 4.0, + ); + if curve.is_empty() { + self.status_message = Some(("Could not detect tempo".into(), StatusLevel::Warning, Instant::now())); + return; + } + self.tempo = curve[0].1; + self.tempo_map = crate::timing::TempoMap::new(self.tempo); + for &(sample_pos, tempo) in &curve { + self.tempo_map.insert_point(sample_pos, tempo); + } + self.project_config.tempo = self.tempo; + self.sync_tempo_to_engine(); + self.mark_dirty(); + self.history.push(EditCommand::SetTempo { + old_tempo, + new_tempo: self.tempo, + old_tempo_map, + new_tempo_map: self.tempo_map.clone(), + }); + self.status_message = Some(( + format!("Detected tempo curve: {} points, base {:.1} BPM (applied)", curve.len(), self.tempo), + StatusLevel::Info, + Instant::now(), + )); + } else { + match crate::engine::onset::estimate_tempo(&all_l, &all_r, sample_rate) { + Some(bpm) => { + let rounded = (bpm * 10.0).round() / 10.0; + self.tempo = rounded; + self.project_config.tempo = rounded; + self.tempo_map = crate::timing::TempoMap::new(rounded); + self.sync_tempo_to_engine(); + self.mark_dirty(); + self.history.push(EditCommand::SetTempo { + old_tempo, + new_tempo: rounded, + old_tempo_map, + new_tempo_map: self.tempo_map.clone(), + }); + self.status_message = Some(( + format!("Detected tempo: {:.1} BPM (applied)", rounded), + StatusLevel::Info, + Instant::now(), + )); + } + None => { + self.status_message = Some(("Could not detect tempo".into(), StatusLevel::Warning, Instant::now())); + } + } + } + } +} diff --git a/au-o2-gui/src/editor/timeline_events.rs b/au-o2-gui/src/editor/timeline_events.rs new file mode 100644 index 0000000..1a125e4 --- /dev/null +++ b/au-o2-gui/src/editor/timeline_events.rs @@ -0,0 +1,217 @@ +use iced::Task; + +use super::{decode_region_audio, Editor, Message}; +use crate::engine::EngineCommand; +use crate::gui::editor::timeline; +use crate::history::EditCommand; +use crate::region::Region; + + +impl Editor { + pub(crate) fn handle_timeline(&mut self, message: Message) -> Task { + match message { + Message::Timeline(timeline::Message::RegionClicked { track_index, region_id, shift }) => { + if !shift { + for track in &mut self.tracks { + for region in &mut track.regions { + region.selected = false; + } + } + } + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(region) = track.regions.iter_mut().find(|r| r.id == region_id) { + region.selected = !region.selected || !shift; + } + } + self.selected_track = Some(track_index); + for (idx, t) in self.tracks.iter_mut().enumerate() { + t.selected = idx == track_index; + } + } + Message::Timeline(timeline::Message::RegionDragging { region_id, track_index, new_start, new_start_sample }) => { + let mut found = None; + for (ti, track) in self.tracks.iter().enumerate() { + if let Some(ri) = track.regions.iter().position(|r| r.id == region_id) { + found = Some((ti, ri)); + break; + } + } + if let Some((old_track_idx, region_idx)) = found { + if old_track_idx == track_index { + let region = &mut self.tracks[old_track_idx].regions[region_idx]; + region.start_time = new_start; + region.start_sample = new_start_sample; + } else if track_index < self.tracks.len() { + let mut region = self.tracks[old_track_idx].regions.remove(region_idx); + region.start_time = new_start; + region.start_sample = new_start_sample; + self.tracks[track_index].regions.push(region); + } + } + } + Message::Timeline(timeline::Message::RegionDragEnd { + region_id, old_track, new_track, old_start, new_start, old_start_sample, new_start_sample, + }) => { + debug_log!("region drag end: region={} old_track={} new_track={}", region_id, old_track, new_track); + if old_track == new_track { + self.history.push(EditCommand::MoveRegion { + track_index: new_track, + region_id, + old_start, + new_start, + old_start_sample, + new_start_sample, + }); + } else { + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id }); + if new_track < self.tracks.len() { + if let Some(region) = self.tracks[new_track].regions.iter().find(|r| r.id == region_id) { + if let Some(ref audio_file) = region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, self.project_config.sample_rate) { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: self.tracks[new_track].bus_name.clone(), + region_id, + start_sample: new_start_sample, + audio_l, + audio_r, + fade_in_samples: region.fade_in_samples, + fade_out_samples: region.fade_out_samples, + }); + } + } + } + } + } + self.history.push(EditCommand::MoveRegionAcrossTracks { + region_id, + old_track, + new_track, + old_start, + new_start, + old_start_sample, + new_start_sample, + }); + } + self.mark_dirty(); + } + Message::Timeline(timeline::Message::RegionSplit { track_index, region_id, split_sample }) => { + self.split_region(track_index, region_id, split_sample); + self.mark_dirty(); + } + Message::Timeline(timeline::Message::RegionDelete { track_index, region_id }) => { + self.delete_region(track_index, region_id); + self.mark_dirty(); + } + Message::Timeline(timeline::Message::DeselectAll) => { + for track in &mut self.tracks { + for region in &mut track.regions { + region.selected = false; + } + } + } + Message::Timeline(timeline::Message::AddMarker(pos)) => { + return self.update(Message::AddMarker(pos)); + } + Message::Timeline(timeline::Message::DeleteMarker(id)) => { + return self.update(Message::DeleteMarker(id)); + } + Message::Timeline(timeline::Message::JumpToMarker(id)) => { + return self.update(Message::JumpToMarker(id)); + } + Message::Timeline(timeline::Message::SetCycleRange { start_bar, end_bar }) => { + self.cycle_start_bar = start_bar; + self.cycle_end_bar = end_bar; + self.cycle_enabled = true; + self.send_cycle_state(); + if self.punch_enabled { self.send_punch_state(); } + } + Message::Timeline(timeline::Message::SetRegionFade { track_index, region_id, fade_in_samples, fade_out_samples }) => { + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(region) = track.regions.iter_mut().find(|r| r.id == region_id) { + region.fade_in_samples = fade_in_samples; + region.fade_out_samples = fade_out_samples; + self.dirty = true; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetRegionFade { + region_id, + fade_in_samples, + fade_out_samples, + }); + } + } + } + } + Message::Timeline(timeline::Message::AutomationPointAdded { track_index, lane_index, sample_pos, value }) => { + return self.update(Message::AddAutomationPoint { track_index, lane_index, sample_pos, value }); + } + Message::Timeline(timeline::Message::AutomationPointMoved { track_index, lane_index, point_index, sample_pos, value }) => { + return self.update(Message::MoveAutomationPoint { track_index, lane_index, point_index, sample_pos, value }); + } + Message::Timeline(timeline::Message::AutomationPointRemoved { track_index, lane_index, point_index }) => { + return self.update(Message::RemoveAutomationPoint { track_index, lane_index, point_index }); + } + Message::Timeline(timeline::Message::TempoPointAdded { sample_pos, tempo }) => { + return self.update(Message::AddTempoPoint { sample_pos, tempo }); + } + Message::Timeline(timeline::Message::TempoPointMoved { index, sample_pos, tempo }) => { + return self.update(Message::MoveTempoPoint { index, sample_pos, tempo }); + } + Message::Timeline(timeline::Message::TempoPointRemoved { index }) => { + return self.update(Message::RemoveTempoPoint(index)); + } + Message::Timeline(timeline::Message::ZoomChanged(h, v)) => { + self.h_zoom = h.clamp(10.0, 1000.0); + self.v_zoom = v.clamp(0.3, 5.0); + } + Message::Timeline(timeline::Message::PlayheadMoved(time)) => { + self.current_position = time; + let sample_pos = time.to_samples_mapped( + &self.tempo_map, + self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::Seek { sample_pos }); + } + } + Message::Timeline(timeline::Message::CycleTake { track_index, folder_id }) => { + if let Some(track) = self.tracks.get(track_index) { + if let Some(folder) = track.take_folders.iter().find(|f| f.id == folder_id) { + let next = (folder.active_index + 1) % folder.take_ids.len(); + return self.update(Message::SetActiveTake { + track_index, + folder_id, + take_index: next, + }); + } + } + } + Message::Timeline(timeline::Message::SetActiveTake { track_index, folder_id, take_index }) => { + return self.update(Message::SetActiveTake { track_index, folder_id, take_index }); + } + Message::Timeline(timeline::Message::DeleteTake { track_index, folder_id, take_index }) => { + return self.update(Message::DeleteTake { track_index, folder_id, take_index }); + } + Message::Timeline(timeline::Message::CreateMidiRegion { + track_index, start_time, duration, start_sample, length_samples, + }) => { + if let Some(track) = self.tracks.get_mut(track_index) { + if track.track_type == crate::track::TrackType::Midi { + let region = Region::with_midi( + start_time, duration, start_sample, length_samples, Vec::new(), + ); + track.regions.push(region); + self.mark_dirty(); + } + } + } + Message::Timeline(timeline::Message::SetRegionPlaybackRate { track_index, region_id, rate }) => { + return self.update(Message::SetRegionPlaybackRate { track_index, region_id, rate }); + } + _ => {} + } + Task::none() + } +} diff --git a/au-o2-gui/src/editor/tracks.rs b/au-o2-gui/src/editor/tracks.rs new file mode 100644 index 0000000..640bfd6 --- /dev/null +++ b/au-o2-gui/src/editor/tracks.rs @@ -0,0 +1,176 @@ +use iced::Task; + +use super::{Editor, Message, ModalState}; +use crate::engine::{EngineCommand, TransportState}; +use crate::gui::editor::{new_track_wizard, track_header}; +use crate::history::EditCommand; +use crate::track::Track; + +impl Editor { + pub(crate) fn handle_tracks(&mut self, message: Message) -> Task { + match message { + Message::ShowNewTrackWizard => { + self.modal_state = + Some(ModalState::NewTrackWizard(new_track_wizard::State::default())); + } + Message::NewTrackWizard(wizard_message) => { + if let Some(ModalState::NewTrackWizard(state)) = &mut self.modal_state { + match wizard_message { + new_track_wizard::Message::Cancel => self.modal_state = None, + new_track_wizard::Message::Create => { + let config = state.config.clone(); + let track = Track::new(config, self.track_count); + self.track_count += 1; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::CreateBus { + name: track.bus_name.clone(), + is_midi: track.track_type == crate::track::TrackType::Midi, + }); + engine.send(EngineCommand::SetBusVolume { + bus_name: track.bus_name.clone(), + volume: track.volume, + }); + } + let idx = self.tracks.len(); + self.tracks.push(track); + self.history.push(EditCommand::CreateTrack { index: idx }); + self.modal_state = None; + } + new_track_wizard::Message::NameChanged(name) => state.config.name = name, + new_track_wizard::Message::TrackTypeSelected(track_type) => { + state.config.track_type = track_type + } + } + } + } + Message::TrackHeader(i, msg) => { + if let Some(track) = self.tracks.get_mut(i) { + match msg { + track_header::Message::MuteToggled => { + track.muted = !track.muted; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetBusMute { + bus_name: track.bus_name.clone(), + muted: track.muted, + }); + } + self.mark_dirty(); + } + track_header::Message::SoloToggled => { + track.soloed = !track.soloed; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetBusSolo { + bus_name: track.bus_name.clone(), + soloed: track.soloed, + }); + } + self.mark_dirty(); + } + track_header::Message::RecordArmToggled => { + track.record_armed = !track.record_armed; + if let Some(ref engine) = self.engine { + if track.record_armed { + engine.send(EngineCommand::ArmTrack { + bus_name: track.bus_name.clone(), + }); + } else { + engine.send(EngineCommand::DisarmTrack { + bus_name: track.bus_name.clone(), + }); + } + } + } + track_header::Message::VolumeChanged(vol) => { + track.volume = if (vol - 0.75).abs() < 0.02 { 0.75 } else { vol }; + let group_vol = track.group_id + .and_then(|gid| self.groups.iter().find(|g| g.id == gid)) + .map(|g| g.volume) + .unwrap_or(1.0); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetBusVolume { + bus_name: track.bus_name.clone(), + volume: track.volume * group_vol, + }); + } + let should_record = self.transport == TransportState::Playing && track.automation_mode.writes(); + let vol_val = track.volume; + self.mark_dirty(); + if should_record { + let sample_pos = self.current_position.to_samples_mapped( + &self.tempo_map, self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + self.record_automation_point(i, crate::automation::AutomationTarget::Volume, sample_pos, vol_val); + } + } + track_header::Message::PanChanged(pan) => { + track.pan = if pan.abs() < 0.02 { 0.0 } else { pan }; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetBusPan { + bus_name: track.bus_name.clone(), + pan: track.pan, + }); + } + let should_record = self.transport == TransportState::Playing && track.automation_mode.writes(); + let pan_val = track.pan; + self.mark_dirty(); + if should_record { + let sample_pos = self.current_position.to_samples_mapped( + &self.tempo_map, self.project_config.sample_rate, + self.time_signature_numerator as u32, + ); + self.record_automation_point(i, crate::automation::AutomationTarget::Pan, sample_pos, pan_val); + } + } + track_header::Message::Delete => { + let removed = self.tracks.remove(i); + for region in &removed.regions { + self.waveform_cache.remove(®ion.id); + } + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::RemoveBus { + name: removed.bus_name.clone(), + }); + } + self.history.push(EditCommand::DeleteTrack { + index: i, + track: removed, + }); + self.mark_dirty(); + if self.selected_track == Some(i) { + self.selected_track = None; + } else if let Some(sel) = self.selected_track { + if sel > i { + self.selected_track = Some(sel - 1); + } + } + } + track_header::Message::Select => { + self.selected_track = Some(i); + for (idx, t) in self.tracks.iter_mut().enumerate() { + t.selected = idx == i; + } + } + track_header::Message::FreezeToggled => { + return self.update(Message::FreezeTrack(i)); + } + } + } + } + Message::SetTrackColor(i, color) => { + if let Some(track) = self.tracks.get_mut(i) { + track.color = color; + self.mark_dirty(); + } + } + Message::SetMonitorMode(i, mode) => { + if let Some(track) = self.tracks.get_mut(i) { + track.monitor_mode = mode; + self.mark_dirty(); + } + } + _ => {} + } + Task::none() + } +} diff --git a/au-o2-gui/src/editor/transport.rs b/au-o2-gui/src/editor/transport.rs new file mode 100644 index 0000000..0f5ca7d --- /dev/null +++ b/au-o2-gui/src/editor/transport.rs @@ -0,0 +1,133 @@ +use iced::Task; + +use super::{Editor, Message}; +use crate::engine::{EngineCommand, TransportState}; + +impl Editor { + pub(crate) fn handle_transport(&mut self, message: Message) -> Task { + match message { + Message::PlayPressed => { + if self.transport == TransportState::Playing { + self.transport = TransportState::Stopped; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetTransportState(TransportState::Stopped)); + } + } else { + self.transport = TransportState::Playing; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetTransportState(TransportState::Playing)); + } + } + } + Message::StopPressed => { + if self.record_armed { + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::StopRecording); + } + self.record_armed = false; + } + self.transport = TransportState::Stopped; + self.current_position = crate::timing::MusicalTime::new(1, 1, 0); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetTransportState(TransportState::Stopped)); + } + } + Message::RecordPressed => { + if self.record_armed { + self.record_armed = false; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::StopRecording); + } + return Task::none(); + } + + let has_armed_tracks = self.tracks.iter().any(|t| t.record_armed); + if !has_armed_tracks { + return Task::none(); + } + + self.record_armed = true; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::StartRecording { + project_path: self.project_path.clone(), + sample_rate: self.project_config.sample_rate, + bit_depth: 24, + fft_size: self.analysis_fft_size as u32, + }); + if self.transport != TransportState::Playing { + self.transport = TransportState::Playing; + engine.send(EngineCommand::SetTransportState(TransportState::Playing)); + } + } + } + Message::RewindPressed => { + self.current_position = crate::timing::MusicalTime::new(1, 1, 0); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::Seek { sample_pos: 0 }); + } + } + Message::CycleToggled => { + self.cycle_enabled = !self.cycle_enabled; + self.send_cycle_state(); + } + Message::MetronomeToggled => { + self.metronome_enabled = !self.metronome_enabled; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetMetronomeEnabled(self.metronome_enabled)); + } + } + Message::CountInToggled => { + self.count_in_enabled = !self.count_in_enabled; + let bars = if self.count_in_enabled { 1 } else { 0 }; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetCountIn { bars }); + } + } + Message::PunchToggled => { + self.punch_enabled = !self.punch_enabled; + self.send_punch_state(); + } + Message::MasterVolumeChanged(vol) => { + self.master_volume = vol; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetMasterVolume(vol)); + } + } + Message::MasterPanChanged(pan) => { + self.master_pan = pan; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetMasterPan(pan)); + } + } + _ => {} + } + Task::none() + } + + pub(crate) fn send_cycle_state(&self) { + let beats_per_bar = self.time_signature_numerator as f64; + let samples_per_beat = (60.0 / self.tempo as f64) * self.project_config.sample_rate as f64; + let start_sample = ((self.cycle_start_bar - 1) as f64 * beats_per_bar * samples_per_beat) as u64; + let end_sample = ((self.cycle_end_bar - 1) as f64 * beats_per_bar * samples_per_beat) as u64; + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::SetCycleState { + enabled: self.cycle_enabled, + start_sample, + end_sample, + }); + } + } + + pub(crate) fn send_punch_state(&self) { + let Some(ref engine) = self.engine else { return }; + let beats_per_bar = self.time_signature_numerator as f64; + let samples_per_beat = (60.0 / self.tempo as f64) * self.project_config.sample_rate as f64; + let start = ((self.cycle_start_bar - 1) as f64 * beats_per_bar * samples_per_beat) as u64; + let end = ((self.cycle_end_bar - 1) as f64 * beats_per_bar * samples_per_beat) as u64; + engine.send(EngineCommand::SetPunch { + enabled: self.punch_enabled, + start_sample: start, + end_sample: end, + }); + } +} diff --git a/au-o2-gui/src/editor/undo.rs b/au-o2-gui/src/editor/undo.rs new file mode 100644 index 0000000..1684829 --- /dev/null +++ b/au-o2-gui/src/editor/undo.rs @@ -0,0 +1,315 @@ +use super::{decode_region_audio, Editor}; +use crate::engine::EngineCommand; +use crate::history::EditCommand; +use crate::waveform::WaveformPeaks; + +impl Editor { + pub(crate) fn perform_undo(&mut self) { + let cmd = match self.history.pop_undo() { + Some(c) => c, + None => return, + }; + match cmd { + EditCommand::MoveRegion { track_index, region_id, old_start, new_start, old_start_sample, new_start_sample } => { + debug_log!("undo MoveRegion: region={} track={}", region_id, track_index); + if let Some(track) = self.tracks.get_mut(track_index) { + if let Some(region) = track.regions.iter_mut().find(|r| r.id == region_id) { + region.start_time = old_start; + region.start_sample = old_start_sample; + } + } + self.history.push_redo(EditCommand::MoveRegion { + track_index, region_id, + old_start: new_start, + new_start: old_start, + old_start_sample: new_start_sample, + new_start_sample: old_start_sample, + }); + } + EditCommand::MoveRegionAcrossTracks { region_id, old_track, new_track, old_start, new_start, old_start_sample, new_start_sample } => { + debug_log!("undo cross-track move: region={} track {}->{}", + region_id, new_track, old_track); + if new_track < self.tracks.len() { + if let Some(pos) = self.tracks[new_track].regions.iter().position(|r| r.id == region_id) { + let mut region = self.tracks[new_track].regions.remove(pos); + region.start_time = old_start; + region.start_sample = old_start_sample; + + if old_track < self.tracks.len() { + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id }); + if let Some(ref audio_file) = region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, self.project_config.sample_rate) { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: self.tracks[old_track].bus_name.clone(), + region_id, + start_sample: old_start_sample, + audio_l, + audio_r, + fade_in_samples: region.fade_in_samples, + fade_out_samples: region.fade_out_samples, + }); + } + } + } + self.tracks[old_track].regions.push(region); + } + } + } + self.history.push_redo(EditCommand::MoveRegionAcrossTracks { + region_id, + old_track: new_track, + new_track: old_track, + old_start: new_start, + new_start: old_start, + old_start_sample: new_start_sample, + new_start_sample: old_start_sample, + }); + } + EditCommand::DeleteRegion { track_index, region } => { + debug_log!("undo DeleteRegion: region={} track={}", region.id, track_index); + let region_clone = region.clone(); + if track_index < self.tracks.len() { + if let Some(ref audio_file) = region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, self.project_config.sample_rate) { + let start = (region.start_sample as usize).min(audio_l.len()); + let end = (start + region.length_samples as usize).min(audio_l.len()); + let sl = &audio_l[start..end]; + let sr = &audio_r[start.min(audio_r.len())..end.min(audio_r.len())]; + self.waveform_cache.insert( + region.id, + WaveformPeaks::from_stereo(sl, sr), + ); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: self.tracks[track_index].bus_name.clone(), + region_id: region.id, + start_sample: region.start_sample, + audio_l: sl.to_vec(), + audio_r: sr.to_vec(), + fade_in_samples: region.fade_in_samples, + fade_out_samples: region.fade_out_samples, + }); + } + } + } + self.tracks[track_index].regions.push(region); + } + self.history.push_redo(EditCommand::DeleteRegion { + track_index, + region: region_clone, + }); + } + EditCommand::DeleteTrack { index, track } => { + debug_log!("undo DeleteTrack: index={}", index); + let track_clone = track.clone(); + if index <= self.tracks.len() { + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::CreateBus { + name: track.bus_name.clone(), + is_midi: track.track_type == crate::track::TrackType::Midi, + }); + } + self.tracks.insert(index, track); + } + self.history.push_redo(EditCommand::DeleteTrack { + index, + track: track_clone, + }); + } + EditCommand::CreateTrack { index } => { + debug_log!("undo CreateTrack: index={}", index); + if index < self.tracks.len() { + let removed = self.tracks.remove(index); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::RemoveBus { + name: removed.bus_name.clone(), + }); + } + self.history.push_redo(EditCommand::CreateTrack { index }); + } + } + EditCommand::DuplicateTrack { source_index, new_index } => { + debug_log!("undo DuplicateTrack: source={} new={}", source_index, new_index); + if new_index < self.tracks.len() { + let removed = self.tracks.remove(new_index); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::RemoveBus { + name: removed.bus_name.clone(), + }); + } + } + self.history.push_redo(EditCommand::DuplicateTrack { source_index, new_index }); + } + EditCommand::SplitRegion { track_index, original_id, original_region, left_id, right_id, split_sample } => { + debug_log!("undo SplitRegion: original={} track={}", original_id, track_index); + if let Some(track) = self.tracks.get_mut(track_index) { + track.regions.retain(|r| r.id != left_id && r.id != right_id); + self.waveform_cache.remove(&left_id); + self.waveform_cache.remove(&right_id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: left_id }); + engine.send(EngineCommand::UnloadRegionAudio { region_id: right_id }); + } + + let orig_clone = original_region.clone(); + if let Some(ref audio_file) = original_region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Ok(decoder) = crate::codec::XtcDecoder::open(&abs_path) { + if let Ok((audio_l, audio_r)) = decoder.decode_real(&abs_path) { + let start = original_region.start_sample as usize; + let end = (start + original_region.length_samples as usize).min(audio_l.len()); + let sl = &audio_l[start.min(audio_l.len())..end]; + let sr = &audio_r[start.min(audio_r.len())..end.min(audio_r.len())]; + self.waveform_cache.insert( + original_id, + WaveformPeaks::from_stereo(sl, sr), + ); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: track.bus_name.clone(), + region_id: original_id, + start_sample: original_region.start_sample, + audio_l: audio_l[start.min(audio_l.len())..end].to_vec(), + audio_r: audio_r[start.min(audio_r.len())..end.min(audio_r.len())].to_vec(), + fade_in_samples: original_region.fade_in_samples, + fade_out_samples: original_region.fade_out_samples, + }); + } + } + } + } + track.regions.push(original_region); + self.history.push_redo(EditCommand::SplitRegion { + track_index, + original_id, + original_region: orig_clone, + left_id, + right_id, + split_sample, + }); + } + } + EditCommand::PasteRegions { entries } => { + debug_log!("undo PasteRegions: {} regions", entries.len()); + let entries_clone = entries.clone(); + for (track_index, region) in &entries { + if *track_index < self.tracks.len() { + self.tracks[*track_index].regions.retain(|r| r.id != region.id); + self.waveform_cache.remove(®ion.id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: region.id }); + } + } + } + self.history.push_redo(EditCommand::PasteRegions { entries: entries_clone }); + } + EditCommand::CutRegions { entries } => { + debug_log!("undo CutRegions: {} regions", entries.len()); + let entries_clone = entries.clone(); + for (track_index, region) in &entries { + if *track_index < self.tracks.len() { + if let Some(ref audio_file) = region.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Ok(decoder) = crate::codec::XtcDecoder::open(&abs_path) { + if let Ok((audio_l, audio_r)) = decoder.decode_real(&abs_path) { + let start = (region.start_sample as usize).min(audio_l.len()); + let end = (start + region.length_samples as usize).min(audio_l.len()); + let sl = &audio_l[start..end]; + let sr = &audio_r[start.min(audio_r.len())..end.min(audio_r.len())]; + self.waveform_cache.insert( + region.id, + WaveformPeaks::from_stereo(sl, sr), + ); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: self.tracks[*track_index].bus_name.clone(), + region_id: region.id, + start_sample: region.start_sample, + audio_l: sl.to_vec(), + audio_r: sr.to_vec(), + fade_in_samples: region.fade_in_samples, + fade_out_samples: region.fade_out_samples, + }); + } + } + } + } + self.tracks[*track_index].regions.push(region.clone()); + } + } + self.history.push_redo(EditCommand::CutRegions { entries: entries_clone }); + } + EditCommand::AudioQuantize { track_index, original_region, result_regions } => { + if track_index < self.tracks.len() { + for r in &result_regions { + self.tracks[track_index].regions.retain(|rr| rr.id != r.id); + self.waveform_cache.remove(&r.id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: r.id }); + } + } + let orig = original_region.clone(); + if let Some(ref audio_file) = orig.audio_file { + let abs_path = self.project_path.join(audio_file); + if let Some((audio_l, audio_r)) = decode_region_audio(&abs_path, self.project_config.sample_rate) { + let s = (orig.start_sample as usize).min(audio_l.len()); + let e = (s + orig.length_samples as usize).min(audio_l.len()); + let sl = &audio_l[s..e]; + let sr = &audio_r[s.min(audio_r.len())..e.min(audio_r.len())]; + self.waveform_cache.insert(orig.id, WaveformPeaks::from_stereo(sl, sr)); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::LoadRegionAudio { + bus_name: self.tracks[track_index].bus_name.clone(), + region_id: orig.id, + start_sample: orig.start_sample, + audio_l: sl.to_vec(), + audio_r: sr.to_vec(), + fade_in_samples: orig.fade_in_samples, + fade_out_samples: orig.fade_out_samples, + }); + } + } + } + self.tracks[track_index].regions.push(orig); + } + self.history.push_redo(EditCommand::AudioQuantize { + track_index, + original_region, + result_regions, + }); + } + EditCommand::SetTempo { old_tempo, new_tempo, old_tempo_map, new_tempo_map } => { + self.tempo = old_tempo; + self.project_config.tempo = old_tempo; + self.tempo_map = old_tempo_map.clone(); + self.sync_tempo_to_engine(); + self.history.push_redo(EditCommand::SetTempo { + old_tempo: new_tempo, + new_tempo: old_tempo, + old_tempo_map: new_tempo_map, + new_tempo_map: old_tempo_map, + }); + } + EditCommand::SplitStems { track_indices } => { + for &idx in track_indices.iter().rev() { + if idx < self.tracks.len() { + let removed = self.tracks.remove(idx); + for r in &removed.regions { + self.waveform_cache.remove(&r.id); + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::UnloadRegionAudio { region_id: r.id }); + } + } + if let Some(ref engine) = self.engine { + engine.send(EngineCommand::RemoveBus { name: removed.bus_name.clone() }); + } + } + } + self.history.push_redo(EditCommand::SplitStems { track_indices }); + } + } + } +} diff --git a/au-o2-gui/src/editor/view.rs b/au-o2-gui/src/editor/view.rs new file mode 100644 index 0000000..cb483c1 --- /dev/null +++ b/au-o2-gui/src/editor/view.rs @@ -0,0 +1,371 @@ +use iced::widget::{ + button, column, container, mouse_area, row, scrollable, stack, text, Column, Space, +}; +use iced::{alignment, Alignment, Background, Color, Element, Length, Theme}; + +use super::{Editor, Message, ModalState, StatusLevel}; +use crate::gui::editor::{ + clip_launcher as clip_launcher_gui, control_bar, editor_pane, inspector, mixer, + new_track_wizard, score, step_seq, timeline, toolbar, track_header, +}; +use crate::gui::theme as ui_theme; +use crate::track::TRACK_HEIGHT; +use super::BottomPanelMode; + +impl Editor { + pub fn view(&self) -> Element<'_, Message> { + let selected_track_ref = self.selected_track.and_then(|i| self.tracks.get(i)); + + let control_bar = control_bar::view( + &self.transport, + &self.current_position, + self.tempo, + self.time_signature_numerator, + self.time_signature_denominator, + self.cycle_enabled, + self.metronome_enabled, + self.count_in_enabled, + self.punch_enabled, + self.record_armed, + self.show_inspector, + self.show_bottom_panel, + &self.bottom_panel_mode, + self.show_tempo_lane, + &self.icons, + self.lcd_editing, + &self.lcd_bar_input, + &self.lcd_beat_input, + &self.lcd_tick_input, + ); + + let toolbar = toolbar::view(&self.active_tool, &self.icons); + + let effective_track_height = TRACK_HEIGHT * self.v_zoom; + let track_headers: Element<_> = container( + column![ + Space::new(0, 34), + scrollable( + self.tracks + .iter() + .enumerate() + .fold(Column::new().spacing(0), |col, (i, track)| { + col.push( + track_header::view(track, &self.icons, effective_track_height) + .map(move |msg| Message::TrackHeader(i, msg)), + ) + }), + ) + .id(self.track_list_scrollable_id.clone()) + .on_scroll(Message::TrackListScrolled), + ], + ) + .width(self.header_width) + .into(); + + let timeline_el: Element<_> = scrollable( + timeline::view( + &self.project_config, + &self.tracks, + self.current_position, + self.active_tool, + self.h_zoom, + self.v_zoom, + self.record_armed, + &self.waveform_cache, + self.cycle_enabled, + self.cycle_start_bar, + self.cycle_end_bar, + &self.markers, + &self.tempo_map, + self.show_tempo_lane, + ) + .map(Message::Timeline), + ) + .id(self.timeline_scrollable_id.clone()) + .on_scroll(Message::TimelineScrolled) + .width(Length::Fill) + .into(); + + let tracklist_resize_handle: Element<_> = mouse_area( + container( + container(Space::new(1, Length::Fill)) + .width(1) + .height(Length::Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(ui_theme::BORDER_SUBTLE)), + ..container::Style::default() + }) + ) + .width(ui_theme::RESIZE_HANDLE_WIDTH) + .height(Length::Fill) + .align_x(alignment::Horizontal::Center) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color::TRANSPARENT)), + ..container::Style::default() + }) + ) + .on_press(Message::TrackListResizePressed) + .on_release(Message::TrackListResizeReleased) + .into(); + + let arrangement = row![track_headers, tracklist_resize_handle, timeline_el].align_y(Alignment::Start); + + let middle: Element<_> = if self.show_inspector { + let inspector_el = container( + inspector::view( + selected_track_ref, + &self.project_config, + &self.routing.module_names, + self.selected_track, + self.analysis_fft_size, + &self.session_player_config, + self.session_player_bars, + self.spatial_mode, + self.mono_lane, + &inspector::InspectorSections { + signal: self.inspector_signal_open, + sends: self.inspector_sends_open, + automation: self.inspector_automation_open, + spatial: self.inspector_spatial_open, + analysis: self.inspector_analysis_open, + }, + &self.module_params, + &self.module_gui.modules_with_gui, + &self.groups, + ), + ).width(self.inspector_width); + + let inspector_resize_handle: Element<_> = mouse_area( + container( + container(Space::new(1, Length::Fill)) + .width(1) + .height(Length::Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(ui_theme::BORDER_SUBTLE)), + ..container::Style::default() + }) + ) + .width(ui_theme::RESIZE_HANDLE_WIDTH) + .height(Length::Fill) + .align_x(alignment::Horizontal::Center) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color::TRANSPARENT)), + ..container::Style::default() + }) + ) + .on_press(Message::InspectorResizePressed) + .on_release(Message::InspectorResizeReleased) + .into(); + + row![inspector_el, inspector_resize_handle, arrangement] + .height(Length::Fill) + .into() + } else { + container(arrangement).height(Length::Fill).into() + }; + + let bottom: Element<_> = if self.show_bottom_panel { + let panel_content: Element<_> = match self.bottom_panel_mode { + BottomPanelMode::Editor => { + let wf_peaks = selected_track_ref + .and_then(|t| t.regions.first()) + .and_then(|r| self.waveform_cache.get(&r.id)); + editor_pane::view( + selected_track_ref, + self.selected_track, + self.h_zoom, + self.tempo, + self.time_signature_numerator, + wf_peaks, + ) + } + BottomPanelMode::Mixer => mixer::view( + &self.tracks, + &self.groups, + &self.icons, + &self.routing.module_names, + &self.routing.disabled_modules, + &self.module_gui.modules_with_gui, + self.routing.module_picker_track, + self.routing.send_picker_track, + self.master_volume, + self.master_pan, + &self.meter_levels, + self.master_meter, + &self.discovered_plugins, + self.show_network_view, + ), + BottomPanelMode::StepSequencer => step_seq::view( + selected_track_ref, + self.selected_track, + self.h_zoom, + self.tempo, + self.time_signature_numerator, + self.pattern_length, + ), + BottomPanelMode::ScoreEditor => score::view( + selected_track_ref, + self.selected_track, + self.h_zoom, + self.tempo, + self.time_signature_numerator, + self.score_note_duration, + ), + BottomPanelMode::ClipLauncher => clip_launcher_gui::view( + &self.tracks, + &self.active_clips, + ), + }; + container(panel_content) + .height(self.bottom_panel_height) + .width(Length::Fill) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(ui_theme::BG_MID)), + ..container::Style::default() + }) + .into() + } else { + Space::new(0, 0).into() + }; + + let add_track_row: Element<_> = container( + row![ + button(text("+ Track").size(ui_theme::TS_MD)).on_press(Message::ShowNewTrackWizard), + Space::new(Length::Fill, 0), + button(text("Export").size(ui_theme::TS_MD)).on_press(Message::ShowExportDialog), + ] + .spacing(ui_theme::SP_LG) + .padding(ui_theme::SP_XS), + ) + .width(Length::Fill) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(ui_theme::BG_BASE)), + ..container::Style::default() + }) + .into(); + + let (status_text, status_bg) = if let Some((ref msg, level, _)) = self.status_message { + let bg = match level { + StatusLevel::Error => Some(ui_theme::PINK), + StatusLevel::Warning => Some(ui_theme::ICON_TINT), + StatusLevel::Info => None, + }; + let text_color = match level { + StatusLevel::Warning => ui_theme::BG_DEEPEST, + _ => ui_theme::TEXT_BRIGHT, + }; + (Some((msg.clone(), text_color)), bg) + } else if let Some((ref msg, _)) = self.last_status { + (Some((msg.clone(), ui_theme::TEXT_MUTED)), None) + } else { + (None, None) + }; + + let status_bar: Element<_> = if let Some((msg, text_color)) = status_text { + container( + text(msg) + .size(ui_theme::TS_SM) + .color(text_color), + ) + .width(Length::Fill) + .height(20) + .padding([2, 6]) + .style(move |_theme: &Theme| container::Style { + background: status_bg.map(Background::Color), + ..container::Style::default() + }) + .into() + } else { + container(Space::new(0, 0)) + .width(Length::Fill) + .height(20) + .into() + }; + + let resize_handle: Element<_> = if self.show_bottom_panel { + mouse_area( + container( + container(Space::new(Length::Fill, 1)) + .width(Length::Fill) + .height(1) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(ui_theme::BORDER_LIGHT)), + ..container::Style::default() + }) + ) + .width(Length::Fill) + .height(ui_theme::RESIZE_HANDLE_HEIGHT) + .align_y(alignment::Vertical::Center) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(ui_theme::BG_DARKER)), + ..container::Style::default() + }) + ) + .on_press(Message::ResizeHandlePressed) + .on_release(Message::ResizeHandleReleased) + .into() + } else { + Space::new(0, 0).into() + }; + + let main_view: Element<_> = container( + column![control_bar, toolbar, add_track_row, middle, resize_handle, bottom, status_bar] + .spacing(0), + ) + .padding(iced::Padding { + top: 0.0, + right: 4.0, + bottom: 0.0, + left: 4.0, + }) + .width(Length::Fill) + .height(Length::Fill) + .into(); + + let any_dragging = self.resize_dragging || self.tracklist_resize_dragging || self.inspector_resize_dragging; + let main_view: Element<_> = if any_dragging { + let mut ma = mouse_area(main_view); + if self.resize_dragging { + ma = ma.on_move(Message::ResizeHandleMoved) + .on_release(Message::ResizeHandleReleased); + } else if self.tracklist_resize_dragging { + ma = ma.on_move(Message::TrackListResizeMoved) + .on_release(Message::TrackListResizeReleased); + } else if self.inspector_resize_dragging { + ma = ma.on_move(Message::InspectorResizeMoved) + .on_release(Message::InspectorResizeReleased); + } + ma.into() + } else { + main_view + }; + + if let Some(modal_state) = &self.modal_state { + let modal_content: Element<_> = match modal_state { + ModalState::NewTrackWizard(state) => { + new_track_wizard::view(state).map(Message::NewTrackWizard) + } + ModalState::ExportDialog(config) => { + self.export_dialog_view(config) + } + }; + + let background = container(main_view).style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.4))), + ..container::Style::default() + }); + + let modal = container(modal_content) + .width(Length::Fill) + .height(Length::Fill) + .align_x(alignment::Horizontal::Center) + .align_y(alignment::Vertical::Center); + + mouse_area(stack![background, modal]) + .on_press(Message::CloseModal) + .into() + } else { + main_view + } + } +} diff --git a/au-o2-gui/src/engine/ara.rs b/au-o2-gui/src/engine/ara.rs new file mode 100644 index 0000000..74344b5 --- /dev/null +++ b/au-o2-gui/src/engine/ara.rs @@ -0,0 +1,93 @@ +/// ARA 2-style host content access. +/// +/// Provides non-real-time random access to audio content, musical context, +/// and region metadata. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +#[derive(Clone)] +pub struct AudioSource { + pub name: String, +} + +impl AudioSource { + pub fn new(name: String) -> Self { + Self { name } + } +} + +#[derive(Clone, Debug)] +pub struct MusicalContext { + pub tempo_points: Vec, + pub time_signature_num: u8, + pub time_signature_den: u8, +} + +#[derive(Clone, Debug)] +pub struct TempoEntry { + pub sample_pos: u64, + pub tempo_bpm: f64, +} + +#[derive(Clone, Debug)] +pub struct ContentRegion { + pub source_name: String, + pub duration_samples: u64, + pub playback_rate: f32, +} + +pub struct AraDocumentController { + sources: RwLock>, + regions: RwLock>, + musical_context: RwLock, +} + +impl AraDocumentController { + pub fn new(tempo: f64, time_sig_num: u8, time_sig_den: u8) -> Self { + Self { + sources: RwLock::new(HashMap::new()), + regions: RwLock::new(Vec::new()), + musical_context: RwLock::new(MusicalContext { + tempo_points: vec![TempoEntry { sample_pos: 0, tempo_bpm: tempo }], + time_signature_num: time_sig_num, + time_signature_den: time_sig_den, + }), + } + } + + pub fn with_relay(tempo: f64, time_sig_num: u8, time_sig_den: u8) -> Arc { + Arc::new(Self::new(tempo, time_sig_num, time_sig_den)) + } + + pub fn register_source(&self, source: AudioSource) { + let name = source.name.clone(); + self.sources.write().unwrap().insert(name, source); + } + + pub fn remove_source(&self, name: &str) { + self.sources.write().unwrap().remove(name); + } + + pub fn add_region(&self, region: ContentRegion) { + self.regions.write().unwrap().push(region); + } + + pub fn set_tempo_curve(&self, points: Vec) { + self.musical_context.write().unwrap().tempo_points = points; + } + + pub fn musical_context(&self) -> MusicalContext { + self.musical_context.read().unwrap().clone() + } + + pub fn prune_stale_regions(&self) { + let sources = self.sources.read().unwrap(); + let mut regions = self.regions.write().unwrap(); + regions.retain(|r| { + sources.contains_key(&r.source_name) + && r.duration_samples > 0 + && r.playback_rate > 0.0 + }); + } +} diff --git a/au-o2-gui/src/engine/atmos.rs b/au-o2-gui/src/engine/atmos.rs new file mode 100644 index 0000000..1165b1b --- /dev/null +++ b/au-o2-gui/src/engine/atmos.rs @@ -0,0 +1,318 @@ +/// Object-based spatial audio renderer. +/// +/// Provides binaural rendering via simplified HRTF (ITD + ILD + head shadow) +/// and 7.1.4 speaker bed rendering via VBAP. + +// 7.1.4 speaker layout (azimuth, elevation in degrees) +const SPEAKERS_714: [(f32, f32); 12] = [ + (-30.0, 0.0), // L + (30.0, 0.0), // R + (0.0, 0.0), // C + (0.0, -30.0), // LFE (below, but gain-only) + (-110.0, 0.0), // Ls + (110.0, 0.0), // Rs + (-150.0, 0.0), // Lrs + (150.0, 0.0), // Rrs + (-45.0, 45.0), // Ltf + (45.0, 45.0), // Rtf + (-135.0, 45.0), // Ltr + (135.0, 45.0), // Rtr +]; + +const HEAD_RADIUS_M: f32 = 0.0875; +const SPEED_OF_SOUND: f32 = 343.0; +const MAX_ITD_SAMPLES_48K: usize = 30; + +#[derive(Debug, Clone, Copy)] +pub struct ObjectPosition { + pub x: f32, // -1..1 left/right + pub y: f32, // -1..1 front/back + pub z: f32, // -1..1 bottom/top + pub size: f32, // 0..1 object spread +} + +impl ObjectPosition { + fn azimuth_rad(&self) -> f32 { + self.x.atan2(-self.y) + } + + fn elevation_rad(&self) -> f32 { + let horiz = (self.x * self.x + self.y * self.y).sqrt(); + self.z.atan2(horiz) + } +} + +/// Binaural renderer state for a single object +pub struct BinauralState { + delay_line_l: Vec, + delay_line_r: Vec, + write_pos: usize, + // One-pole low-pass state for head shadow + shadow_state_l: f32, + shadow_state_r: f32, + cached_az: f32, + cached_el: f32, + cached_x: f32, + cached_y: f32, + cached_z: f32, +} + +impl BinauralState { + pub fn new() -> Self { + Self { + delay_line_l: vec![0.0; MAX_ITD_SAMPLES_48K + 1], + delay_line_r: vec![0.0; MAX_ITD_SAMPLES_48K + 1], + write_pos: 0, + shadow_state_l: 0.0, + shadow_state_r: 0.0, + cached_az: 0.0, + cached_el: 0.0, + cached_x: 0.0, + cached_y: 0.0, + cached_z: 0.0, + } + } + + /// Render mono source to binaural stereo using simplified HRTF. + pub fn render( + &mut self, + mono: &[f32], + out_l: &mut [f32], + out_r: &mut [f32], + pos: &ObjectPosition, + sample_rate: u32, + ) { + let (az, el) = if pos.x != self.cached_x || pos.y != self.cached_y || pos.z != self.cached_z { + let a = pos.azimuth_rad(); + let e = pos.elevation_rad(); + self.cached_az = a; + self.cached_el = e; + self.cached_x = pos.x; + self.cached_y = pos.y; + self.cached_z = pos.z; + (a, e) + } else { + (self.cached_az, self.cached_el) + }; + + // ITD: Woodworth formula + let itd_sec = HEAD_RADIUS_M / SPEED_OF_SOUND * (az.sin() + az); + let itd_samples = (itd_sec.abs() * sample_rate as f32) as usize; + let itd_samples = itd_samples.min(MAX_ITD_SAMPLES_48K); + let source_left = az < 0.0; // negative azimuth = left side + + // ILD: frequency-independent approximation, ~6dB max + let ild_db = 6.0 * az.sin(); + let gain_l; + let gain_r; + if source_left { + gain_l = 1.0; + gain_r = 10.0_f32.powf(-ild_db.abs() / 20.0); + } else { + gain_l = 10.0_f32.powf(-ild_db.abs() / 20.0); + gain_r = 1.0; + } + + // Elevation: attenuate slightly when source is above/below + let el_atten = 1.0 - 0.15 * el.abs(); + + // Head shadow: one-pole LPF coefficient for contralateral ear + // More shadow (lower cutoff) for sources further to the side + let shadow_amount = az.sin().abs() * 0.4; + let shadow_coeff_l = if source_left { 0.0 } else { shadow_amount }; + let shadow_coeff_r = if source_left { shadow_amount } else { 0.0 }; + + let dl_len = self.delay_line_l.len(); + + for i in 0..mono.len() { + let s = mono[i] * el_atten; + + self.delay_line_l[self.write_pos] = s; + self.delay_line_r[self.write_pos] = s; + + // Read with ITD offset + let read_l = if source_left { + self.write_pos + } else { + (self.write_pos + dl_len - itd_samples) % dl_len + }; + let read_r = if source_left { + (self.write_pos + dl_len - itd_samples) % dl_len + } else { + self.write_pos + }; + + let raw_l = self.delay_line_l[read_l] * gain_l; + let raw_r = self.delay_line_r[read_r] * gain_r; + + // Head shadow filter + self.shadow_state_l += shadow_coeff_l * (raw_l - self.shadow_state_l); + self.shadow_state_r += shadow_coeff_r * (raw_r - self.shadow_state_r); + + let filtered_l = raw_l - shadow_coeff_l * self.shadow_state_l; + let filtered_r = raw_r - shadow_coeff_r * self.shadow_state_r; + + out_l[i] += filtered_l; + out_r[i] += filtered_r; + + self.write_pos = (self.write_pos + 1) % dl_len; + } + } +} + +/// Compute 7.1.4 VBAP gains for a given object position. +/// Returns gains for each of the 12 speakers. +pub fn vbap_714(pos: &ObjectPosition) -> [f32; 12] { + let az = pos.azimuth_rad().to_degrees(); + let el = pos.elevation_rad().to_degrees(); + + let mut gains = [0.0_f32; 12]; + let mut total = 0.0_f32; + + for (i, &(spk_az, spk_el)) in SPEAKERS_714.iter().enumerate() { + if i == 3 { continue; } // Skip LFE for directional panning + + let daz = angle_diff(az, spk_az); + let del = el - spk_el; + let dist = (daz * daz + del * del).sqrt().max(1.0); + let g = (1.0 / dist).max(0.0); + gains[i] = g; + total += g * g; + } + + // Normalize to constant power + if total > 0.0 { + let norm = total.sqrt().recip(); + for g in &mut gains { + *g *= norm; + } + } + + // LFE: low-frequency content gets a fixed send + gains[3] = 0.25; + + // Object size: spread energy across more speakers + if pos.size > 0.0 { + let base = gains; + let spread = pos.size.clamp(0.0, 1.0); + let uniform = 1.0 / 12.0_f32.sqrt(); + for (i, g) in gains.iter_mut().enumerate() { + *g = base[i] * (1.0 - spread) + uniform * spread; + } + } + + gains +} + +/// Render mono source to 7.1.4 speaker bed (12 channels interleaved). +pub fn render_714( + mono: &[f32], + output: &mut [f32], + pos: &ObjectPosition, +) { + let gains = vbap_714(pos); + let frames = mono.len(); + debug_assert!(output.len() >= frames * 12); + + for i in 0..frames { + let s = mono[i]; + for ch in 0..12 { + output[i * 12 + ch] += s * gains[ch]; + } + } +} + +/// Downmix 7.1.4 (12ch) to stereo using ITU-R BS.775 derived coefficients. +pub fn downmix_714_to_stereo(input: &[f32], out_l: &mut [f32], out_r: &mut [f32]) { + let frames = out_l.len(); + let inv_sqrt2 = std::f32::consts::FRAC_1_SQRT_2; + + for i in 0..frames { + let base = i * 12; + if base + 11 >= input.len() { break; } + + let l = input[base]; + let r = input[base + 1]; + let c = input[base + 2]; + let lfe = input[base + 3]; + let ls = input[base + 4]; + let rs = input[base + 5]; + let lrs = input[base + 6]; + let rrs = input[base + 7]; + let ltf = input[base + 8]; + let rtf = input[base + 9]; + let ltr = input[base + 10]; + let rtr = input[base + 11]; + + out_l[i] += l + inv_sqrt2 * c + inv_sqrt2 * lfe + + inv_sqrt2 * ls + 0.5 * lrs + + inv_sqrt2 * ltf + 0.5 * ltr; + + out_r[i] += r + inv_sqrt2 * c + inv_sqrt2 * lfe + + inv_sqrt2 * rs + 0.5 * rrs + + inv_sqrt2 * rtf + 0.5 * rtr; + } +} + +fn angle_diff(a: f32, b: f32) -> f32 { + let mut d = a - b; + while d > 180.0 { d -= 360.0; } + while d < -180.0 { d += 360.0; } + d +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpatialRenderMode { + Mono, + Stereo, + Binaural, + Surround714, +} + +impl SpatialRenderMode { + pub const ALL: [SpatialRenderMode; 4] = [ + SpatialRenderMode::Mono, + SpatialRenderMode::Stereo, + SpatialRenderMode::Binaural, + SpatialRenderMode::Surround714, + ]; +} + +impl Default for SpatialRenderMode { + fn default() -> Self { + Self::Stereo + } +} + +impl std::fmt::Display for SpatialRenderMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Mono => write!(f, "Mono"), + Self::Stereo => write!(f, "Stereo"), + Self::Binaural => write!(f, "Binaural"), + Self::Surround714 => write!(f, "7.1.4"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MonoLane { + #[default] + Mix, + Left, + Right, +} + +impl MonoLane { + pub const ALL: [MonoLane; 3] = [MonoLane::Mix, MonoLane::Left, MonoLane::Right]; +} + +impl std::fmt::Display for MonoLane { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Mix => write!(f, "L+R"), + Self::Left => write!(f, "L"), + Self::Right => write!(f, "R"), + } + } +} diff --git a/au-o2-gui/src/engine/bus.rs b/au-o2-gui/src/engine/bus.rs new file mode 100644 index 0000000..cd5fd90 --- /dev/null +++ b/au-o2-gui/src/engine/bus.rs @@ -0,0 +1,192 @@ +use std::collections::HashMap; + +use super::lane::{FftPlanCache, Lane}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BusId(pub u32); + +#[allow(dead_code)] // fields stored for future introspection +pub struct Bus { + id: BusId, + name: String, + lanes: Vec, + sample_rate: u32, +} + +impl Bus { + pub fn new( + name: &str, + id: BusId, + channel_count: usize, + buffer_size: usize, + sample_rate: u32, + fft_size: usize, + plan_cache: &mut FftPlanCache, + ) -> Self { + let lanes = (0..channel_count) + .map(|_| Lane::new(buffer_size, fft_size, plan_cache)) + .collect(); + Self { + id, + name: name.to_string(), + lanes, + sample_rate, + } + } + + pub fn lanes(&self) -> &[Lane] { + &self.lanes + } + + pub fn lanes_mut(&mut self) -> &mut [Lane] { + &mut self.lanes + } + + pub fn channels(&self) -> usize { + self.lanes.len() + } + + pub fn clear(&mut self) { + for lane in &mut self.lanes { + lane.clear(); + } + } + + pub fn write_interleaved(&mut self, data: &[f32]) { + let ch = self.lanes.len(); + if ch == 0 { return; } + let frame_count = data.len() / ch; + let mut channel_bufs: Vec> = (0..ch) + .map(|_| Vec::with_capacity(frame_count)) + .collect(); + for frame in 0..frame_count { + for c in 0..ch { + channel_bufs[c].push(data[frame * ch + c]); + } + } + for (i, lane) in self.lanes.iter_mut().enumerate() { + lane.write_real(&channel_bufs[i]); + } + } + + pub fn read_interleaved(&self) -> Vec { + let ch = self.lanes.len(); + if ch == 0 { return Vec::new(); } + let frame_count = self.lanes[0].real().len(); + let mut out = Vec::with_capacity(frame_count * ch); + for frame in 0..frame_count { + for lane in &self.lanes { + let real = lane.real(); + out.push(if frame < real.len() { real[frame] } else { 0.0 }); + } + } + out + } + + pub fn accumulate_interleaved(&mut self, data: &[f32], gain: f32) { + let ch = self.lanes.len(); + if ch == 0 { return; } + let frame_count = data.len() / ch; + for frame in 0..frame_count { + for (c, lane) in self.lanes.iter_mut().enumerate() { + let real = lane.real_mut(); + if frame < real.len() { + real[frame] += data[frame * ch + c] * gain; + } + } + } + } + + pub fn buffer_size(&self) -> usize { + self.lanes.first().map(|l| l.real().len()).unwrap_or(0) + } +} + +pub struct BusRegistry { + buses: HashMap, + name_to_id: HashMap, + next_id: u32, + buffer_size: usize, + channels: usize, + sample_rate: u32, + fft_size: usize, + plan_cache: FftPlanCache, +} + +impl BusRegistry { + pub fn new(buffer_size: usize, channels: usize, sample_rate: u32, fft_size: usize) -> Self { + Self { + buses: HashMap::new(), + name_to_id: HashMap::new(), + next_id: 0, + buffer_size, + channels, + sample_rate, + fft_size, + plan_cache: FftPlanCache::new(), + } + } + + pub fn channels(&self) -> usize { + self.channels + } + + pub fn buffer_size(&self) -> usize { + self.buffer_size + } + + pub fn create_bus(&mut self, name: &str) -> BusId { + if let Some(&id) = self.name_to_id.get(name) { + return id; + } + let id = BusId(self.next_id); + self.next_id += 1; + let bus = Bus::new( + name, id, self.channels, self.buffer_size, + self.sample_rate, self.fft_size, &mut self.plan_cache, + ); + self.name_to_id.insert(name.to_string(), id); + self.buses.insert(id, bus); + id + } + + pub fn remove_bus(&mut self, name: &str) -> bool { + if let Some(id) = self.name_to_id.remove(name) { + self.buses.remove(&id); + true + } else { + false + } + } + + pub fn get_by_name(&self, name: &str) -> Option<&Bus> { + self.name_to_id.get(name).and_then(|id| self.buses.get(id)) + } + + pub fn get_mut_by_name(&mut self, name: &str) -> Option<&mut Bus> { + if let Some(&id) = self.name_to_id.get(name) { + self.buses.get_mut(&id) + } else { + None + } + } + + pub fn clear_all(&mut self) { + for bus in self.buses.values_mut() { + bus.clear(); + } + } + + pub fn bus_names(&self) -> Vec { + self.name_to_id.keys().cloned().collect() + } + + pub fn set_fft_size(&mut self, size: usize) { + self.fft_size = size; + for bus in self.buses.values_mut() { + for lane in bus.lanes_mut() { + lane.set_fft_size(size, &mut self.plan_cache); + } + } + } +} diff --git a/au-o2-gui/src/engine/contract.rs b/au-o2-gui/src/engine/contract.rs new file mode 100644 index 0000000..ccb766f --- /dev/null +++ b/au-o2-gui/src/engine/contract.rs @@ -0,0 +1,111 @@ +use std::collections::HashMap; + +pub struct ModuleTiming { + pub last_ns: u64, + pub peak_ns: u64, + pub violation_count: u32, +} + +pub struct ContractEnforcer { + module_timing: HashMap, + cycle_budget_ns: u64, + system_latency_ns: u64, + recording: bool, + flagged: HashMap, +} + +impl ContractEnforcer { + pub fn new(buffer_size: usize, sample_rate: u32) -> Self { + let cycle_budget_ns = (buffer_size as u64 * 1_000_000_000) / sample_rate as u64; + Self { + module_timing: HashMap::new(), + cycle_budget_ns, + system_latency_ns: 500_000, // 500us initial estimate + recording: false, + flagged: HashMap::new(), + } + } + + pub fn available_ns(&self) -> u64 { + self.cycle_budget_ns.saturating_sub(self.system_latency_ns) + } + + pub fn set_recording(&mut self, recording: bool) { + self.recording = recording; + } + + pub fn record_timing(&mut self, module_id: u32, elapsed_ns: u64) { + let timing = self.module_timing.entry(module_id).or_insert(ModuleTiming { + last_ns: 0, + peak_ns: 0, + violation_count: 0, + }); + timing.last_ns = elapsed_ns; + if elapsed_ns > timing.peak_ns { + timing.peak_ns = elapsed_ns; + } + } + + pub fn check_cycle(&mut self, total_ns: u64) -> Option { + if total_ns <= self.available_ns() { + return None; + } + + let heaviest = self.module_timing.iter() + .max_by_key(|(_, t)| t.last_ns) + .map(|(&id, t)| (id, t.last_ns)); + + if let Some((module_id, ns)) = heaviest { + if let Some(timing) = self.module_timing.get_mut(&module_id) { + timing.violation_count += 1; + } + + let reason = format!("exceeded budget: {}us / {}us available", + ns / 1000, self.available_ns() / 1000); + self.flagged.insert(module_id, reason.clone()); + + Some(CycleViolation { + module_id, + module_ns: ns, + total_ns, + budget_ns: self.available_ns(), + recording: self.recording, + }) + } else { + None + } + } + + pub fn suggest_buffer_increase(&self, current_size: usize) -> usize { + let increased = (current_size as f32 * 1.25) as usize; + // Round to next standard size + for &standard in &[64, 128, 256, 512, 1024, 2048, 4096, 8192] { + if standard >= increased { + return standard; + } + } + increased + } + + pub fn drain_flagged(&mut self) -> Vec<(u32, String)> { + self.flagged.drain().collect() + } + + pub fn has_flagged(&self) -> bool { + !self.flagged.is_empty() + } + + pub fn get_timing(&self, module_id: u32) -> Option<&ModuleTiming> { + self.module_timing.get(&module_id) + } + +} + +#[allow(dead_code)] // fields populated for future violation reporting UI +pub struct CycleViolation { + pub module_id: u32, + pub module_ns: u64, + pub total_ns: u64, + pub budget_ns: u64, + pub recording: bool, +} diff --git a/au-o2-gui/src/engine/cycle/commands.rs b/au-o2-gui/src/engine/cycle/commands.rs new file mode 100644 index 0000000..f29c57b --- /dev/null +++ b/au-o2-gui/src/engine/cycle/commands.rs @@ -0,0 +1,410 @@ +use std::sync::atomic::Ordering; + +use oxforge::mdk::{GlobalConfig, MidiPlaybackRegion, PlaybackRegion}; + +use super::super::ara::{AudioSource, ContentRegion, TempoEntry}; +use super::super::{EngineCommand, EngineEvent, TransportState}; +use super::CycleProcessor; + +impl CycleProcessor { + pub(super) fn drain_commands(&mut self) { + while let Ok(cmd) = self.cmd_rx.try_recv() { + match cmd { + EngineCommand::Shutdown => { + self.running.store(false, Ordering::Relaxed); + } + EngineCommand::SetTransportState(state) => { + self.transport = state; + if state == TransportState::Stopped { + self.sample_pos = 0; + + if self.enforcer.has_flagged() { + for (module_id, reason) in self.enforcer.drain_flagged() { + self.module_host.disable_module(module_id); + let name = self.module_host.get(module_id) + .map(|m| m.name.clone()) + .unwrap_or_default(); + let _ = self.evt_tx.send(EngineEvent::ModuleDisabled { + module_id, + reason: reason.clone(), + }); + let timing = self.enforcer.get_timing(module_id); + let avg_ns = timing.map(|t| t.last_ns).unwrap_or(0); + let _ = self.evt_tx.send(EngineEvent::ContractViolation { + module_id, + module_name: name, + avg_ns, + budget_ns: self.enforcer.available_ns(), + }); + } + } + } + } + EngineCommand::CreateBus { name, is_midi } => { + self.bus_registry.create_bus(&name); + + if name != "hw_input" && name != "hw_output" { + self.auto_load_system_modules(&name, is_midi); + } + + let _ = self.evt_tx.send(EngineEvent::BusCreated); + } + EngineCommand::RemoveBus { name } => { + self.ara_controller.prune_stale_regions(); + self.spatial.remove_position(&name); + + if let Some(modules) = self.system_modules.remove(&name) { + for (_, module_id) in modules { + self.module_host.unload(module_id); + self.graph.remove_module(module_id); + } + } + if let Some(mixer_id) = self.output_mixer_id { + self.graph.remove_module_read(mixer_id, &name); + self.module_host.send_data( + mixer_id, "remove_bus", Box::new(name.clone()), + ); + } + self.bus_registry.remove_bus(&name); + self.needs_rebuild = true; + } + EngineCommand::SetParam { module_id, key, value } => { + self.param_engine.queue_change(module_id, key, value); + } + EngineCommand::ArmTrack { bus_name } => { + if let Some(modules) = self.system_modules.get(&bus_name) { + if let Some(&id) = modules.get("input_router") { + self.param_engine.queue_change(id, "armed".into(), 1.0); + } + } + } + EngineCommand::DisarmTrack { bus_name } => { + if let Some(modules) = self.system_modules.get(&bus_name) { + if let Some(&id) = modules.get("input_router") { + self.param_engine.queue_change(id, "armed".into(), 0.0); + } + } + } + EngineCommand::LoadModuleOnBus { bus_name, module_type, chain_position } => { + let config = GlobalConfig { + instance_id: oxforge::mdk::uuid::Uuid::new_v4(), + sample_rate: self.sample_rate as f32, + buffer_size: self.bus_registry.buffer_size() as u32, + }; + let actual_position = chain_position + 5; + if let Some(module_id) = crate::modules::registry::load_builtin( + &mut self.module_host, &module_type, &config, + ) { + if let Some(module) = self.module_host.get(module_id) { + if let Some(min_buf) = module.contract.min_buffer_samples { + let current = self.bus_registry.buffer_size(); + if min_buf > current { + let required_ms = min_buf as f32 / self.sample_rate as f32 * 1000.0; + let current_ms = current as f32 / self.sample_rate as f32 * 1000.0; + let _ = self.evt_tx.send(EngineEvent::BufferNegotiation { + module_id, + required_samples: min_buf, + required_ms, + current_samples: current, + current_ms, + }); + } + } + } + + self.graph.add_module_to_bus(module_id, &bus_name); + self.graph.set_chain_position(module_id, &bus_name, actual_position); + self.needs_rebuild = true; + let has_gui = self.module_host.has_gui(module_id); + let gui_descriptor = self.module_host.gui_descriptor(module_id); + let _ = self.evt_tx.send(EngineEvent::ModuleLoaded { + bus_name, + module_id, + module_type, + plugin_name: None, + has_gui, + gui_descriptor, + }); + } else { + let _ = self.evt_tx.send(EngineEvent::Error( + format!("unknown module type: {}", module_type), + )); + } + } + EngineCommand::UnloadModule { module_id } => { + self.module_host.unload(module_id); + self.graph.remove_module(module_id); + self.needs_rebuild = true; + } + EngineCommand::SetModuleDisabled { module_id, disabled } => { + self.module_host.set_disabled(module_id, disabled); + self.needs_rebuild = true; + } + EngineCommand::SetModuleChainPosition { module_id, bus_name, chain_position } => { + let actual_position = chain_position + 5; + self.graph.set_chain_position(module_id, &bus_name, actual_position); + self.needs_rebuild = true; + } + EngineCommand::SetHilbertFftSize { size } => { + for modules in self.system_modules.values() { + if let Some(&id) = modules.get("hilbert") { + self.module_host.send_data(id, "set_fft_size", Box::new(size)); + } + } + self.bus_registry.set_fft_size(size); + } + EngineCommand::StartRecording { project_path, sample_rate, bit_depth, fft_size } => { + self.recording_project_path = Some(project_path.clone()); + self.recording_config = Some((sample_rate, bit_depth, fft_size)); + + if self.punch_enabled && self.punch_end_sample > self.punch_start_sample { + self.punch_armed = true; + debug_log!("[recording] punch armed: in={} out={}", self.punch_start_sample, self.punch_end_sample); + } else if self.count_in_bars > 0 { + let beats = self.count_in_bars as f64 * self.time_signature_numerator as f64; + let samps = (beats * 60.0 / self.tempo as f64) * self.sample_rate as f64; + self.count_in_remaining = samps as u64; + if self.metronome_midi_id.is_none() { + self.load_metronome(); + } + debug_log!("[recording] count-in: {} bars = {} samples", self.count_in_bars, self.count_in_remaining); + } else { + self.begin_recording(); + } + } + EngineCommand::StopRecording => { + self.count_in_remaining = 0; + self.punch_armed = false; + self.stop_recording(); + } + EngineCommand::LoadRegionAudio { bus_name, region_id, start_sample, audio_l, audio_r, fade_in_samples, fade_out_samples } => { + let source_name = format!("region_{}", region_id); + let duration = audio_l.len() as u64; + let source = AudioSource::new(source_name.clone()); + self.ara_controller.register_source(source); + self.ara_controller.add_region(ContentRegion { + source_name, + duration_samples: duration, + playback_rate: 1.0, + }); + if let Some(modules) = self.system_modules.get(&bus_name) { + if let Some(&id) = modules.get("region_player") { + self.module_host.send_data(id, "load_region", Box::new(PlaybackRegion { + bus_name, + region_id, + start_sample, + audio_l, + audio_r, + fade_in_samples, + fade_out_samples, + })); + } + } + } + EngineCommand::UnloadRegionAudio { region_id } => { + self.ara_controller.remove_source(&format!("region_{}", region_id)); + for modules in self.system_modules.values() { + if let Some(&id) = modules.get("region_player") { + self.module_host.send_data(id, "unload_region", Box::new(region_id)); + } + } + } + EngineCommand::LoadMidiRegion { bus_name, region_id, start_beat, notes } => { + if let Some(modules) = self.system_modules.get(&bus_name) { + if let Some(&id) = modules.get("midi_player") { + self.module_host.send_data(id, "load_midi_region", Box::new(MidiPlaybackRegion { + bus_name, + region_id, + start_beat, + notes, + })); + } + } + } + EngineCommand::UnloadMidiRegion { region_id } => { + for modules in self.system_modules.values() { + if let Some(&id) = modules.get("midi_player") { + self.module_host.send_data(id, "unload_region", Box::new(region_id)); + } + } + } + EngineCommand::SetRegionFade { region_id, fade_in_samples, fade_out_samples } => { + for modules in self.system_modules.values() { + if let Some(&id) = modules.get("region_player") { + self.module_host.send_data(id, "set_fade", Box::new((region_id, fade_in_samples, fade_out_samples))); + } + } + } + EngineCommand::SetBusVolume { bus_name, volume } => { + if let Some(mixer_id) = self.output_mixer_id { + self.module_host.send_data( + mixer_id, "set_bus_volume", Box::new((bus_name, volume)), + ); + } + } + EngineCommand::SetBusPan { bus_name, pan } => { + if let Some(mixer_id) = self.output_mixer_id { + self.module_host.send_data( + mixer_id, "set_bus_pan", Box::new((bus_name, pan)), + ); + } + } + EngineCommand::SetBusMute { bus_name, muted } => { + if let Some(mixer_id) = self.output_mixer_id { + self.module_host.send_data( + mixer_id, "set_bus_mute", Box::new((bus_name, muted)), + ); + } + } + EngineCommand::SetBusSolo { bus_name, soloed } => { + if let Some(mixer_id) = self.output_mixer_id { + self.module_host.send_data( + mixer_id, "set_bus_solo", Box::new((bus_name, soloed)), + ); + } + } + EngineCommand::SetCycleState { enabled, start_sample, end_sample } => { + self.cycle_enabled = enabled; + self.cycle_start_sample = start_sample; + self.cycle_end_sample = end_sample; + } + EngineCommand::SetCountIn { bars } => { + self.count_in_bars = bars; + } + EngineCommand::SetPunch { enabled, start_sample, end_sample } => { + self.punch_enabled = enabled; + self.punch_start_sample = start_sample; + self.punch_end_sample = end_sample; + } + EngineCommand::SetMetronomeEnabled(enabled) => { + self.metronome_enabled = enabled; + if enabled { + self.load_metronome(); + } else { + self.unload_metronome(); + } + } + EngineCommand::SetMetronomeVolume(vol) => { + self.metronome_volume = vol; + if let Some(id) = self.click_instrument_id { + self.param_engine.queue_change(id, "volume".into(), vol); + } + } + EngineCommand::Seek { sample_pos } => { + self.sample_pos = sample_pos; + } + EngineCommand::SetMasterVolume(vol) => { + self.master_volume = vol; + } + EngineCommand::SetMasterPan(pan) => { + self.master_pan = pan; + } + EngineCommand::SetTempoCurve { points } => { + self.tempo_map = crate::timing::TempoMap::new(self.tempo); + let mut ara_points = Vec::with_capacity(points.len()); + for (sample_pos, tempo) in points { + self.tempo_map.insert_point(sample_pos, tempo); + ara_points.push(TempoEntry { sample_pos, tempo_bpm: tempo as f64 }); + } + self.ara_controller.set_tempo_curve(ara_points); + } + EngineCommand::SetSend { source_bus, aux_bus, level } => { + let entries = self.sends.entry(source_bus).or_default(); + if let Some(entry) = entries.iter_mut().find(|(b, _)| *b == aux_bus) { + entry.1 = level; + } else { + entries.push((aux_bus, level)); + } + } + EngineCommand::RemoveSend { source_bus, aux_bus } => { + if let Some(entries) = self.sends.get_mut(&source_bus) { + entries.retain(|(b, _)| *b != aux_bus); + } + } + EngineCommand::SetAutomationData { bus_name, target, points } => { + let lanes = self.automation.entry(bus_name).or_default(); + if let Some(lane) = lanes.iter_mut().find(|l| l.target == target) { + lane.points = points; + } else { + lanes.push(super::AutomationLaneEngine { target, points }); + } + } + EngineCommand::SetAutomationMode { bus_name, mode } => { + self.automation_modes.insert(bus_name, mode); + } + EngineCommand::LoadDynamicPlugin { bus_name, plugin_path, chain_position } => { + let config = GlobalConfig { + instance_id: oxforge::mdk::uuid::Uuid::new_v4(), + sample_rate: self.sample_rate as f32, + buffer_size: self.bus_registry.buffer_size() as u32, + }; + let actual_position = chain_position + 5; + match self.module_host.load_dynamic(&plugin_path, &config) { + Some((module_id, display_name, bridge)) => { + self.graph.add_module_to_bus(module_id, &bus_name); + self.graph.set_chain_position(module_id, &bus_name, actual_position); + self.needs_rebuild = true; + if let Some(br) = bridge { + let _ = self.bridge_tx.send((module_id, br)); + } + let pname = self.module_host.plugin_name(module_id).map(|s| s.to_string()); + let has_gui = self.module_host.has_gui(module_id); + let gui_descriptor = self.module_host.gui_descriptor(module_id); + let _ = self.evt_tx.send(EngineEvent::ModuleLoaded { + bus_name, + module_id, + module_type: display_name, + plugin_name: pname, + has_gui, + gui_descriptor, + }); + } + None => { + let _ = self.evt_tx.send(EngineEvent::Error( + format!("failed to load plugin: {}", plugin_path.display()), + )); + } + } + } + EngineCommand::QueryModuleParams { module_id } => { + let descriptors = self.module_host.param_descriptors(module_id); + let _ = self.evt_tx.send(EngineEvent::ModuleParamDescriptors { module_id, descriptors }); + } + EngineCommand::QueryModuleGuiDescriptor { module_id } => { + let descriptor = self.module_host.gui_descriptor(module_id); + let _ = self.evt_tx.send(EngineEvent::ModuleGuiDescriptorReady { module_id, descriptor }); + } + EngineCommand::ScanPlugins => { + let plugins = crate::modules::plugin_host::scan_all_plugins(); + let _ = self.evt_tx.send(EngineEvent::PluginsDiscovered { plugins }); + } + EngineCommand::SetSpatialMode(mode) => { + self.spatial.mode = mode; + } + EngineCommand::SetMonoLane(lane) => { + self.spatial.mono_lane = lane; + } + EngineCommand::SetObjectPosition { bus_name, position } => { + self.spatial.set_position(bus_name, position); + } + EngineCommand::AttachModuleGuiFence { module_id, fence } => { + for routing in self.schedule.entries_mut() { + if routing.module_id == module_id { + routing.gui_fence = Some(fence); + let _ = self.evt_tx.send(EngineEvent::ModuleGuiReady); + break; + } + } + } + EngineCommand::DetachModuleGuiFence { module_id } => { + for routing in self.schedule.entries_mut() { + if routing.module_id == module_id { + routing.gui_fence = None; + break; + } + } + } + } + } + } +} diff --git a/au-o2-gui/src/engine/cycle/metronome.rs b/au-o2-gui/src/engine/cycle/metronome.rs new file mode 100644 index 0000000..51a6e8f --- /dev/null +++ b/au-o2-gui/src/engine/cycle/metronome.rs @@ -0,0 +1,56 @@ +use oxforge::mdk::GlobalConfig; + +use super::CycleProcessor; + +impl CycleProcessor { + pub(super) fn load_metronome(&mut self) { + let config = GlobalConfig { + instance_id: oxforge::mdk::uuid::Uuid::new_v4(), + sample_rate: self.sample_rate as f32, + buffer_size: self.bus_registry.buffer_size() as u32, + }; + + if let Some(midi_id) = crate::modules::registry::load_builtin( + &mut self.module_host, "metronome_midi", &config, + ) { + self.graph.add_module(midi_id); + self.graph.set_module_writes(midi_id, "midi:metronome_midi_out"); + self.metronome_midi_id = Some(midi_id); + } + + if let Some(click_id) = crate::modules::registry::load_builtin( + &mut self.module_host, "click_instrument", &config, + ) { + self.graph.add_module(click_id); + self.graph.set_module_reads(click_id, "midi:metronome_midi_out"); + self.graph.set_module_writes(click_id, "hw_output"); + + if let Some(mixer_id) = self.output_mixer_id { + if let Some(mixer_node) = self.graph.get_node(mixer_id) { + let mixer_pos = mixer_node.chain_position + .as_ref() + .map(|(_, p)| *p) + .unwrap_or(0); + self.graph.set_chain_position(click_id, "hw_output", mixer_pos + 1); + } + } + + self.param_engine.queue_change(click_id, "volume".into(), self.metronome_volume); + self.click_instrument_id = Some(click_id); + } + + self.needs_rebuild = true; + } + + pub(super) fn unload_metronome(&mut self) { + if let Some(id) = self.metronome_midi_id.take() { + self.module_host.unload(id); + self.graph.remove_module(id); + } + if let Some(id) = self.click_instrument_id.take() { + self.module_host.unload(id); + self.graph.remove_module(id); + } + self.needs_rebuild = true; + } +} diff --git a/au-o2-gui/src/engine/cycle/mod.rs b/au-o2-gui/src/engine/cycle/mod.rs new file mode 100644 index 0000000..9ca03c8 --- /dev/null +++ b/au-o2-gui/src/engine/cycle/mod.rs @@ -0,0 +1,208 @@ +mod commands; +mod metronome; +mod modules; +mod process; +mod recording; + +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use crossbeam_channel::{Receiver, Sender, unbounded}; + +use oxforge::mdk::{GlobalConfig, ModuleError, RecorderMessage, ToGuiMessage}; + +use super::ara::AraDocumentController; +use super::spatial::SpatialRenderer; +use super::bus::BusRegistry; +use super::contract::ContractEnforcer; +use super::graph::ProcessGraph; +use super::host::ModuleHost; +use super::param::ParamEngine; +use super::schedule::CycleSchedule; +use super::{AutomationModeFlag, AutomationTarget, EngineCommand, EngineEvent, TransportState}; +use crate::modules::plugin_host::FramebufferGuiBridge; +use crate::timing::{MusicalTime, TempoMap}; + +pub struct CycleProcessor { + pub bus_registry: BusRegistry, + pub module_host: ModuleHost, + pub graph: ProcessGraph, + pub schedule: CycleSchedule, + pub param_engine: ParamEngine, + pub transport: TransportState, + pub tempo: f32, + pub tempo_map: TempoMap, + pub sample_rate: u32, + pub sample_pos: u64, + pub time_signature_numerator: u8, + pub time_signature_denominator: u8, + cmd_rx: Receiver, + evt_tx: Sender, + gui_tx: Sender<(u32, ToGuiMessage)>, + needs_rebuild: bool, + enforcer: ContractEnforcer, + recording: bool, + recording_start_sample: u64, + recording_project_path: Option, + recording_config: Option<(u32, u16, u32)>, + recording_tx: Option>, + system_modules: HashMap>, + recording_armed_buses: HashSet, + output_mixer_id: Option, + running: Arc, + cycle_enabled: bool, + cycle_start_sample: u64, + cycle_end_sample: u64, + metronome_enabled: bool, + metronome_volume: f32, + metronome_midi_id: Option, + click_instrument_id: Option, + master_volume: f32, + master_pan: f32, + automation: HashMap>, + automation_modes: HashMap, + sends: HashMap>, + count_in_bars: u32, + count_in_remaining: u64, + punch_enabled: bool, + punch_start_sample: u64, + punch_end_sample: u64, + punch_armed: bool, + meter_counter: u32, + ara_controller: Arc, + spatial: SpatialRenderer, + bridge_tx: Sender<(u32, FramebufferGuiBridge)>, + error_log_tx: Sender, + #[allow(dead_code)] // drained on demand for debug inspection + error_log_rx: Receiver, + error_report_tx: Sender, + error_report_rx: Receiver, +} + +struct AutomationLaneEngine { + target: AutomationTarget, + points: Vec<(u64, f32)>, +} + +impl AutomationLaneEngine { + fn value_at(&self, pos: u64) -> f32 { + if self.points.is_empty() { return 0.0; } + if self.points.len() == 1 || pos <= self.points[0].0 { return self.points[0].1; } + let last = self.points.last().unwrap(); + if pos >= last.0 { return last.1; } + let idx = self.points.partition_point(|p| p.0 <= pos); + let a = &self.points[idx - 1]; + let b = &self.points[idx]; + let t = (pos - a.0) as f32 / (b.0 - a.0) as f32; + a.1 + (b.1 - a.1) * t + } +} + +impl CycleProcessor { + pub fn new( + sample_rate: u32, + buffer_size: u32, + hilbert_fft_size: usize, + cmd_rx: Receiver, + evt_tx: Sender, + gui_tx: Sender<(u32, ToGuiMessage)>, + bridge_tx: Sender<(u32, FramebufferGuiBridge)>, + running: Arc, + ) -> Self { + let mut bus_registry = BusRegistry::new( + buffer_size as usize, 2, sample_rate, hilbert_fft_size, + ); + bus_registry.create_bus("hw_input"); + bus_registry.create_bus("hw_output"); + + let enforcer = ContractEnforcer::new(buffer_size as usize, sample_rate); + let (error_log_tx, error_log_rx) = unbounded(); + let (error_report_tx, error_report_rx) = unbounded(); + + let mut processor = Self { + bus_registry, + module_host: ModuleHost::new(), + graph: ProcessGraph::new(), + schedule: CycleSchedule::new(), + param_engine: ParamEngine::new(), + transport: TransportState::Stopped, + tempo: 120.0, + tempo_map: TempoMap::new(120.0), + sample_rate, + sample_pos: 0, + time_signature_numerator: 4, + time_signature_denominator: 4, + cmd_rx, + evt_tx, + gui_tx, + needs_rebuild: false, + enforcer, + recording: false, + recording_start_sample: 0, + recording_project_path: None, + recording_config: None, + recording_tx: None, + system_modules: HashMap::new(), + recording_armed_buses: HashSet::new(), + output_mixer_id: None, + running, + cycle_enabled: false, + cycle_start_sample: 0, + cycle_end_sample: 0, + metronome_enabled: false, + metronome_volume: 1.0, + metronome_midi_id: None, + click_instrument_id: None, + master_volume: 1.0, + master_pan: 0.0, + automation: HashMap::new(), + automation_modes: HashMap::new(), + sends: HashMap::new(), + count_in_bars: 0, + count_in_remaining: 0, + punch_enabled: false, + punch_start_sample: 0, + punch_end_sample: 0, + punch_armed: false, + meter_counter: 0, + ara_controller: AraDocumentController::with_relay(120.0, 4, 4), + spatial: SpatialRenderer::new(buffer_size as usize), + bridge_tx, + error_log_tx, + error_log_rx, + error_report_tx, + error_report_rx, + }; + + let config = GlobalConfig { + instance_id: oxforge::mdk::uuid::Uuid::new_v4(), + sample_rate: sample_rate as f32, + buffer_size, + }; + if let Some(mixer_id) = crate::modules::registry::load_builtin( + &mut processor.module_host, "output_mixer", &config, + ) { + processor.graph.add_module(mixer_id); + processor.graph.set_module_writes(mixer_id, "hw_output"); + processor.graph.set_chain_position(mixer_id, "hw_output", 0); + processor.output_mixer_id = Some(mixer_id); + processor.needs_rebuild = true; + } + + processor + } + + fn current_musical_time(&self) -> MusicalTime { + MusicalTime::from_samples_mapped( + self.sample_pos, + &self.tempo_map, + self.sample_rate, + self.time_signature_numerator as u32, + ) + } + + fn current_tempo(&self) -> f32 { + self.tempo_map.tempo_at(self.sample_pos) + } +} diff --git a/au-o2-gui/src/engine/cycle/modules.rs b/au-o2-gui/src/engine/cycle/modules.rs new file mode 100644 index 0000000..a6cfa96 --- /dev/null +++ b/au-o2-gui/src/engine/cycle/modules.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; + +use oxforge::mdk::GlobalConfig; + +use super::super::schedule::CycleSchedule; +use super::super::EngineEvent; +use super::CycleProcessor; + +impl CycleProcessor { + pub(super) fn auto_load_system_modules(&mut self, bus_name: &str, is_midi: bool) { + let config = GlobalConfig { + instance_id: oxforge::mdk::uuid::Uuid::new_v4(), + sample_rate: self.sample_rate as f32, + buffer_size: self.bus_registry.buffer_size() as u32, + }; + + let base_chain: &[(&str, usize)] = &[ + ("region_player", 0), + ("input_router", 1), + ("hilbert", 2), + ("recorder", 3), + ]; + let midi_entry: [(&str, usize); 1] = [("midi_player", 4)]; + + let system_chain: Vec<(&str, usize)> = if is_midi { + base_chain.iter().copied().chain(midi_entry.iter().copied()).collect() + } else { + base_chain.to_vec() + }; + + let mut bus_modules = HashMap::new(); + + for (type_name, chain_pos) in &system_chain { + if let Some(module_id) = crate::modules::registry::load_builtin( + &mut self.module_host, type_name, &config, + ) { + self.graph.add_module_to_bus(module_id, bus_name); + self.graph.set_chain_position(module_id, bus_name, *chain_pos); + + self.module_host.send_data( + module_id, "bus_name", + Box::new(bus_name.to_string()), + ); + + let has_gui = self.module_host.has_gui(module_id); + let gui_descriptor = self.module_host.gui_descriptor(module_id); + let _ = self.evt_tx.send(EngineEvent::ModuleLoaded { + bus_name: bus_name.to_string(), + module_id, + module_type: type_name.to_string(), + plugin_name: None, + has_gui, + gui_descriptor, + }); + + bus_modules.insert(type_name.to_string(), module_id); + } + } + + if let Some(mixer_id) = self.output_mixer_id { + self.graph.set_module_reads(mixer_id, bus_name); + } + + let ctx = self.ara_controller.musical_context(); + self.time_signature_numerator = ctx.time_signature_num; + self.time_signature_denominator = ctx.time_signature_den; + if let Some(tp) = ctx.tempo_points.first() { + if tp.sample_pos == 0 { + self.tempo = tp.tempo_bpm as f32; + } + } + for &module_id in bus_modules.values() { + self.module_host.send_data( + module_id, "ara_controller", + Box::new(self.ara_controller.clone()), + ); + } + + self.system_modules.insert(bus_name.to_string(), bus_modules); + self.needs_rebuild = true; + } + + pub(super) fn rebuild_schedule(&mut self) { + match self.graph.resolve_order() { + Ok(order) => { + let prev_fences: HashMap = self.schedule.entries_mut() + .iter_mut() + .filter_map(|r| r.gui_fence.take().map(|f| (r.module_id, f))) + .collect(); + + let mut schedule = CycleSchedule::from_graph(&order, &self.graph, &self.bus_registry, &self.module_host); + for routing in schedule.entries_mut() { + let module_id = routing.module_id; + let tx = self.gui_tx.clone(); + routing.to_gui = oxforge::mdk::ToGuiQueue::with_callback(move |msg| { + let _ = tx.send((module_id, msg)); + }); + + if let Some(module) = self.module_host.get(module_id) { + routing.disabled = module.disabled; + } + + if let Some(fence) = prev_fences.get(&module_id) { + routing.gui_fence = Some(fence.clone()); + } + } + + // Plugin delay compensation + if let Some(mixer_id) = self.output_mixer_id { + let mut bus_latency: HashMap = HashMap::new(); + for &module_id in &order { + if let Some(node) = self.graph.get_node(module_id) { + if let Some((bus_name, _)) = &node.chain_position { + if bus_name == "hw_input" || bus_name == "hw_output" { + continue; + } + if let Some(module) = self.module_host.get(module_id) { + *bus_latency.entry(bus_name.clone()).or_default() + += module.contract.latency_samples; + } + } + } + } + let max_latency = bus_latency.values().copied().max().unwrap_or(0); + if max_latency > 0 { + for (bus_name, latency) in &bus_latency { + let delay = (max_latency - latency) as usize; + self.module_host.send_data( + mixer_id, "set_bus_delay", + Box::new((bus_name.clone(), delay)), + ); + } + for name in self.bus_registry.bus_names() { + if name == "hw_input" || name == "hw_output" { continue; } + if !bus_latency.contains_key(&name) { + self.module_host.send_data( + mixer_id, "set_bus_delay", + Box::new((name, max_latency as usize)), + ); + } + } + } + } + + self.schedule = schedule; + let _ = self.evt_tx.send(EngineEvent::GraphRebuilt); + } + Err(e) => { + let _ = self.evt_tx.send(EngineEvent::Error(e)); + } + } + } +} diff --git a/au-o2-gui/src/engine/cycle/process.rs b/au-o2-gui/src/engine/cycle/process.rs new file mode 100644 index 0000000..01f8d6e --- /dev/null +++ b/au-o2-gui/src/engine/cycle/process.rs @@ -0,0 +1,296 @@ +use std::time::Instant; + +use oxforge::mdk::{ErrorKind, RecorderMessage}; + +use super::super::schedule::CycleContext; +use super::super::{AutomationModeFlag, AutomationTarget, EngineEvent, TransportState}; +use super::CycleProcessor; + +impl CycleProcessor { + pub fn process_cycle(&mut self, hw_input: &[f32], hw_output: &mut [f32]) { + self.drain_commands(); + self.apply_automation(); + self.param_engine.apply_pending(&mut self.module_host); + + if self.needs_rebuild { + self.rebuild_schedule(); + self.needs_rebuild = false; + } + + let channels = self.bus_registry.channels(); + let total_frames = hw_output.len() / channels; + + // Sample-accurate cycle: detect if this buffer spans the cycle boundary + let cycle_split = if self.transport == TransportState::Playing + && self.cycle_enabled + && self.cycle_end_sample > self.cycle_start_sample + { + let buffer_end = self.sample_pos + total_frames as u64; + if self.sample_pos < self.cycle_end_sample && buffer_end > self.cycle_end_sample { + Some((self.cycle_end_sample - self.sample_pos) as usize) + } else { + None + } + } else { + None + }; + + if let Some(split_frame) = cycle_split { + self.process_inner(hw_input, hw_output); + + if self.recording { + if let Some(ref tx) = self.recording_tx { + let _ = tx.send(RecorderMessage::CycleBoundary { + boundary_sample: self.cycle_end_sample, + }); + } + } + + self.sample_pos = self.cycle_start_sample; + + let mut tail_buf = vec![0.0f32; hw_output.len()]; + self.process_inner(hw_input, &mut tail_buf); + + let tail_start = split_frame * channels; + let tail_len = hw_output.len() - tail_start; + if tail_start < hw_output.len() { + hw_output[tail_start..].copy_from_slice(&tail_buf[..tail_len]); + } + + let tail_frames = (total_frames - split_frame) as u64; + self.sample_pos = self.cycle_start_sample + tail_frames; + } else { + self.process_inner(hw_input, hw_output); + + if self.transport == TransportState::Playing { + let advanced = total_frames as u64; + self.sample_pos += advanced; + + if self.count_in_remaining > 0 { + if self.count_in_remaining <= advanced { + self.count_in_remaining = 0; + self.begin_recording(); + } else { + self.count_in_remaining -= advanced; + } + } + + if self.punch_armed && !self.recording + && self.sample_pos >= self.punch_start_sample + { + self.begin_recording(); + } + + if self.punch_armed && self.recording + && self.sample_pos >= self.punch_end_sample + { + self.punch_armed = false; + self.stop_recording(); + } + + if self.cycle_enabled + && self.sample_pos >= self.cycle_end_sample + && self.cycle_end_sample > self.cycle_start_sample + { + if self.recording { + if let Some(ref tx) = self.recording_tx { + let _ = tx.send(RecorderMessage::CycleBoundary { + boundary_sample: self.cycle_end_sample, + }); + } + } + let overshoot = self.sample_pos - self.cycle_end_sample; + self.sample_pos = self.cycle_start_sample + overshoot; + } + } + } + + self.drain_error_reports(); + + // Master volume, pan, and brickwall limiter + let (gain_l, gain_r) = { + let pan = self.master_pan; + let vol = self.master_volume; + let angle = (pan + 1.0) * 0.25 * std::f32::consts::PI; + (vol * angle.cos(), vol * angle.sin()) + }; + let frames = hw_output.len() / 2; + for i in 0..frames { + hw_output[i * 2] = (hw_output[i * 2] * gain_l).clamp(-1.0, 1.0); + hw_output[i * 2 + 1] = (hw_output[i * 2 + 1] * gain_r).clamp(-1.0, 1.0); + } + + self.meter_counter = self.meter_counter.wrapping_add(1); + if self.meter_counter % 8 == 0 { + let mut bus_peaks: Vec<(String, f32, f32)> = Vec::new(); + for name in self.bus_registry.bus_names() { + if name == "hw_input" || name == "hw_output" { continue; } + if let Some(bus) = self.bus_registry.get_by_name(&name) { + let lanes = bus.lanes(); + let peak_l = lanes.get(0) + .map(|l| l.real().iter().fold(0.0f32, |m, &s| m.max(s.abs()))) + .unwrap_or(0.0); + let peak_r = lanes.get(1) + .map(|l| l.real().iter().fold(0.0f32, |m, &s| m.max(s.abs()))) + .unwrap_or(0.0); + bus_peaks.push((name, peak_l, peak_r)); + } + } + let master_peak_l = hw_output.iter().step_by(2).fold(0.0f32, |m, &s| m.max(s.abs())); + let master_peak_r = hw_output.iter().skip(1).step_by(2).fold(0.0f32, |m, &s| m.max(s.abs())); + let _ = self.evt_tx.send(EngineEvent::MeterUpdate { + bus_peaks, + master_peak: (master_peak_l, master_peak_r), + }); + } + + if self.transport == TransportState::Playing { + let _ = self.evt_tx.send(EngineEvent::TransportPosition(self.current_musical_time())); + } + } + + fn process_inner(&mut self, hw_input: &[f32], hw_output: &mut [f32]) { + self.bus_registry.clear_all(); + + if let Some(bus) = self.bus_registry.get_mut_by_name("hw_input") { + bus.write_interleaved(hw_input); + } + + let ctx = CycleContext { + sample_pos: self.sample_pos, + tempo: self.current_tempo(), + beat_pos: self.tempo_map.beat_pos_at(self.sample_pos, self.sample_rate), + sample_rate: self.sample_rate, + transport: self.transport, + time_sig_num: self.time_signature_numerator, + time_sig_den: self.time_signature_denominator, + cycle_active: self.cycle_enabled, + cycle_start_sample: self.cycle_start_sample, + cycle_end_sample: self.cycle_end_sample, + }; + + let cycle_start = Instant::now(); + self.schedule.execute( + &mut self.bus_registry, + &mut self.module_host, + &self.param_engine, + &ctx, + &mut self.enforcer, + &self.error_log_tx, + &self.error_report_tx, + ); + let total_ns = cycle_start.elapsed().as_nanos() as u64; + + if let Some(violation) = self.enforcer.check_cycle(total_ns) { + if violation.recording { + let current = self.bus_registry.buffer_size(); + let new_size = self.enforcer.suggest_buffer_increase(current); + let latency_ms = new_size as f32 / self.sample_rate as f32 * 1000.0; + let _ = self.evt_tx.send(EngineEvent::BufferAutoIncreased { + new_size, + latency_ms, + reason: format!("module {} exceeded budget during recording", violation.module_id), + }); + } + } + + self.spatial.render(&mut self.bus_registry, self.sample_rate); + self.apply_sends(); + + if let Some(bus) = self.bus_registry.get_by_name("hw_output") { + let interleaved = bus.read_interleaved(); + let len = hw_output.len().min(interleaved.len()); + hw_output[..len].copy_from_slice(&interleaved[..len]); + } + } + + fn apply_sends(&mut self) { + let send_list: Vec<(String, Vec<(String, f32)>)> = self.sends.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + for (source_bus, targets) in &send_list { + let source_audio = match self.bus_registry.get_by_name(source_bus) { + Some(bus) => bus.read_interleaved(), + None => continue, + }; + for (aux_bus, level) in targets { + if *level <= 0.0 { continue; } + if let Some(bus) = self.bus_registry.get_mut_by_name(aux_bus) { + bus.accumulate_interleaved(&source_audio, *level); + } + } + } + } + + fn apply_automation(&mut self) { + if self.transport != TransportState::Playing { + return; + } + let pos = self.sample_pos; + for (bus_name, lanes) in &self.automation { + let mode = self.automation_modes.get(bus_name).copied() + .unwrap_or(AutomationModeFlag::Off); + if mode.writes() && !mode.reads() { + continue; + } + if !mode.reads() { + continue; + } + for lane in lanes { + if lane.points.is_empty() { continue; } + let value = lane.value_at(pos); + match &lane.target { + AutomationTarget::Volume => { + if let Some(mixer_id) = self.output_mixer_id { + self.module_host.send_data( + mixer_id, "set_bus_volume", + Box::new((bus_name.clone(), value)), + ); + } + } + AutomationTarget::Pan => { + if let Some(mixer_id) = self.output_mixer_id { + self.module_host.send_data( + mixer_id, "set_bus_pan", + Box::new((bus_name.clone(), value)), + ); + } + } + AutomationTarget::Mute => { + if let Some(mixer_id) = self.output_mixer_id { + self.module_host.send_data( + mixer_id, "set_bus_mute", + Box::new((bus_name.clone(), value >= 0.5)), + ); + } + } + AutomationTarget::ModuleParam { module_id, key } => { + self.param_engine.queue_change(*module_id, key.clone(), value); + let _ = self.evt_tx.send(EngineEvent::ModuleParamChanged { + module_id: *module_id, + key: key.clone(), + value, + }); + } + } + } + } + } + + fn drain_error_reports(&self) { + while let Ok(err) = self.error_report_rx.try_recv() { + let kind_str = match err.error_kind { + ErrorKind::Panic => "Panic", + ErrorKind::ProcessError => "ProcessError", + ErrorKind::PortError => "PortError", + ErrorKind::ContractViolation => "ContractViolation", + }; + let _ = self.evt_tx.send(EngineEvent::ModuleErrorReport { + module_id: err.module_id, + module_name: err.module_name, + error_kind: kind_str.to_string(), + message: err.message, + }); + } + } +} diff --git a/au-o2-gui/src/engine/cycle/recording.rs b/au-o2-gui/src/engine/cycle/recording.rs new file mode 100644 index 0000000..e4bf55c --- /dev/null +++ b/au-o2-gui/src/engine/cycle/recording.rs @@ -0,0 +1,69 @@ +use oxforge::mdk::RecorderMessage; + +use super::super::recorder::spawn_recorder; +use super::CycleProcessor; + +impl CycleProcessor { + pub(super) fn begin_recording(&mut self) { + self.recording = true; + self.recording_start_sample = self.sample_pos; + self.enforcer.set_recording(true); + self.recording_armed_buses.clear(); + + let tx = spawn_recorder(self.evt_tx.clone()); + self.recording_tx = Some(tx.clone()); + + for (bus_name, modules) in &self.system_modules { + let armed = modules.get("input_router") + .and_then(|&ir_id| self.param_engine.get_params(ir_id)) + .and_then(|params| params.get("armed")) + .map(|&v| v > 0.5) + .unwrap_or(false); + + if !armed { + continue; + } + + if let Some(&id) = modules.get("recorder") { + self.module_host.send_data(id, "start_recording", Box::new(tx.clone())); + self.recording_armed_buses.insert(bus_name.clone()); + } + } + debug_log!("[recording] started: {} armed buses, sample_pos={}", + self.recording_armed_buses.len(), self.sample_pos); + } + + pub(super) fn stop_recording(&mut self) { + if !self.recording { + return; + } + for (bus_name, modules) in &self.system_modules { + if !self.recording_armed_buses.contains(bus_name) { + continue; + } + if let Some(&id) = modules.get("recorder") { + self.module_host.send_data(id, "stop_recording", Box::new(())); + } + } + if let Some(ref tx) = self.recording_tx { + if let Some(ref project_path) = self.recording_project_path { + let (sr, bd, fft) = self.recording_config.unwrap_or((48000, 24, 2048)); + let _ = tx.send(RecorderMessage::Finish { + project_path: project_path.clone(), + sample_rate: sr, + bit_depth: bd, + fft_size: fft, + start_sample: self.recording_start_sample, + tempo: self.tempo, + time_sig_num: self.time_signature_numerator, + }); + } + } + self.recording = false; + self.recording_tx = None; + self.recording_project_path = None; + self.recording_config = None; + self.recording_armed_buses.clear(); + self.enforcer.set_recording(false); + } +} diff --git a/au-o2-gui/src/engine/device.rs b/au-o2-gui/src/engine/device.rs new file mode 100644 index 0000000..ca2883d --- /dev/null +++ b/au-o2-gui/src/engine/device.rs @@ -0,0 +1,143 @@ +use cpal::traits::{DeviceTrait, HostTrait}; +use cpal::SampleFormat; + +pub const STANDARD_RATES: &[u32] = &[ + 8000, 11025, 16000, 22050, 32000, 44100, 48000, + 88200, 96000, 176400, 192000, 352800, 384000, +]; + +#[derive(Debug, Clone)] +pub struct DeviceCapabilities { + pub name: String, + pub supported_sample_rates: Vec, + pub supported_formats: Vec, + pub buffer_size_range: Option<(u32, u32)>, +} + +impl DeviceCapabilities { + pub fn max_bit_depth(&self) -> u16 { + self.supported_formats.iter().map(|f| format_bit_depth(*f)).max().unwrap_or(16) + } +} + +#[derive(Debug, Clone, Default)] +pub struct DeviceCache { + pub output_devices: Vec, + pub input_devices: Vec, +} + +pub fn query_all_devices() -> DeviceCache { + let host = cpal::default_host(); + + let output_devices = host.output_devices() + .into_iter() + .flatten() + .filter_map(|dev| query_device_caps(&dev, false)) + .collect(); + + let input_devices = host.input_devices() + .into_iter() + .flatten() + .filter_map(|dev| query_device_caps(&dev, true)) + .collect(); + + DeviceCache { output_devices, input_devices } +} + +fn query_device_caps(device: &cpal::Device, is_input: bool) -> Option { + let name = device.name().ok()?; + + let configs: Vec<_> = if is_input { + device.supported_input_configs().ok()?.collect() + } else { + device.supported_output_configs().ok()?.collect() + }; + + if configs.is_empty() { + return None; + } + + let mut sample_rates = Vec::new(); + let mut formats = Vec::new(); + let mut buf_min: Option = None; + let mut buf_max: Option = None; + + for cfg in &configs { + let min_rate = cfg.min_sample_rate().0; + let max_rate = cfg.max_sample_rate().0; + + for &rate in STANDARD_RATES { + if rate >= min_rate && rate <= max_rate && !sample_rates.contains(&rate) { + sample_rates.push(rate); + } + } + + if !formats.contains(&cfg.sample_format()) { + formats.push(cfg.sample_format()); + } + + match cfg.buffer_size() { + cpal::SupportedBufferSize::Range { min, max } => { + buf_min = Some(buf_min.map_or(*min, |v: u32| v.min(*min))); + buf_max = Some(buf_max.map_or(*max, |v: u32| v.max(*max))); + } + cpal::SupportedBufferSize::Unknown => {} + } + } + + sample_rates.sort(); + + let buffer_size_range = match (buf_min, buf_max) { + (Some(min), Some(max)) => Some((min, max)), + _ => None, + }; + + Some(DeviceCapabilities { + name, + supported_sample_rates: sample_rates, + supported_formats: formats, + buffer_size_range, + }) +} + +pub fn format_bit_depth(format: SampleFormat) -> u16 { + match format { + SampleFormat::I8 | SampleFormat::U8 => 8, + SampleFormat::I16 | SampleFormat::U16 => 16, + SampleFormat::I32 | SampleFormat::U32 | SampleFormat::F32 => 32, + SampleFormat::I64 | SampleFormat::U64 | SampleFormat::F64 => 64, + _ => 32, + } +} + +pub fn negotiate_bit_depth(output: &DeviceCapabilities, input: &DeviceCapabilities) -> u16 { + let out_max = output.max_bit_depth(); + let in_max = input.max_bit_depth(); + out_max.min(in_max) +} + +pub fn negotiate_sample_rates(output: &DeviceCapabilities, input: &DeviceCapabilities) -> Vec { + output.supported_sample_rates.iter() + .filter(|r| input.supported_sample_rates.contains(r)) + .copied() + .collect() +} + +pub fn find_device<'a>(name: &str, caps: &'a [DeviceCapabilities]) -> Option<&'a DeviceCapabilities> { + if name == "Default" || name.is_empty() { + caps.first() + } else { + caps.iter().find(|d| d.name == name) + } +} + +pub fn buffer_size_options(range: Option<(u32, u32)>) -> Vec { + let standard = [32, 64, 128, 256, 512, 1024, 2048, 4096]; + match range { + Some((min, max)) => standard.iter() + .copied() + .filter(|&s| s >= min && s <= max) + .collect(), + None => standard.to_vec(), + } +} diff --git a/au-o2-gui/src/engine/graph.rs b/au-o2-gui/src/engine/graph.rs new file mode 100644 index 0000000..4a808f9 --- /dev/null +++ b/au-o2-gui/src/engine/graph.rs @@ -0,0 +1,156 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +#[derive(Debug, Clone)] +pub struct ModuleNode { + #[allow(dead_code)] // stored for graph debug/introspection + pub module_id: u32, + pub reads: HashSet, + pub writes: HashSet, + pub chain_position: Option<(String, usize)>, +} + +pub struct ProcessGraph { + nodes: HashMap, +} + +impl ProcessGraph { + pub fn new() -> Self { + Self { + nodes: HashMap::new(), + } + } + + pub fn add_module(&mut self, module_id: u32) { + self.nodes.entry(module_id).or_insert_with(|| ModuleNode { + module_id, + reads: HashSet::new(), + writes: HashSet::new(), + chain_position: None, + }); + } + + pub fn remove_module(&mut self, module_id: u32) { + self.nodes.remove(&module_id); + } + + pub fn add_module_to_bus(&mut self, module_id: u32, bus_name: &str) { + let node = self.nodes.entry(module_id).or_insert_with(|| ModuleNode { + module_id, + reads: HashSet::new(), + writes: HashSet::new(), + chain_position: None, + }); + node.reads.insert(bus_name.to_string()); + node.writes.insert(bus_name.to_string()); + } + + pub fn set_chain_position(&mut self, module_id: u32, bus_name: &str, position: usize) { + if let Some(node) = self.nodes.get_mut(&module_id) { + node.chain_position = Some((bus_name.to_string(), position)); + } + } + + pub fn set_module_reads(&mut self, module_id: u32, bus_name: &str) { + if let Some(node) = self.nodes.get_mut(&module_id) { + node.reads.insert(bus_name.to_string()); + } + } + + pub fn set_module_writes(&mut self, module_id: u32, bus_name: &str) { + if let Some(node) = self.nodes.get_mut(&module_id) { + node.writes.insert(bus_name.to_string()); + } + } + + pub fn remove_module_read(&mut self, module_id: u32, bus_name: &str) { + if let Some(node) = self.nodes.get_mut(&module_id) { + node.reads.remove(bus_name); + } + } + + pub fn get_node(&self, module_id: u32) -> Option<&ModuleNode> { + self.nodes.get(&module_id) + } + + /// Kahn's algorithm: topological sort based on bus read/write dependencies. + /// Writers must execute before readers within each cycle. + pub fn resolve_order(&self) -> Result, String> { + if self.nodes.is_empty() { + return Ok(Vec::new()); + } + + // Build adjacency: if module A writes to bus X and module B reads from bus X, + // then A must come before B (edge A -> B). + let ids: Vec = self.nodes.keys().copied().collect(); + let mut in_degree: HashMap = ids.iter().map(|&id| (id, 0)).collect(); + let mut adj: HashMap> = ids.iter().map(|&id| (id, Vec::new())).collect(); + + for &writer_id in &ids { + let writer = &self.nodes[&writer_id]; + for &reader_id in &ids { + if writer_id == reader_id { + continue; + } + let reader = &self.nodes[&reader_id]; + // If writer writes to a bus that reader reads (and reader doesn't also write it), + // writer must come first. + for bus in &writer.writes { + if reader.reads.contains(bus) && !reader.writes.contains(bus) { + if let Some(edges) = adj.get_mut(&writer_id) { + edges.push(reader_id); + } + if let Some(deg) = in_degree.get_mut(&reader_id) { + *deg += 1; + } + } + } + } + } + + let mut queue: VecDeque = in_degree + .iter() + .filter(|(_id, deg)| **deg == 0) + .map(|(id, _)| *id) + .collect(); + + // Stable ordering: process lower IDs first + let mut queue_vec: Vec = queue.drain(..).collect(); + queue_vec.sort(); + queue.extend(queue_vec); + + let mut order = Vec::with_capacity(ids.len()); + + while let Some(id) = queue.pop_front() { + order.push(id); + if let Some(neighbors) = adj.get(&id) { + for &next in neighbors { + if let Some(deg) = in_degree.get_mut(&next) { + *deg = deg.saturating_sub(1); + if *deg == 0 { + queue.push_back(next); + } + } + } + } + } + + if order.len() != ids.len() { + return Err("cycle detected in module graph".into()); + } + + // Stable-sort modules on the same bus by chain_position + let nodes = &self.nodes; + order.sort_by(|&a, &b| { + let a_pos = nodes.get(&a).and_then(|n| n.chain_position.as_ref()); + let b_pos = nodes.get(&b).and_then(|n| n.chain_position.as_ref()); + match (a_pos, b_pos) { + (Some((bus_a, pos_a)), Some((bus_b, pos_b))) if bus_a == bus_b => { + pos_a.cmp(pos_b) + } + _ => std::cmp::Ordering::Equal, + } + }); + + Ok(order) + } +} diff --git a/au-o2-gui/src/engine/host.rs b/au-o2-gui/src/engine/host.rs new file mode 100644 index 0000000..6e92435 --- /dev/null +++ b/au-o2-gui/src/engine/host.rs @@ -0,0 +1,172 @@ +use oxforge::mdk::{ + GlobalConfig, ModuleContract, ModuleGuiDescriptor, OxideModule, + ParameterDescriptor, PortDeclaration, Ports, ProcessContext, +}; +use std::any::Any; +use std::collections::HashMap; +use std::path::Path; + +pub struct LoadedModule { + pub name: String, + pub plugin_name: Option, + pub contract: ModuleContract, + pub port_declarations: Vec, + pub disabled: bool, + pub is_dynamic: bool, + instance: Box, +} + +impl LoadedModule { + pub fn process(&mut self, ports: Ports, context: &ProcessContext) { + self.instance.process(ports, context); + } + + pub fn receive_data(&mut self, key: &str, data: Box) { + self.instance.receive_data(key, data); + } + + pub fn param_descriptors(&self) -> Vec { + self.instance.param_descriptors() + } + + pub fn gui_descriptor(&self) -> Option { + self.instance.gui_descriptor() + } + + pub fn has_gui(&self) -> bool { + self.instance.has_gui() + } + +} + +pub struct ModuleHost { + modules: HashMap, + next_id: u32, +} + +impl ModuleHost { + pub fn new() -> Self { + Self { + modules: HashMap::new(), + next_id: 1, + } + } + + pub fn load_builtin_boxed(&mut self, instance: Box, name: &str) -> u32 { + let id = self.next_id; + self.next_id += 1; + + let contract = instance.contract(); + let port_declarations = instance.port_declarations(); + self.modules.insert(id, LoadedModule { + name: name.to_string(), + plugin_name: None, + contract, + port_declarations, + disabled: false, + is_dynamic: false, + instance, + }); + id + } + + /// Load a dynamic plugin from a dylib file path. + /// Returns (module_id, display_name, optional framebuffer bridge). + pub fn load_dynamic( + &mut self, + path: &Path, + config: &GlobalConfig, + ) -> Option<(u32, String, Option)> { + let (plugin, _load_info) = crate::modules::plugin_host::DynamicPlugin::load(path, config)?; + let id = self.next_id; + self.next_id += 1; + + let info = plugin.info(); + let contract = plugin.contract(); + let port_declarations = plugin.port_declarations(); + let display_name = info.display_name.clone(); + let plugin_name = info.name.clone(); + let bridge = plugin.take_framebuffer_bridge(); + + self.modules.insert(id, LoadedModule { + name: display_name.clone(), + plugin_name: Some(plugin_name), + contract, + port_declarations, + disabled: false, + is_dynamic: true, + instance: Box::new(plugin), + }); + + Some((id, display_name, bridge)) + } + + pub fn unload(&mut self, module_id: u32) -> bool { + if let Some(m) = self.modules.get(&module_id) { + if !m.is_dynamic { + return false; + } + } + self.modules.remove(&module_id).is_some() + } + + pub fn get_mut(&mut self, module_id: u32) -> Option<&mut LoadedModule> { + self.modules.get_mut(&module_id) + } + + pub fn get(&self, module_id: u32) -> Option<&LoadedModule> { + self.modules.get(&module_id) + } + + pub fn plugin_name(&self, module_id: u32) -> Option<&str> { + self.modules.get(&module_id) + .and_then(|m| m.plugin_name.as_deref()) + } + + pub fn set_param(&mut self, module_id: u32, key: &str, value: f32) -> bool { + if let Some(m) = self.modules.get_mut(&module_id) { + m.receive_data(key, Box::new(value)); + true + } else { + false + } + } + + pub fn disable_module(&mut self, module_id: u32) { + if let Some(m) = self.modules.get_mut(&module_id) { + m.disabled = true; + } + } + + pub fn set_disabled(&mut self, module_id: u32, disabled: bool) { + if let Some(m) = self.modules.get_mut(&module_id) { + m.disabled = disabled; + } + } + + pub fn param_descriptors(&self, module_id: u32) -> Vec { + self.modules.get(&module_id) + .map(|m| m.param_descriptors()) + .unwrap_or_default() + } + + pub fn gui_descriptor(&self, module_id: u32) -> Option { + self.modules.get(&module_id) + .and_then(|m| m.gui_descriptor()) + } + + pub fn has_gui(&self, module_id: u32) -> bool { + self.modules.get(&module_id) + .map(|m| m.has_gui()) + .unwrap_or(false) + } + + pub fn send_data(&mut self, module_id: u32, key: &str, data: Box) -> bool { + if let Some(m) = self.modules.get_mut(&module_id) { + m.receive_data(key, data); + true + } else { + false + } + } +} diff --git a/au-o2-gui/src/engine/io.rs b/au-o2-gui/src/engine/io.rs new file mode 100644 index 0000000..56254e4 --- /dev/null +++ b/au-o2-gui/src/engine/io.rs @@ -0,0 +1,289 @@ +use crossbeam_channel::Sender; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use ringbuf::HeapRb; +use ringbuf::traits::{Consumer, Producer, Split}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use oxforge::mdk::ToGuiMessage; + +use super::cycle::CycleProcessor; +use super::device; +use super::resample::IoResampler; +use super::{EngineCommand, EngineConfig, EngineEvent}; + +struct ResolvedDevice { + device: cpal::Device, + name: String, + was_fallback: bool, +} + +fn resolve_output(host: &cpal::Host, requested: &str) -> Option { + if requested == "Default" || requested.is_empty() { + let d = host.default_output_device()?; + let name = d.name().unwrap_or_default(); + return Some(ResolvedDevice { device: d, name, was_fallback: false }); + } + + if let Some(d) = host.output_devices().ok() + .and_then(|mut devs| devs.find(|d| d.name().ok().as_deref() == Some(requested))) + { + return Some(ResolvedDevice { + name: requested.to_string(), + device: d, + was_fallback: false, + }); + } + + let d = host.default_output_device()?; + let name = d.name().unwrap_or_default(); + Some(ResolvedDevice { device: d, name, was_fallback: true }) +} + +fn resolve_input(host: &cpal::Host, requested: &str) -> Option { + if requested == "Default" || requested.is_empty() { + let d = host.default_input_device()?; + let name = d.name().unwrap_or_default(); + return Some(ResolvedDevice { device: d, name, was_fallback: false }); + } + + if let Some(d) = host.input_devices().ok() + .and_then(|mut devs| devs.find(|d| d.name().ok().as_deref() == Some(requested))) + { + return Some(ResolvedDevice { + name: requested.to_string(), + device: d, + was_fallback: false, + }); + } + + let d = host.default_input_device()?; + let name = d.name().unwrap_or_default(); + Some(ResolvedDevice { device: d, name, was_fallback: true }) +} + +fn collect_supported_rates( + ranges: impl Iterator, +) -> Vec { + let mut supported = Vec::new(); + for cfg in ranges { + let min = cfg.min_sample_rate().0; + let max = cfg.max_sample_rate().0; + for &rate in device::STANDARD_RATES { + if rate >= min && rate <= max && !supported.contains(&rate) { + supported.push(rate); + } + } + } + supported.sort(); + supported +} + +fn negotiate_rate(device: &cpal::Device, requested: u32, is_input: bool) -> u32 { + let supported = if is_input { + match device.supported_input_configs() { + Ok(c) => collect_supported_rates(c), + Err(_) => return requested, + } + } else { + match device.supported_output_configs() { + Ok(c) => collect_supported_rates(c), + Err(_) => return requested, + } + }; + + if supported.is_empty() || supported.contains(&requested) { + return requested; + } + + supported.into_iter() + .min_by_key(|&r| (r as i64 - requested as i64).unsigned_abs()) + .unwrap_or(requested) +} + +pub fn run_audio( + config: &EngineConfig, + cmd_rx: crossbeam_channel::Receiver, + evt_tx: Sender, + gui_tx: Sender<(u32, ToGuiMessage)>, + bridge_tx: Sender<(u32, crate::modules::plugin_host::FramebufferGuiBridge)>, +) { + let host = cpal::default_host(); + + let out_resolved = match resolve_output(&host, &config.output_device) { + Some(d) => d, + None => { + let _ = evt_tx.send(EngineEvent::Error("no audio output device available".into())); + return; + } + }; + + if out_resolved.was_fallback { + let _ = evt_tx.send(EngineEvent::Error(format!( + "output device '{}' not found, using '{}'", + config.output_device, out_resolved.name + ))); + } + + let output_rate = negotiate_rate(&out_resolved.device, config.sample_rate, false); + if output_rate != config.sample_rate { + let _ = evt_tx.send(EngineEvent::Error(format!( + "output device '{}' does not support {}Hz, using {}Hz", + out_resolved.name, config.sample_rate, output_rate + ))); + } + + let output_config = cpal::StreamConfig { + channels: 2, + sample_rate: cpal::SampleRate(output_rate), + buffer_size: cpal::BufferSize::Fixed(config.output_buffer_size), + }; + + let _ = evt_tx.send(EngineEvent::AudioConfigResolved { + output_device: out_resolved.name.clone(), + input_device: String::new(), + sample_rate: output_rate, + }); + + let running = Arc::new(AtomicBool::new(true)); + let running_flag = running.clone(); + + let processor = Arc::new(Mutex::new(CycleProcessor::new( + output_rate, + config.output_buffer_size, + config.hilbert_fft_size, + cmd_rx, + evt_tx.clone(), + gui_tx, + bridge_tx, + running_flag, + ))); + + let ring_size = config.input_buffer_size.max(config.output_buffer_size) as usize * 2 * 4; + let rb = HeapRb::::new(ring_size); + let (mut producer, mut consumer) = rb.split(); + + let mut actual_input_rate = output_rate; + let _input_stream = if let Some(in_resolved) = resolve_input(&host, &config.input_device) { + if in_resolved.was_fallback { + let _ = evt_tx.send(EngineEvent::Error(format!( + "input device '{}' not found, using '{}'", + config.input_device, in_resolved.name + ))); + } + + let input_rate = negotiate_rate(&in_resolved.device, output_rate, true); + actual_input_rate = input_rate; + if input_rate != output_rate { + let _ = evt_tx.send(EngineEvent::Error(format!( + "input device '{}' at {}Hz, resampling to output {}Hz", + in_resolved.name, input_rate, output_rate + ))); + } + + let input_config = cpal::StreamConfig { + channels: 2, + sample_rate: cpal::SampleRate(input_rate), + buffer_size: cpal::BufferSize::Fixed(config.input_buffer_size), + }; + + let _ = evt_tx.send(EngineEvent::AudioConfigResolved { + output_device: out_resolved.name.clone(), + input_device: in_resolved.name.clone(), + sample_rate: output_rate, + }); + + let input_err_tx = evt_tx.clone(); + + match in_resolved.device.build_input_stream( + &input_config, + move |data: &[f32], _info: &cpal::InputCallbackInfo| { + let _ = producer.push_slice(data); + }, + move |err| { + let _ = input_err_tx.send(EngineEvent::Error(format!("cpal input error: {}", err))); + }, + None, + ) { + Ok(stream) => { + if let Err(e) = stream.play() { + let _ = evt_tx.send(EngineEvent::Error(format!("failed to start input: {}", e))); + } + Some(stream) + } + Err(e) => { + let _ = evt_tx.send(EngineEvent::Error(format!("failed to build input stream: {}", e))); + None + } + } + } else { + None + }; + + let proc_clone = Arc::clone(&processor); + let err_tx = evt_tx.clone(); + + let mut input_resampler = if actual_input_rate != output_rate { + IoResampler::new(actual_input_rate, output_rate, config.output_buffer_size as usize) + } else { + None + }; + + let input_buf_size = if input_resampler.is_some() { + let ratio = actual_input_rate as f64 / output_rate as f64; + (config.output_buffer_size as f64 * 2.0 * ratio).ceil() as usize + 4 + } else { + config.output_buffer_size as usize * 2 + }; + let mut input_buf = vec![0.0f32; input_buf_size]; + let mut resampled_buf = vec![0.0f32; config.output_buffer_size as usize * 2]; + + let stream = out_resolved.device.build_output_stream( + &output_config, + move |output: &mut [f32], _info: &cpal::OutputCallbackInfo| { + for s in input_buf.iter_mut() { + *s = 0.0; + } + consumer.pop_slice(&mut input_buf); + + if let Some(ref mut resampler) = input_resampler { + let frames_out = output.len() / 2; + let resampled = resampler.process_interleaved(&input_buf, frames_out); + let len = resampled_buf.len().min(resampled.len()); + resampled_buf[..len].copy_from_slice(&resampled[..len]); + + if let Ok(mut proc) = proc_clone.lock() { + proc.process_cycle(&resampled_buf, output); + } else { + output.fill(0.0); + } + } else if let Ok(mut proc) = proc_clone.lock() { + proc.process_cycle(&input_buf, output); + } else { + output.fill(0.0); + } + }, + move |err| { + let _ = err_tx.send(EngineEvent::Error(format!("cpal stream error: {}", err))); + }, + None, + ); + + match stream { + Ok(stream) => { + if let Err(e) = stream.play() { + let _ = evt_tx.send(EngineEvent::Error(format!("failed to start stream: {}", e))); + return; + } + + while running.load(Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + + drop(stream); + } + Err(e) => { + let _ = evt_tx.send(EngineEvent::Error(format!("failed to build stream: {}", e))); + } + } +} diff --git a/au-o2-gui/src/engine/lane.rs b/au-o2-gui/src/engine/lane.rs new file mode 100644 index 0000000..cfba595 --- /dev/null +++ b/au-o2-gui/src/engine/lane.rs @@ -0,0 +1,127 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use rustfft::num_complex::Complex; +use rustfft::{Fft, FftPlanner}; + +pub struct FftPlanCache { + plans: HashMap>, Arc>)>, +} + +impl FftPlanCache { + pub fn new() -> Self { + Self { plans: HashMap::new() } + } + + pub fn get_or_create(&mut self, fft_size: usize) -> (Arc>, Arc>) { + self.plans.entry(fft_size).or_insert_with(|| { + let mut planner = FftPlanner::new(); + let forward = planner.plan_fft_forward(fft_size); + let inverse = planner.plan_fft_inverse(fft_size); + (forward, inverse) + }).clone() + } +} + +pub struct Lane { + real: Vec, + analytic: Vec<(f32, f32)>, + fft_size: usize, + history: Vec, + forward: Arc>, + inverse: Arc>, + fft_buf: Vec>, + ifft_buf: Vec>, + hop_size: usize, +} + +impl Lane { + pub fn new(buffer_size: usize, fft_size: usize, plan_cache: &mut FftPlanCache) -> Self { + let (forward, inverse) = plan_cache.get_or_create(fft_size); + Self { + real: vec![0.0; buffer_size], + analytic: vec![(0.0, 0.0); buffer_size], + fft_size, + history: vec![0.0; fft_size], + forward, + inverse, + fft_buf: vec![Complex::new(0.0, 0.0); fft_size], + ifft_buf: vec![Complex::new(0.0, 0.0); fft_size], + hop_size: 0, + } + } + + pub fn real(&self) -> &[f32] { + &self.real + } + + pub fn real_mut(&mut self) -> &mut [f32] { + &mut self.real + } + + pub fn analytic(&self) -> &[(f32, f32)] { + &self.analytic + } + + pub fn write_real(&mut self, data: &[f32]) { + let len = data.len().min(self.real.len()); + self.real[..len].copy_from_slice(&data[..len]); + self.compute_hilbert(len); + } + + pub fn clear(&mut self) { + for s in &mut self.real { *s = 0.0; } + for s in &mut self.analytic { *s = (0.0, 0.0); } + } + + pub fn set_fft_size(&mut self, size: usize, plan_cache: &mut FftPlanCache) { + if size == self.fft_size { return; } + let (forward, inverse) = plan_cache.get_or_create(size); + self.fft_size = size; + self.forward = forward; + self.inverse = inverse; + self.history = vec![0.0; size]; + self.fft_buf = vec![Complex::new(0.0, 0.0); size]; + self.ifft_buf = vec![Complex::new(0.0, 0.0); size]; + self.hop_size = 0; + } + + fn compute_hilbert(&mut self, sample_count: usize) { + if sample_count == 0 { return; } + + if self.hop_size == 0 { + self.hop_size = sample_count; + } + + let hop = &self.real[..sample_count]; + + self.history.copy_within(sample_count.., 0); + self.history[self.fft_size - sample_count..].copy_from_slice(hop); + + for (i, &s) in self.history.iter().enumerate() { + self.fft_buf[i] = Complex::new(s, 0.0); + } + self.forward.process(&mut self.fft_buf); + + let n = self.fft_size; + let nyquist = n / 2; + // DC bin: unchanged + for i in 1..nyquist { + self.fft_buf[i] *= 2.0; + } + // Nyquist bin: unchanged + for i in (nyquist + 1)..n { + self.fft_buf[i] = Complex::new(0.0, 0.0); + } + + self.ifft_buf.copy_from_slice(&self.fft_buf); + self.inverse.process(&mut self.ifft_buf); + + let norm = 1.0 / n as f32; + let offset = n - sample_count; + for i in 0..sample_count { + let c = self.ifft_buf[offset + i]; + self.analytic[i] = (c.re * norm, c.im * norm); + } + } +} diff --git a/au-o2-gui/src/engine/mod.rs b/au-o2-gui/src/engine/mod.rs new file mode 100644 index 0000000..5061559 --- /dev/null +++ b/au-o2-gui/src/engine/mod.rs @@ -0,0 +1,241 @@ +pub mod ara; +pub mod atmos; +pub mod bus; +pub mod contract; +pub mod cycle; +pub mod device; +pub mod graph; +pub mod host; +pub mod io; +pub mod lane; +pub mod onset; +pub mod param; +pub mod spatial; +pub mod stems; +pub mod recorder; +pub mod resample; +pub mod schedule; +pub mod session_player; + +use crossbeam_channel::{Receiver, Sender, unbounded}; +use oxforge::mdk::ToGuiMessage; +use std::path::PathBuf; +use crate::modules::plugin_host::FramebufferGuiBridge; +use crate::timing::MusicalTime; +use atmos::ObjectPosition; + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum EngineCommand { + Shutdown, + SetTransportState(TransportState), + CreateBus { name: String, is_midi: bool }, + RemoveBus { name: String }, + SetParam { module_id: u32, key: String, value: f32 }, + ArmTrack { bus_name: String }, + DisarmTrack { bus_name: String }, + LoadModuleOnBus { bus_name: String, module_type: String, chain_position: usize }, + UnloadModule { module_id: u32 }, + SetModuleDisabled { module_id: u32, disabled: bool }, + SetModuleChainPosition { module_id: u32, bus_name: String, chain_position: usize }, + SetHilbertFftSize { size: usize }, + StartRecording { project_path: PathBuf, sample_rate: u32, bit_depth: u16, fft_size: u32 }, + StopRecording, + LoadRegionAudio { bus_name: String, region_id: uuid::Uuid, start_sample: u64, audio_l: Vec, audio_r: Vec, fade_in_samples: u64, fade_out_samples: u64 }, + UnloadRegionAudio { region_id: uuid::Uuid }, + SetRegionFade { region_id: uuid::Uuid, fade_in_samples: u64, fade_out_samples: u64 }, + SetBusVolume { bus_name: String, volume: f32 }, + SetBusPan { bus_name: String, pan: f32 }, + SetBusMute { bus_name: String, muted: bool }, + SetBusSolo { bus_name: String, soloed: bool }, + SetCycleState { enabled: bool, start_sample: u64, end_sample: u64 }, + SetMetronomeEnabled(bool), + SetMetronomeVolume(f32), + Seek { sample_pos: u64 }, + SetMasterVolume(f32), + SetMasterPan(f32), + SetTempoCurve { points: Vec<(u64, f32)> }, + LoadMidiRegion { bus_name: String, region_id: uuid::Uuid, start_beat: f64, notes: Vec }, + UnloadMidiRegion { region_id: uuid::Uuid }, + SetCountIn { bars: u32 }, + SetPunch { enabled: bool, start_sample: u64, end_sample: u64 }, + SetSend { source_bus: String, aux_bus: String, level: f32 }, + RemoveSend { source_bus: String, aux_bus: String }, + SetAutomationData { + bus_name: String, + target: AutomationTarget, + points: Vec<(u64, f32)>, + }, + SetAutomationMode { + bus_name: String, + mode: AutomationModeFlag, + }, + LoadDynamicPlugin { + bus_name: String, + plugin_path: PathBuf, + chain_position: usize, + }, + QueryModuleParams { module_id: u32 }, + QueryModuleGuiDescriptor { module_id: u32 }, + ScanPlugins, + SetSpatialMode(atmos::SpatialRenderMode), + SetMonoLane(atmos::MonoLane), + SetObjectPosition { bus_name: String, position: ObjectPosition }, + AttachModuleGuiFence { + module_id: u32, + fence: oxforge::mdk::gui::AudioFenceHandle, + }, + DetachModuleGuiFence { + module_id: u32, + }, +} + +/// Lightweight copy of AutomationTarget for engine-side use (no serde dep needed) +#[derive(Debug, Clone, PartialEq)] +pub enum AutomationTarget { + Volume, + Pan, + Mute, + ModuleParam { module_id: u32, key: String }, +} + +/// Engine-side automation mode flag +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AutomationModeFlag { + Off, + Read, + Write, + Touch, + Latch, +} + +impl AutomationModeFlag { + pub fn reads(self) -> bool { + matches!(self, Self::Read | Self::Touch | Self::Latch) + } + + pub fn writes(self) -> bool { + matches!(self, Self::Write | Self::Touch | Self::Latch) + } +} + +#[derive(Debug, Clone)] +pub enum EngineEvent { + TransportPosition(MusicalTime), + Error(String), + BusCreated, + GraphRebuilt, + ModuleLoaded { bus_name: String, module_id: u32, module_type: String, plugin_name: Option, has_gui: bool, gui_descriptor: Option }, + ContractViolation { module_id: u32, module_name: String, avg_ns: u64, budget_ns: u64 }, + BufferAutoIncreased { new_size: usize, latency_ms: f32, reason: String }, + BufferNegotiation { module_id: u32, required_samples: usize, required_ms: f32, current_samples: usize, current_ms: f32 }, + ModuleDisabled { module_id: u32, reason: String }, + RecordingComplete { bus_name: String, file_path: String, start_sample: u64, length_samples: u64, start_time: MusicalTime, duration: MusicalTime }, + TakeRecordingComplete { + bus_name: String, + takes: Vec, + }, + AudioConfigResolved { output_device: String, input_device: String, sample_rate: u32 }, + PluginsDiscovered { plugins: Vec }, + MeterUpdate { bus_peaks: Vec<(String, f32, f32)>, master_peak: (f32, f32) }, + ModuleParamDescriptors { module_id: u32, descriptors: Vec }, + ModuleParamChanged { module_id: u32, key: String, value: f32 }, + ModuleGuiDescriptorReady { module_id: u32, descriptor: Option }, + ModuleGuiReady, + #[allow(dead_code)] // fields read by GUI event handler + ModuleErrorReport { + module_id: u32, + module_name: String, + error_kind: String, + message: String, + }, +} + +#[derive(Debug, Clone)] +pub struct TakeRecording { + pub file_path: String, + pub start_sample: u64, + pub length_samples: u64, + pub start_time: MusicalTime, + pub duration: MusicalTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportState { + Playing, + Stopped, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] // fields populated from config for planned engine features +pub struct EngineConfig { + pub sample_rate: u32, + pub output_buffer_size: u32, + pub input_buffer_size: u32, + pub output_device: String, + pub input_device: String, + pub auto_oversample: bool, + pub auto_undersample: bool, + pub hilbert_fft_size: usize, +} + +pub struct EngineHandle { + cmd_tx: Sender, + evt_rx: Receiver, + gui_rx: Receiver<(u32, ToGuiMessage)>, + bridge_rx: Receiver<(u32, FramebufferGuiBridge)>, +} + +impl EngineHandle { + pub fn spawn(config: &EngineConfig) -> Self { + let (cmd_tx, cmd_rx) = unbounded(); + let (evt_tx, evt_rx) = unbounded(); + let (gui_tx, gui_rx) = unbounded::<(u32, ToGuiMessage)>(); + let (bridge_tx, bridge_rx) = unbounded::<(u32, FramebufferGuiBridge)>(); + + let cfg = config.clone(); + + std::thread::Builder::new() + .name("audio-engine".into()) + .spawn(move || { + io::run_audio(&cfg, cmd_rx, evt_tx, gui_tx, bridge_tx); + }) + .expect("failed to spawn engine thread"); + + Self { cmd_tx, evt_rx, gui_rx, bridge_rx } + } + + pub fn send(&self, cmd: EngineCommand) { + let _ = self.cmd_tx.send(cmd); + } + + pub fn poll_events(&self) -> Vec { + let mut events = Vec::new(); + while let Ok(evt) = self.evt_rx.try_recv() { + events.push(evt); + } + events + } + + pub fn poll_gui_messages(&self) -> Vec<(u32, ToGuiMessage)> { + let mut messages = Vec::new(); + while let Ok(msg) = self.gui_rx.try_recv() { + messages.push(msg); + } + messages + } + + pub fn poll_bridges(&self) -> Vec<(u32, FramebufferGuiBridge)> { + let mut bridges = Vec::new(); + while let Ok(b) = self.bridge_rx.try_recv() { + bridges.push(b); + } + bridges + } +} + +impl Drop for EngineHandle { + fn drop(&mut self) { + let _ = self.cmd_tx.send(EngineCommand::Shutdown); + } +} diff --git a/au-o2-gui/src/engine/onset.rs b/au-o2-gui/src/engine/onset.rs new file mode 100644 index 0000000..4c799c2 --- /dev/null +++ b/au-o2-gui/src/engine/onset.rs @@ -0,0 +1,235 @@ +/// Energy-based onset detection for audio quantize. +/// Returns sample positions of detected transients. +pub fn detect_onsets(left: &[f32], right: &[f32], sample_rate: u32) -> Vec { + let n = left.len().min(right.len()); + if n < 2 { + return Vec::new(); + } + + // Window size ~10ms, hop size ~5ms + let window = (sample_rate as usize / 100).max(64); + let hop = window / 2; + + // Compute short-term energy per window + let num_frames = (n.saturating_sub(window)) / hop + 1; + if num_frames < 3 { + return Vec::new(); + } + + let mut energies = Vec::with_capacity(num_frames); + for i in 0..num_frames { + let start = i * hop; + let end = (start + window).min(n); + let mut energy = 0.0f64; + for j in start..end { + let mono = (left[j] + right[j]) as f64 * 0.5; + energy += mono * mono; + } + energies.push(energy / (end - start) as f64); + } + + // Spectral flux: positive energy difference between consecutive frames + let mut flux = Vec::with_capacity(num_frames); + flux.push(0.0); + for i in 1..energies.len() { + let diff = energies[i] - energies[i - 1]; + flux.push(if diff > 0.0 { diff } else { 0.0 }); + } + + // Adaptive threshold: median of local neighborhood + offset + let neighborhood = 10; + let threshold_factor = 1.5; + let min_energy = 1e-8; + + let mut onsets = Vec::new(); + let min_gap = (sample_rate as usize / 10).max(hop); // min 100ms between onsets + + let mut last_onset: Option = None; + + for i in 1..flux.len() { + let lo = if i > neighborhood { i - neighborhood } else { 0 }; + let hi = (i + neighborhood + 1).min(flux.len()); + let mut local: Vec = flux[lo..hi].to_vec(); + local.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let median = local[local.len() / 2]; + let threshold = median * threshold_factor + min_energy; + + if flux[i] > threshold && energies[i] > min_energy { + let sample_pos = i * hop; + if let Some(last) = last_onset { + if sample_pos - last < min_gap { + continue; + } + } + onsets.push(sample_pos); + last_onset = Some(sample_pos); + } + } + + onsets +} + +/// Estimate tempo (BPM) from audio using onset detection + inter-onset interval histogram. +/// Returns None if insufficient onsets for reliable estimation. +/// BPM range: 60-200 (doubles/halves IOIs outside this range to fit). +pub fn estimate_tempo(left: &[f32], right: &[f32], sample_rate: u32) -> Option { + let onsets = detect_onsets(left, right, sample_rate); + if onsets.len() < 4 { + return None; + } + + // Compute inter-onset intervals in seconds + let mut iois: Vec = Vec::with_capacity(onsets.len() - 1); + for i in 1..onsets.len() { + let diff = (onsets[i] - onsets[i - 1]) as f64 / sample_rate as f64; + if diff > 0.05 { // ignore intervals < 50ms (likely detection artifacts) + iois.push(diff); + } + } + + if iois.len() < 3 { + return None; + } + + // Histogram approach: bin IOIs into tempo candidates + // BPM range 60-200 → period range 0.3s - 1.0s + let min_period = 0.3; + let max_period = 1.0; + let num_bins = 140; // 1 bin per BPM in 60-200 range + let mut histogram = vec![0u32; num_bins]; + + for &ioi in &iois { + // Normalize IOI to 60-200 BPM range via doubling/halving + let mut period = ioi; + while period < min_period && period > 0.01 { + period *= 2.0; + } + while period > max_period { + period *= 0.5; + } + if period < min_period || period > max_period { + continue; + } + let bpm = 60.0 / period; + let bin = ((bpm - 60.0) as usize).min(num_bins - 1); + histogram[bin] += 1; + + // Also vote for nearby bins (±2 BPM tolerance) + if bin > 0 { histogram[bin - 1] += 1; } + if bin > 1 { histogram[bin - 2] += 1; } + if bin + 1 < num_bins { histogram[bin + 1] += 1; } + if bin + 2 < num_bins { histogram[bin + 2] += 1; } + } + + // Find peak bin + let (peak_bin, peak_count) = histogram.iter().enumerate().max_by_key(|(_, v)| **v)?; + let peak_count = *peak_count; + if peak_count < 3 { + return None; + } + + // Refine: weighted average of IOIs that fall near the peak BPM + let peak_bpm = peak_bin as f64 + 60.0; + let peak_period = 60.0 / peak_bpm; + let tolerance = peak_period * 0.08; // 8% tolerance + + let mut sum = 0.0f64; + let mut count = 0u32; + for &ioi in &iois { + let mut period = ioi; + while period < min_period && period > 0.01 { + period *= 2.0; + } + while period > max_period { + period *= 0.5; + } + if (period - peak_period).abs() < tolerance { + sum += period; + count += 1; + } + } + + if count == 0 { + return Some(peak_bpm as f32); + } + + let avg_period = sum / count as f64; + let refined_bpm = 60.0 / avg_period; + Some(refined_bpm as f32) +} + +/// Estimate tempo with variable tempo detection. +/// Returns a list of (sample_pos, tempo) pairs for tempo map construction. +/// Uses a sliding window approach to detect tempo changes over time. +pub fn estimate_tempo_curve( + left: &[f32], + right: &[f32], + sample_rate: u32, + window_seconds: f64, +) -> Vec<(u64, f32)> { + let onsets = detect_onsets(left, right, sample_rate); + if onsets.len() < 8 { + // Not enough onsets for curve detection, try single tempo + if let Some(bpm) = estimate_tempo(left, right, sample_rate) { + return vec![(0, bpm)]; + } + return Vec::new(); + } + + let window_samples = (window_seconds * sample_rate as f64) as usize; + let hop_samples = window_samples / 2; + let total_samples = left.len().min(right.len()); + + let mut curve: Vec<(u64, f32)> = Vec::new(); + let mut pos = 0usize; + + while pos < total_samples { + let win_end = (pos + window_samples).min(total_samples); + + // Collect onsets within this window + let window_onsets: Vec = onsets.iter() + .filter(|&&o| o >= pos && o < win_end) + .copied() + .collect(); + + if window_onsets.len() >= 3 { + // Compute IOIs within window + let mut iois: Vec = Vec::new(); + for i in 1..window_onsets.len() { + let diff = (window_onsets[i] - window_onsets[i - 1]) as f64 / sample_rate as f64; + if diff > 0.05 { + iois.push(diff); + } + } + + if iois.len() >= 2 { + // Median IOI → BPM + iois.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let median_ioi = iois[iois.len() / 2]; + + // Normalize to 60-200 BPM range + let mut period = median_ioi; + while period < 0.3 && period > 0.01 { period *= 2.0; } + while period > 1.0 { period *= 0.5; } + + if period >= 0.3 && period <= 1.0 { + let bpm = (60.0 / period) as f32; + let sample_pos = pos as u64; + + // Only add if tempo changed significantly from last point + if let Some(last) = curve.last() { + if (bpm - last.1).abs() > 2.0 { + curve.push((sample_pos, bpm)); + } + } else { + curve.push((sample_pos, bpm)); + } + } + } + } + + pos += hop_samples; + } + + curve +} diff --git a/au-o2-gui/src/engine/param.rs b/au-o2-gui/src/engine/param.rs new file mode 100644 index 0000000..d0f2b98 --- /dev/null +++ b/au-o2-gui/src/engine/param.rs @@ -0,0 +1,42 @@ +use std::collections::HashMap; + +use super::host::ModuleHost; + +#[derive(Debug, Clone)] +pub struct ParamChange { + pub module_id: u32, + pub key: String, + pub value: f32, +} + +pub struct ParamEngine { + pending: Vec, + params: HashMap>, +} + +impl ParamEngine { + pub fn new() -> Self { + Self { + pending: Vec::new(), + params: HashMap::new(), + } + } + + pub fn queue_change(&mut self, module_id: u32, key: String, value: f32) { + self.pending.push(ParamChange { module_id, key, value }); + } + + pub fn apply_pending(&mut self, host: &mut ModuleHost) { + for change in self.pending.drain(..) { + self.params + .entry(change.module_id) + .or_default() + .insert(change.key.clone(), change.value); + host.set_param(change.module_id, &change.key, change.value); + } + } + + pub fn get_params(&self, module_id: u32) -> Option<&HashMap> { + self.params.get(&module_id) + } +} diff --git a/au-o2-gui/src/engine/recorder.rs b/au-o2-gui/src/engine/recorder.rs new file mode 100644 index 0000000..2a9be30 --- /dev/null +++ b/au-o2-gui/src/engine/recorder.rs @@ -0,0 +1,269 @@ +use std::collections::HashMap; + +use crossbeam_channel::{Receiver, Sender}; + +use crate::codec::XtcEncoder; +use crate::timing::MusicalTime; +use super::{EngineEvent, TakeRecording}; + +pub use oxforge::mdk::RecorderMessage; + +pub fn spawn_recorder( + evt_tx: Sender, +) -> Sender { + let (tx, rx) = crossbeam_channel::unbounded(); + + std::thread::Builder::new() + .name("recorder".into()) + .spawn(move || { + recorder_thread(rx, evt_tx); + }) + .expect("failed to spawn recorder thread"); + + tx +} + +struct RecordingBuffer { + real_l: Vec, + real_r: Vec, + imag_l: Vec, + imag_r: Vec, +} + +impl RecordingBuffer { + fn new() -> Self { + Self { + real_l: Vec::with_capacity(48000 * 60), + real_r: Vec::with_capacity(48000 * 60), + imag_l: Vec::with_capacity(48000 * 60), + imag_r: Vec::with_capacity(48000 * 60), + } + } + + fn push(&mut self, real_l: &[f32], real_r: &[f32], imag_l: &[f32], imag_r: &[f32]) { + self.real_l.extend_from_slice(real_l); + self.real_r.extend_from_slice(real_r); + self.imag_l.extend_from_slice(imag_l); + self.imag_r.extend_from_slice(imag_r); + } + + fn is_empty(&self) -> bool { + self.real_l.is_empty() + } + + fn len(&self) -> usize { + self.real_l.len() + } +} + +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect() +} + +fn encode_buffer( + encoder: &XtcEncoder, + audio_dir: &std::path::Path, + safe_name: &str, + timestamp: u64, + take_num: usize, + buf: &RecordingBuffer, +) -> Result { + let suffix = if take_num > 0 { + format!("{}_{}_t{}.xtc", safe_name, timestamp, take_num) + } else { + format!("{}_{}.xtc", safe_name, timestamp) + }; + let file_path = audio_dir.join(&suffix); + let relative_path = format!("audio/{}", suffix); + + encoder.encode_to_file( + &file_path, + &buf.real_l, + &buf.real_r, + &buf.imag_l, + &buf.imag_r, + ).map_err(|e| format!("encode error: {:?}", e))?; + + Ok(relative_path) +} + +struct BusRecording { + current: RecordingBuffer, + takes: Vec, + boundary_samples: Vec, +} + +impl BusRecording { + fn new() -> Self { + Self { + current: RecordingBuffer::new(), + takes: Vec::new(), + boundary_samples: Vec::new(), + } + } + + fn push(&mut self, real_l: &[f32], real_r: &[f32], imag_l: &[f32], imag_r: &[f32]) { + self.current.push(real_l, real_r, imag_l, imag_r); + } + + fn split_at_boundary(&mut self, _boundary_sample: u64) { + let prev = std::mem::replace(&mut self.current, RecordingBuffer::new()); + if !prev.is_empty() { + self.takes.push(prev); + } + } + + fn finalize(mut self) -> Vec { + if !self.current.is_empty() { + self.takes.push(self.current); + } + self.takes + } + + fn has_boundaries(&self) -> bool { + !self.boundary_samples.is_empty() + } +} + +fn recorder_thread(rx: Receiver, evt_tx: Sender) { + let mut recordings: HashMap = HashMap::new(); + let mut chunk_count: u64 = 0; + let mut _boundary_count: u32 = 0; + + loop { + match rx.recv() { + Ok(RecorderMessage::Chunk { bus_name, real_l, real_r, imag_l, imag_r }) => { + let _samples = real_l.len(); + let rec = recordings.entry(bus_name).or_insert_with(BusRecording::new); + rec.push(&real_l, &real_r, &imag_l, &imag_r); + chunk_count += 1; + if chunk_count <= 3 || chunk_count % 500 == 0 { + debug_log!("[recorder] chunk #{}: {} samples, total={}", + chunk_count, _samples, rec.current.len()); + } + } + Ok(RecorderMessage::CycleBoundary { boundary_sample }) => { + _boundary_count += 1; + debug_log!("[recorder] cycle boundary #{} at sample {}", + boundary_count, boundary_sample); + for rec in recordings.values_mut() { + rec.boundary_samples.push(boundary_sample); + rec.split_at_boundary(boundary_sample); + } + } + Ok(RecorderMessage::Finish { + project_path, sample_rate, bit_depth, fft_size, + start_sample, tempo, time_sig_num, + }) => { + debug_log!("[recorder] finish: {} buses, {} chunks, {} boundaries", + recordings.len(), chunk_count, boundary_count); + + let audio_dir = project_path.join("audio"); + if let Err(e) = std::fs::create_dir_all(&audio_dir) { + let _ = evt_tx.send(EngineEvent::Error( + format!("failed to create audio dir: {}", e), + )); + return; + } + + let encoder = XtcEncoder::new(sample_rate, bit_depth, fft_size); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + for (bus_name, rec) in recordings { + let has_takes = rec.has_boundaries(); + let boundary_samples = rec.boundary_samples.clone(); + let takes = rec.finalize(); + + if takes.is_empty() { + debug_log!("[recorder] bus '{}': empty, skipping", bus_name); + continue; + } + + let safe_name = sanitize_filename(&bus_name); + + if !has_takes || takes.len() == 1 { + // Single recording — use original event + let buf = &takes[0]; + match encode_buffer(&encoder, &audio_dir, &safe_name, timestamp, 0, buf) { + Ok(relative_path) => { + let length_samples = buf.len() as u64; + let start_time = MusicalTime::from_samples( + start_sample, tempo, sample_rate, time_sig_num as u32, + ); + let end_sample = start_sample + length_samples; + let end_time = MusicalTime::from_samples( + end_sample, tempo, sample_rate, time_sig_num as u32, + ); + let duration = end_time - start_time; + debug_log!("[recorder] single: {} samples", length_samples); + let _ = evt_tx.send(EngineEvent::RecordingComplete { + bus_name: bus_name.clone(), + file_path: relative_path, + start_sample, + length_samples, + start_time, + duration, + }); + } + Err(e) => { + let _ = evt_tx.send(EngineEvent::Error(e)); + } + } + } else { + // Multiple takes from cycle recording + let cycle_start = start_sample; + let _cycle_len = boundary_samples.first() + .map(|&b| b - start_sample) + .unwrap_or(0); + + let mut take_recordings = Vec::new(); + for (i, buf) in takes.iter().enumerate() { + if buf.is_empty() { continue; } + let take_start = cycle_start; + match encode_buffer(&encoder, &audio_dir, &safe_name, timestamp, i + 1, buf) { + Ok(relative_path) => { + let length_samples = buf.len() as u64; + let start_time = MusicalTime::from_samples( + take_start, tempo, sample_rate, time_sig_num as u32, + ); + let end_time = MusicalTime::from_samples( + take_start + length_samples, tempo, sample_rate, time_sig_num as u32, + ); + let duration = end_time - start_time; + debug_log!("[recorder] take {}: {} samples", i + 1, length_samples); + take_recordings.push(TakeRecording { + file_path: relative_path, + start_sample: take_start, + length_samples, + start_time, + duration, + }); + } + Err(e) => { + let _ = evt_tx.send(EngineEvent::Error(e)); + } + } + } + if !take_recordings.is_empty() { + let _ = evt_tx.send(EngineEvent::TakeRecordingComplete { + bus_name: bus_name.clone(), + takes: take_recordings, + }); + } + } + } + + return; + } + Err(_) => { + debug_log!("[recorder] channel disconnected"); + return; + } + } + } +} diff --git a/au-o2-gui/src/engine/resample.rs b/au-o2-gui/src/engine/resample.rs new file mode 100644 index 0000000..93214f0 --- /dev/null +++ b/au-o2-gui/src/engine/resample.rs @@ -0,0 +1,135 @@ +use rubato::{FftFixedInOut, Resampler, SincFixedOut, SincInterpolationParameters, SincInterpolationType, WindowFunction}; + +/// Streaming stereo resampler for the CPAL I/O boundary. +/// Wraps rubato's SincFixedOut to produce a fixed number of output frames +/// per call, consuming a variable number of input frames. +pub struct IoResampler { + resampler: SincFixedOut, + input_buf_l: Vec, + input_buf_r: Vec, +} + +impl IoResampler { + pub fn new(from_rate: u32, to_rate: u32, chunk_frames: usize) -> Option { + if from_rate == to_rate { + return None; + } + let params = SincInterpolationParameters { + sinc_len: 64, + f_cutoff: 0.95, + interpolation: SincInterpolationType::Linear, + oversampling_factor: 128, + window: WindowFunction::BlackmanHarris2, + }; + let resampler = SincFixedOut::::new( + to_rate as f64 / from_rate as f64, + 1.0, + params, + chunk_frames, + 2, + ).ok()?; + Some(Self { + resampler, + input_buf_l: Vec::new(), + input_buf_r: Vec::new(), + }) + } + + /// Resample interleaved stereo input, producing exactly `output_frames` frames + /// of interleaved stereo output. Returns the output buffer. + pub fn process_interleaved(&mut self, input: &[f32], output_frames: usize) -> Vec { + let in_frames = input.len() / 2; + self.input_buf_l.clear(); + self.input_buf_r.clear(); + self.input_buf_l.reserve(in_frames); + self.input_buf_r.reserve(in_frames); + for i in 0..in_frames { + self.input_buf_l.push(input[i * 2]); + self.input_buf_r.push(input[i * 2 + 1]); + } + + let needed = self.resampler.input_frames_next(); + // Pad if we have fewer frames than needed + self.input_buf_l.resize(needed, 0.0); + self.input_buf_r.resize(needed, 0.0); + + let channels_in = vec![self.input_buf_l.clone(), self.input_buf_r.clone()]; + match self.resampler.process(&channels_in, None) { + Ok(result) => { + let frames = result[0].len().min(output_frames); + let mut out = vec![0.0f32; output_frames * 2]; + for i in 0..frames { + out[i * 2] = result[0][i]; + out[i * 2 + 1] = result[1][i]; + } + out + } + Err(_) => vec![0.0f32; output_frames * 2], + } + } + +} + +/// Time-stretch stereo audio by a playback rate. +/// rate < 1.0 = slower (more samples), rate > 1.0 = faster (fewer samples). +/// Uses rubato FFT resampler — this is varispeed (pitch changes with speed). +pub fn stretch_stereo(left: &[f32], right: &[f32], rate: f32) -> (Vec, Vec) { + if (rate - 1.0).abs() < 0.001 || left.is_empty() { + return (left.to_vec(), right.to_vec()); + } + let stretched_l = resample_mono(left, 1000, (1000.0 / rate) as u32); + let stretched_r = resample_mono(right, 1000, (1000.0 / rate) as u32); + (stretched_l, stretched_r) +} + +pub fn resample_mono(input: &[f32], from_rate: u32, to_rate: u32) -> Vec { + if from_rate == to_rate || input.is_empty() { + return input.to_vec(); + } + + let chunk_size = 1024; + let mut resampler = match FftFixedInOut::::new( + from_rate as usize, + to_rate as usize, + chunk_size, + 1, + ) { + Ok(r) => r, + Err(_) => return input.to_vec(), + }; + + let frames_needed = resampler.input_frames_next(); + let output_frames = resampler.output_frames_next(); + + let ratio = to_rate as f64 / from_rate as f64; + let estimated = (input.len() as f64 * ratio) as usize + output_frames; + let mut output = Vec::with_capacity(estimated); + + let mut pos = 0; + while pos + frames_needed <= input.len() { + let chunk = vec![input[pos..pos + frames_needed].to_vec()]; + if let Ok(result) = resampler.process(&chunk, None) { + if let Some(ch) = result.first() { + output.extend_from_slice(ch); + } + } + pos += frames_needed; + } + + // Process remaining samples with zero-padding + if pos < input.len() { + let remaining = input.len() - pos; + let mut padded = vec![0.0f32; frames_needed]; + padded[..remaining].copy_from_slice(&input[pos..]); + let chunk = vec![padded]; + if let Ok(result) = resampler.process(&chunk, None) { + if let Some(ch) = result.first() { + let useful = (remaining as f64 * ratio).ceil() as usize; + let take = useful.min(ch.len()); + output.extend_from_slice(&ch[..take]); + } + } + } + + output +} diff --git a/au-o2-gui/src/engine/schedule.rs b/au-o2-gui/src/engine/schedule.rs new file mode 100644 index 0000000..0a58c61 --- /dev/null +++ b/au-o2-gui/src/engine/schedule.rs @@ -0,0 +1,360 @@ +use std::any::Any; +use std::collections::HashMap; +use std::panic::AssertUnwindSafe; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use crossbeam_channel::Sender; +use oxforge::mdk::{ + BusRef, ChainInput, ChainOutput, ErrorChannel, LaneRef, MainAudioInput, MainAudioOutput, + MidiEvent, MidiInput, MidiOutput, ModuleError, ErrorKind, + MusicalTime as MdkMusicalTime, PortContent, PortDeclaration, PortDirection, + PortView, Ports, ProcessContext, ToGuiQueue, + TransportState as MdkTransportState, + gui::AudioFenceHandle, +}; + +use super::bus::BusRegistry; +use super::contract::ContractEnforcer; +use super::graph::ProcessGraph; +use super::host::ModuleHost; +use super::param::ParamEngine; +use super::TransportState; + +pub struct CycleContext { + pub sample_pos: u64, + pub tempo: f32, + pub beat_pos: f64, + pub sample_rate: u32, + pub transport: TransportState, + pub time_sig_num: u8, + pub time_sig_den: u8, + pub cycle_active: bool, + pub cycle_start_sample: u64, + pub cycle_end_sample: u64, +} + +pub struct ModuleRouting { + pub module_id: u32, + pub input_bus: Option, + pub output_bus: Option, + pub bus_name: Option, + pub scratch_in: Vec, + pub scratch_out: Vec, + pub to_gui: ToGuiQueue, + pub disabled: bool, + pub errored: bool, + pub port_declarations: Vec, + pub gui_fence: Option, +} + +pub struct CycleSchedule { + entries: Vec, +} + +impl CycleSchedule { + pub fn new() -> Self { + Self { + entries: Vec::new(), + } + } + + pub fn entries_mut(&mut self) -> &mut Vec { + &mut self.entries + } + + pub fn from_graph( + order: &[u32], + graph: &ProcessGraph, + bus_registry: &BusRegistry, + module_host: &ModuleHost, + ) -> Self { + let mut entries = Vec::new(); + + for &module_id in order { + if let Some(node) = graph.get_node(module_id) { + let input_bus = node.reads.iter().next().cloned(); + let output_bus = node.writes.iter().next().cloned(); + + let buf_size = input_bus.as_ref() + .and_then(|name| bus_registry.get_by_name(name)) + .or_else(|| output_bus.as_ref().and_then(|name| bus_registry.get_by_name(name))) + .map(|b| b.buffer_size() * b.channels()) + .unwrap_or_else(|| bus_registry.buffer_size() * bus_registry.channels()); + + let bus_name = node.chain_position.as_ref().map(|(b, _)| b.clone()) + .or_else(|| input_bus.clone()); + + let port_declarations = module_host.get(module_id) + .map(|m| m.port_declarations.clone()) + .unwrap_or_default(); + + entries.push(ModuleRouting { + module_id, + input_bus, + output_bus, + bus_name, + scratch_in: vec![0.0; buf_size], + scratch_out: vec![0.0; buf_size], + to_gui: ToGuiQueue::noop(), + disabled: false, + errored: false, + port_declarations, + gui_fence: None, + }); + } + } + + Self { entries } + } + + pub fn execute( + &mut self, + bus_registry: &mut BusRegistry, + module_host: &mut ModuleHost, + param_engine: &ParamEngine, + ctx: &CycleContext, + enforcer: &mut ContractEnforcer, + error_log_tx: &Sender, + error_report_tx: &Sender, + ) { + let mdk_transport = match ctx.transport { + TransportState::Playing => MdkTransportState::Playing, + TransportState::Stopped => MdkTransportState::Stopped, + }; + + let mdk_time = MdkMusicalTime { + sample_pos: ctx.sample_pos, + beat_pos: ctx.beat_pos, + tempo: ctx.tempo as f64, + time_signature_numerator: ctx.time_sig_num, + time_signature_denominator: ctx.time_sig_den, + state: mdk_transport, + cycle_active: ctx.cycle_active, + cycle_start_sample: ctx.cycle_start_sample, + cycle_end_sample: ctx.cycle_end_sample, + }; + + let mut prev_chain_data: Option> = None; + let mut prev_bus: Option = None; + + let mut snapshot_lanes: Vec<(Vec, Vec<(f32, f32)>)> = Vec::new(); + let mut extra_snapshots: Vec<(String, Vec<(Vec, Vec<(f32, f32)>)>)> = Vec::new(); + + let mut midi_port_data: HashMap> = HashMap::new(); + let mut midi_port_buffers: HashMap>>> = HashMap::new(); + + for routing in &mut self.entries { + if routing.disabled || routing.errored { + continue; + } + + if let Some(module) = module_host.get(routing.module_id) { + if module.disabled { + continue; + } + } + + // Load input from bus into scratch_in + if let Some(ref bus_name) = routing.input_bus { + if let Some(bus) = bus_registry.get_by_name(bus_name) { + let interleaved = bus.read_interleaved(); + let len = routing.scratch_in.len().min(interleaved.len()); + routing.scratch_in[..len].copy_from_slice(&interleaved[..len]); + } + } + + for s in &mut routing.scratch_out { + *s = 0.0; + } + + // Reset chain on bus boundary + let new_bus = routing.bus_name.is_some() && routing.bus_name != prev_bus; + if new_bus { + prev_chain_data = None; + } + + // Snapshot main bus lanes for PortView + snapshot_lanes.clear(); + if let Some(ref bus_name) = routing.input_bus { + if let Some(bus) = bus_registry.get_by_name(bus_name) { + for lane in bus.lanes() { + snapshot_lanes.push((lane.real().to_vec(), lane.analytic().to_vec())); + } + } + } + + // Snapshot extra buses from port_declarations + extra_snapshots.clear(); + for decl in &routing.port_declarations { + match (&decl.content, &decl.direction) { + (PortContent::Bus { .. }, PortDirection::Input) => { + if let Some(bus) = bus_registry.get_by_name(&decl.name) { + let lanes: Vec<_> = bus.lanes().iter() + .map(|l| (l.real().to_vec(), l.analytic().to_vec())) + .collect(); + extra_snapshots.push((decl.name.clone(), lanes)); + } + } + (PortContent::AllBuses, PortDirection::Input) => { + for name in bus_registry.bus_names() { + if name == "hw_input" || name == "hw_output" { continue; } + if let Some(bus) = bus_registry.get_by_name(&name) { + let lanes: Vec<_> = bus.lanes().iter() + .map(|l| (l.real().to_vec(), l.analytic().to_vec())) + .collect(); + extra_snapshots.push((name, lanes)); + } + } + } + _ => {} + } + } + + // Build PortView from snapshots + let mut port_view = PortView::new(); + + if !snapshot_lanes.is_empty() { + let lane_refs: Vec> = snapshot_lanes.iter() + .map(|(real, analytic)| LaneRef::new(real, analytic)) + .collect(); + port_view.add_bus_in("main".to_string(), BusRef::new(lane_refs)); + } + + for (name, lanes) in &extra_snapshots { + let lane_refs: Vec> = lanes.iter() + .map(|(real, analytic)| LaneRef::new(real, analytic)) + .collect(); + port_view.add_bus_in(name.clone(), BusRef::new(lane_refs)); + } + + let mut chain_out_box: Box = Box::new(()); + + let chain_in = prev_chain_data.as_deref().map(|data| ChainInput { data }); + let chain_out = Some(ChainOutput { data: &mut chain_out_box }); + + // Wire MIDI ports from port declarations + let mut midi_out_opt = None; + let mut midi_in_storage: Option> = None; + + for decl in &routing.port_declarations { + match (&decl.content, &decl.direction) { + (PortContent::Custom { type_name }, PortDirection::Output) if type_name == "midi" => { + let buffer = Arc::new(Mutex::new(Vec::new())); + midi_port_buffers.insert(decl.name.clone(), buffer.clone()); + midi_out_opt = Some(MidiOutput::new(decl.name.clone(), buffer)); + } + (PortContent::Custom { type_name }, PortDirection::Input) if type_name == "midi" => { + if let Some(events) = midi_port_data.get(&decl.name) { + midi_in_storage = Some(events.clone()); + } + } + _ => {} + } + } + + let midi_in_ref = midi_in_storage.as_deref().map(MidiInput::new); + + let ports = Ports { + main_audio_in: Some(MainAudioInput { + buffer: &routing.scratch_in, + }), + main_audio_out: Some(MainAudioOutput { + buffer: &mut routing.scratch_out, + }), + chain_in, + chain_out, + port: port_view, + midi_out: midi_out_opt, + midi_in: midi_in_ref, + }; + + let mut module_params = param_engine + .get_params(routing.module_id) + .cloned() + .unwrap_or_default(); + + if let Some(ref mut fence) = routing.gui_fence { + for (key, value) in fence.drain_changes() { + module_params.insert(key, value); + } + } + + let context = ProcessContext { + time: mdk_time, + params: module_params.clone(), + to_gui: routing.to_gui.clone(), + sample_rate: ctx.sample_rate, + error_log: ErrorChannel::new(error_log_tx.clone()), + error_report: ErrorChannel::new(error_report_tx.clone()), + }; + + let module_start = Instant::now(); + if let Some(module) = module_host.get_mut(routing.module_id) { + let result = std::panic::catch_unwind(AssertUnwindSafe(|| { + module.process(ports, &context); + })); + if let Err(panic_info) = result { + let msg = panic_payload_to_string(&panic_info); + routing.errored = true; + let _ = error_report_tx.send(ModuleError::new( + routing.module_id, + module.name.clone(), + ErrorKind::Panic, + msg, + )); + continue; + } + } + let elapsed_ns = module_start.elapsed().as_nanos() as u64; + + if let Some(ref fence) = routing.gui_fence { + for (key, value) in &module_params { + fence.write_readback(key, *value); + } + } + enforcer.record_timing(routing.module_id, elapsed_ns); + + // Collect MIDI events from output buffers + for (name, buffer) in midi_port_buffers.drain() { + let events = buffer.lock().unwrap(); + if !events.is_empty() { + midi_port_data.insert(name, events.clone()); + } + } + + if !chain_out_box.is::<()>() { + prev_chain_data = Some(chain_out_box); + } + prev_bus = routing.bus_name.clone(); + + // Immediate commit: write scratch_out to output bus + if let Some(ref bus_name) = routing.output_bus { + if let Some(bus) = bus_registry.get_mut_by_name(bus_name) { + let ch = bus.channels(); + if ch > 0 { + let frame_count = routing.scratch_out.len() / ch; + for frame in 0..frame_count { + for (c, lane) in bus.lanes_mut().iter_mut().enumerate() { + let real = lane.real_mut(); + if frame < real.len() { + real[frame] = routing.scratch_out[frame * ch + c]; + } + } + } + } + } + } + } + } +} + +fn panic_payload_to_string(payload: &Box) -> String { + if let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "unknown panic".to_string() + } +} diff --git a/au-o2-gui/src/engine/session_player.rs b/au-o2-gui/src/engine/session_player.rs new file mode 100644 index 0000000..d90df71 --- /dev/null +++ b/au-o2-gui/src/engine/session_player.rs @@ -0,0 +1,415 @@ +/// AI session player: rule-based generative MIDI accompaniment. +/// +/// Generates complementary musical patterns based on key, scale, tempo, +/// and style parameters. Patterns follow music theory rules — root motion, +/// voice leading, rhythmic density control. Not ML-based; deterministic +/// from seed + parameters. + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlayerStyle { + Drums, + Bass, + Chords, + Arpeggio, +} + +impl PlayerStyle { + pub const ALL: [PlayerStyle; 4] = [ + PlayerStyle::Drums, + PlayerStyle::Bass, + PlayerStyle::Chords, + PlayerStyle::Arpeggio, + ]; + + pub fn label(&self) -> &'static str { + match self { + Self::Drums => "Drums", + Self::Bass => "Bass", + Self::Chords => "Chords", + Self::Arpeggio => "Arp", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScaleType { + Major, + Minor, + Dorian, + Mixolydian, + Pentatonic, + Blues, +} + +impl ScaleType { + pub const ALL: [ScaleType; 6] = [ + ScaleType::Major, + ScaleType::Minor, + ScaleType::Dorian, + ScaleType::Mixolydian, + ScaleType::Pentatonic, + ScaleType::Blues, + ]; +} + +impl std::fmt::Display for ScaleType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Major => write!(f, "Major"), + Self::Minor => write!(f, "Minor"), + Self::Dorian => write!(f, "Dorian"), + Self::Mixolydian => write!(f, "Mixolydian"), + Self::Pentatonic => write!(f, "Pentatonic"), + Self::Blues => write!(f, "Blues"), + } + } +} + +impl ScaleType { + fn intervals(&self) -> &[u8] { + match self { + Self::Major => &[0, 2, 4, 5, 7, 9, 11], + Self::Minor => &[0, 2, 3, 5, 7, 8, 10], + Self::Dorian => &[0, 2, 3, 5, 7, 9, 10], + Self::Mixolydian => &[0, 2, 4, 5, 7, 9, 10], + Self::Pentatonic => &[0, 2, 4, 7, 9], + Self::Blues => &[0, 3, 5, 6, 7, 10], + } + } +} + +#[derive(Debug, Clone)] +pub struct SessionPlayerConfig { + pub style: PlayerStyle, + pub root_note: u8, // MIDI note 0-11 (C=0) + pub scale: ScaleType, + pub octave: u8, // base octave (3-6) + pub density: f32, // 0.0 sparse .. 1.0 dense + pub velocity_base: u8, + pub swing: f32, // 0.0 straight .. 1.0 full swing + pub seed: u64, +} + +impl Default for SessionPlayerConfig { + fn default() -> Self { + Self { + style: PlayerStyle::Drums, + root_note: 0, + scale: ScaleType::Major, + octave: 4, + density: 0.5, + velocity_base: 80, + swing: 0.0, + seed: 42, + } + } +} + +#[derive(Debug, Clone)] +pub struct GeneratedNote { + pub beat_offset: f64, // beat position within the pattern + pub duration_beats: f64, + pub note: u8, // MIDI note number + pub velocity: u8, +} + +/// Simple deterministic PRNG (xorshift64) +struct Rng { + state: u64, +} + +impl Rng { + fn new(seed: u64) -> Self { + Self { state: seed.max(1) } + } + + fn next(&mut self) -> u64 { + self.state ^= self.state << 13; + self.state ^= self.state >> 7; + self.state ^= self.state << 17; + self.state + } + + fn next_f32(&mut self) -> f32 { + (self.next() % 10000) as f32 / 10000.0 + } + + fn next_range(&mut self, min: usize, max: usize) -> usize { + if max <= min { return min; } + min + (self.next() as usize % (max - min)) + } +} + +/// Generate a pattern of MIDI notes for the given config. +/// Pattern length is `bars` bars at the given time signature. +pub fn generate_pattern( + config: &SessionPlayerConfig, + bars: u32, + beats_per_bar: u32, +) -> Vec { + let mut rng = Rng::new(config.seed); + let total_beats = bars as f64 * beats_per_bar as f64; + let scale = build_scale(config.root_note, &config.scale, config.octave); + + match config.style { + PlayerStyle::Drums => generate_drums(config, &mut rng, total_beats, beats_per_bar), + PlayerStyle::Bass => generate_bass(config, &mut rng, total_beats, beats_per_bar, &scale), + PlayerStyle::Chords => generate_chords(config, &mut rng, total_beats, beats_per_bar, &scale), + PlayerStyle::Arpeggio => generate_arpeggio(config, &mut rng, total_beats, beats_per_bar, &scale), + } +} + +fn build_scale(root: u8, scale_type: &ScaleType, octave: u8) -> Vec { + let base = root + octave * 12; + let intervals = scale_type.intervals(); + let mut notes = Vec::new(); + for oct_offset in 0..2u8 { + for &interval in intervals { + let n = base + oct_offset * 12 + interval; + if n < 128 { + notes.push(n); + } + } + } + notes +} + +fn apply_swing(beat: f64, swing: f32) -> f64 { + let frac = beat.fract(); + let base = beat.floor(); + if frac >= 0.5 { + let shift = 0.5 * swing as f64 * 0.33; + base + 0.5 + (frac - 0.5) * (1.0 - shift * 2.0) + shift + } else { + beat + } +} + +fn generate_drums( + config: &SessionPlayerConfig, + rng: &mut Rng, + total_beats: f64, + beats_per_bar: u32, +) -> Vec { + let mut notes = Vec::new(); + // GM drum map: kick=36, snare=38, hihat=42, open_hh=46, ride=51 + let kick = 36u8; + let snare = 38u8; + let hihat = 42u8; + + let sixteenths = (total_beats * 4.0) as u32; + for step in 0..sixteenths { + let beat = step as f64 / 4.0; + let beat_in_bar = (step % (beats_per_bar * 4)) as f64 / 4.0; + + // Kick: beats 1 and 3 (or more with density) + if step % 4 == 0 && (beat_in_bar < 0.1 || (beat_in_bar - 2.0).abs() < 0.1) { + notes.push(GeneratedNote { + beat_offset: apply_swing(beat, config.swing), + duration_beats: 0.25, + note: kick, + velocity: config.velocity_base, + }); + } else if step % 4 == 0 && rng.next_f32() < config.density * 0.3 { + notes.push(GeneratedNote { + beat_offset: apply_swing(beat, config.swing), + duration_beats: 0.25, + note: kick, + velocity: (config.velocity_base as f32 * 0.7) as u8, + }); + } + + // Snare: beats 2 and 4 + if step % 4 == 0 && ((beat_in_bar - 1.0).abs() < 0.1 || (beat_in_bar - 3.0).abs() < 0.1) { + notes.push(GeneratedNote { + beat_offset: apply_swing(beat, config.swing), + duration_beats: 0.25, + note: snare, + velocity: config.velocity_base, + }); + } + + // Hihat: every 8th or 16th depending on density + let hh_interval = if config.density > 0.6 { 1 } else { 2 }; + if step % hh_interval == 0 { + let accent = step % 4 == 0; + let vel = if accent { + config.velocity_base + } else { + (config.velocity_base as f32 * 0.6) as u8 + }; + notes.push(GeneratedNote { + beat_offset: apply_swing(beat, config.swing), + duration_beats: 0.125, + note: hihat, + velocity: vel, + }); + } + } + notes +} + +fn generate_bass( + config: &SessionPlayerConfig, + rng: &mut Rng, + total_beats: f64, + beats_per_bar: u32, + scale: &[u8], +) -> Vec { + let mut notes = Vec::new(); + if scale.is_empty() { return notes; } + + let bars = (total_beats / beats_per_bar as f64).ceil() as u32; + let mut current_degree = 0usize; + + for bar in 0..bars { + let bar_start = bar as f64 * beats_per_bar as f64; + + // Root on beat 1, occasional octave jump + let root = scale[current_degree % scale.len()]; + let octave_shift: i8 = if rng.next_f32() < 0.15 { -12 } else { 0 }; + let root_note = (root as i8 + octave_shift).clamp(24, 72) as u8; + notes.push(GeneratedNote { + beat_offset: apply_swing(bar_start, config.swing), + duration_beats: 0.75, + note: root_note, + velocity: config.velocity_base, + }); + + // Eighth note pattern with density gating + let eighths = beats_per_bar * 2; + for e in 2..eighths { + if rng.next_f32() > config.density { continue; } + let beat = bar_start + e as f64 * 0.5; + let degree = current_degree + rng.next_range(0, 3); + let n = scale[degree % scale.len()]; + notes.push(GeneratedNote { + beat_offset: apply_swing(beat, config.swing), + duration_beats: 0.5, + note: n, + velocity: (config.velocity_base as f32 * 0.8) as u8, + }); + } + + // Chord progression: move by common intervals + let movement = [0, 3, 4, 5, 2, 5, 3, 0]; + current_degree = movement[bar as usize % movement.len()]; + } + notes +} + +fn generate_chords( + config: &SessionPlayerConfig, + rng: &mut Rng, + total_beats: f64, + beats_per_bar: u32, + scale: &[u8], +) -> Vec { + let mut notes = Vec::new(); + if scale.len() < 5 { return notes; } + + let bars = (total_beats / beats_per_bar as f64).ceil() as u32; + let progression = [0usize, 3, 4, 0, 0, 5, 3, 4]; + + for bar in 0..bars { + let bar_start = bar as f64 * beats_per_bar as f64; + let degree = progression[bar as usize % progression.len()]; + + // Build triad from scale degrees with random inversions + let mut chord_notes = [ + scale[degree % scale.len()], + scale[(degree + 2) % scale.len()], + scale[(degree + 4) % scale.len()], + ]; + // Random inversion: shift lowest note up an octave + let inversion = rng.next_range(0, 3); + if inversion > 0 && inversion <= chord_notes.len() { + chord_notes[inversion - 1] = chord_notes[inversion - 1].wrapping_add(12).min(108); + } + + // Rhythmic pattern depends on density + let hits_per_bar = 1 + (config.density * 3.0) as u32; + for h in 0..hits_per_bar { + let beat = bar_start + h as f64 * (beats_per_bar as f64 / hits_per_bar as f64); + let vel_jitter = rng.next_range(0, 10) as i8 - 5; + let base_vel = if h == 0 { config.velocity_base } else { (config.velocity_base as f32 * 0.7) as u8 }; + let vel = (base_vel as i8 + vel_jitter).clamp(1, 127) as u8; + let dur = if h == 0 { beats_per_bar as f64 / hits_per_bar as f64 } else { 0.5 }; + + for &n in &chord_notes { + notes.push(GeneratedNote { + beat_offset: apply_swing(beat, config.swing), + duration_beats: dur, + note: n, + velocity: vel, + }); + } + } + } + notes +} + +fn generate_arpeggio( + config: &SessionPlayerConfig, + rng: &mut Rng, + total_beats: f64, + beats_per_bar: u32, + scale: &[u8], +) -> Vec { + let mut notes = Vec::new(); + if scale.len() < 5 { return notes; } + + let bars = (total_beats / beats_per_bar as f64).ceil() as u32; + let progression = [0usize, 3, 4, 0]; + + for bar in 0..bars { + let bar_start = bar as f64 * beats_per_bar as f64; + let degree = progression[bar as usize % progression.len()]; + + let arp_notes = [ + scale[degree % scale.len()], + scale[(degree + 2) % scale.len()], + scale[(degree + 4) % scale.len()], + scale[(degree + 2) % scale.len()], + ]; + + // 16th note arpeggiation + let steps = (beats_per_bar as f32 * 4.0 * config.density.max(0.25)) as u32; + for step in 0..steps { + let beat = bar_start + step as f64 * 0.25; + if beat >= total_beats { break; } + let n = arp_notes[step as usize % arp_notes.len()]; + notes.push(GeneratedNote { + beat_offset: apply_swing(beat, config.swing), + duration_beats: 0.25, + note: n, + velocity: config.velocity_base + .saturating_sub((step % 4 != 0) as u8 * 15) + .saturating_add((rng.next() % 7) as u8), + }); + } + } + notes +} + +/// Convert generated notes to MIDI events at a given sample rate and tempo. +pub fn notes_to_midi_events( + notes: &[GeneratedNote], + tempo_bpm: f64, + sample_rate: u32, +) -> Vec<(u64, u8, u8, bool)> { + // Returns (sample_pos, note, velocity, is_note_on) + let mut events = Vec::new(); + let samples_per_beat = (60.0 / tempo_bpm) * sample_rate as f64; + + for n in notes { + let on_sample = (n.beat_offset * samples_per_beat) as u64; + let off_sample = ((n.beat_offset + n.duration_beats) * samples_per_beat) as u64; + events.push((on_sample, n.note, n.velocity, true)); + events.push((off_sample, n.note, 0, false)); + } + + events.sort_by_key(|e| e.0); + events +} diff --git a/au-o2-gui/src/engine/spatial.rs b/au-o2-gui/src/engine/spatial.rs new file mode 100644 index 0000000..489bd5e --- /dev/null +++ b/au-o2-gui/src/engine/spatial.rs @@ -0,0 +1,154 @@ +use std::collections::HashMap; + +use super::atmos::{BinauralState, MonoLane, ObjectPosition, SpatialRenderMode, downmix_714_to_stereo, render_714}; +use super::bus::BusRegistry; + +pub struct SpatialRenderer { + pub mode: SpatialRenderMode, + pub mono_lane: MonoLane, + positions: HashMap, + binaural_states: HashMap, + scratch_mono: Vec, + scratch_l: Vec, + scratch_r: Vec, + scratch_bed: Vec, + scratch_out: Vec, + scratch_names: Vec, +} + +impl SpatialRenderer { + pub fn new(buffer_size: usize) -> Self { + Self { + mode: SpatialRenderMode::default(), + mono_lane: MonoLane::default(), + positions: HashMap::new(), + binaural_states: HashMap::new(), + scratch_mono: vec![0.0; buffer_size], + scratch_l: vec![0.0; buffer_size], + scratch_r: vec![0.0; buffer_size], + scratch_bed: vec![0.0; buffer_size * 12], + scratch_out: vec![0.0; buffer_size * 2], + scratch_names: Vec::new(), + } + } + + pub fn set_position(&mut self, bus_name: String, position: ObjectPosition) { + if !self.binaural_states.contains_key(&bus_name) { + self.binaural_states.insert(bus_name.clone(), BinauralState::new()); + } + self.positions.insert(bus_name, position); + } + + pub fn remove_position(&mut self, bus_name: &str) { + self.positions.remove(bus_name); + self.binaural_states.remove(bus_name); + } + + pub fn render(&mut self, bus_registry: &mut BusRegistry, sample_rate: u32) { + if self.positions.is_empty() { + return; + } + + self.scratch_names.clear(); + self.scratch_names.extend(self.positions.keys().cloned()); + + for idx in 0..self.scratch_names.len() { + let bus_name = self.scratch_names[idx].clone(); + let pos = match self.positions.get(&bus_name) { + Some(p) => *p, + None => continue, + }; + + let mut mono_handled = false; + let frames = match bus_registry.get_by_name(&bus_name) { + Some(bus) => { + let interleaved = bus.read_interleaved(); + let f = interleaved.len() / 2; + if f == 0 { continue; } + self.ensure_scratch(f); + + if self.mode == SpatialRenderMode::Mono { + for i in 0..f { + let s = match self.mono_lane { + MonoLane::Mix => (interleaved[i * 2] + interleaved[i * 2 + 1]) * 0.5, + MonoLane::Left => interleaved[i * 2], + MonoLane::Right => interleaved[i * 2 + 1], + }; + self.scratch_out[i * 2] = s; + self.scratch_out[i * 2 + 1] = s; + } + mono_handled = true; + f + } else { + for i in 0..f { + self.scratch_mono[i] = (interleaved[i * 2] + interleaved[i * 2 + 1]) * 0.5; + } + f + } + } + None => continue, + }; + + if mono_handled { + if let Some(bus) = bus_registry.get_mut_by_name(&bus_name) { + bus.write_interleaved(&self.scratch_out[..frames * 2]); + } + continue; + } + + for s in &mut self.scratch_l[..frames] { *s = 0.0; } + for s in &mut self.scratch_r[..frames] { *s = 0.0; } + + match self.mode { + SpatialRenderMode::Mono => unreachable!(), + SpatialRenderMode::Stereo => { + let pan = (pos.x + 1.0) * 0.5; + for i in 0..frames { + self.scratch_l[i] = self.scratch_mono[i] * (1.0 - pan); + self.scratch_r[i] = self.scratch_mono[i] * pan; + } + } + SpatialRenderMode::Binaural => { + if let Some(state) = self.binaural_states.get_mut(&bus_name) { + state.render( + &self.scratch_mono[..frames], + &mut self.scratch_l[..frames], + &mut self.scratch_r[..frames], + &pos, + sample_rate, + ); + } + } + SpatialRenderMode::Surround714 => { + let bed_len = frames * 12; + for s in &mut self.scratch_bed[..bed_len] { *s = 0.0; } + render_714(&self.scratch_mono[..frames], &mut self.scratch_bed[..bed_len], &pos); + downmix_714_to_stereo( + &self.scratch_bed[..bed_len], + &mut self.scratch_l[..frames], + &mut self.scratch_r[..frames], + ); + } + } + + if let Some(bus) = bus_registry.get_mut_by_name(&bus_name) { + for i in 0..frames { + self.scratch_out[i * 2] = self.scratch_l[i]; + self.scratch_out[i * 2 + 1] = self.scratch_r[i]; + } + bus.clear(); + bus.write_interleaved(&self.scratch_out[..frames * 2]); + } + } + } + + fn ensure_scratch(&mut self, frames: usize) { + if self.scratch_mono.len() < frames { + self.scratch_mono.resize(frames, 0.0); + self.scratch_l.resize(frames, 0.0); + self.scratch_r.resize(frames, 0.0); + self.scratch_bed.resize(frames * 12, 0.0); + self.scratch_out.resize(frames * 2, 0.0); + } + } +} diff --git a/au-o2-gui/src/engine/stems.rs b/au-o2-gui/src/engine/stems.rs new file mode 100644 index 0000000..a88d8c9 --- /dev/null +++ b/au-o2-gui/src/engine/stems.rs @@ -0,0 +1,252 @@ +use rustfft::{FftPlanner, num_complex::Complex}; + +const FFT_SIZE: usize = 2048; +const HOP_SIZE: usize = 512; + +/// Result of stem splitting: separated audio components +pub struct StemSplit { + pub bass: (Vec, Vec), + pub drums: (Vec, Vec), + pub vocals: (Vec, Vec), + pub other: (Vec, Vec), +} + +/// Split stereo audio into 4 stems: bass, drums, vocals, other. +/// Uses frequency-band separation + harmonic/percussive source separation (HPSS). +pub fn split_stems(left: &[f32], right: &[f32]) -> StemSplit { + let n = left.len().min(right.len()); + if n == 0 { + return StemSplit { + bass: (Vec::new(), Vec::new()), + drums: (Vec::new(), Vec::new()), + vocals: (Vec::new(), Vec::new()), + other: (Vec::new(), Vec::new()), + }; + } + + // Process mono mix for separation, then apply masks to L/R independently + let mono: Vec = (0..n).map(|i| (left[i] + right[i]) * 0.5).collect(); + + // Compute STFT + let spectrogram = stft(&mono); + let num_frames = spectrogram.len(); + let num_bins = FFT_SIZE / 2 + 1; + + // Compute magnitude spectrogram + let magnitudes: Vec> = spectrogram.iter() + .map(|frame| frame[..num_bins].iter().map(|c| c.norm()).collect()) + .collect(); + + // HPSS: median filter along time (harmonic) and frequency (percussive) + let harmonic_mag = median_filter_time(&magnitudes, 17); + let percussive_mag = median_filter_freq(&magnitudes, 17); + + // Soft masks for H/P separation + let eps = 1e-10f32; + let mut harmonic_mask: Vec> = Vec::with_capacity(num_frames); + let mut percussive_mask: Vec> = Vec::with_capacity(num_frames); + + for f in 0..num_frames { + let mut h_row = Vec::with_capacity(num_bins); + let mut p_row = Vec::with_capacity(num_bins); + for b in 0..num_bins { + let h = harmonic_mag[f][b]; + let p = percussive_mag[f][b]; + let total = h * h + p * p + eps; + h_row.push((h * h) / total); + p_row.push((p * p) / total); + } + harmonic_mask.push(h_row); + percussive_mask.push(p_row); + } + + // Frequency band boundaries (bin indices) + let freq_per_bin = 44100.0 / FFT_SIZE as f32; + let bass_cutoff = (250.0 / freq_per_bin) as usize; + let vocal_lo = (300.0 / freq_per_bin) as usize; + let vocal_hi = (4000.0 / freq_per_bin) as usize; + + // Build per-stem spectrograms + let mut bass_spec: Vec>> = Vec::with_capacity(num_frames); + let mut drums_spec: Vec>> = Vec::with_capacity(num_frames); + let mut vocals_spec: Vec>> = Vec::with_capacity(num_frames); + let mut other_spec: Vec>> = Vec::with_capacity(num_frames); + + for f in 0..num_frames { + let mut bass_frame = vec![Complex::new(0.0, 0.0); FFT_SIZE]; + let mut drums_frame = vec![Complex::new(0.0, 0.0); FFT_SIZE]; + let mut vocals_frame = vec![Complex::new(0.0, 0.0); FFT_SIZE]; + let mut other_frame = vec![Complex::new(0.0, 0.0); FFT_SIZE]; + + for b in 0..num_bins { + let orig = spectrogram[f][b]; + let h_mask = harmonic_mask[f][b]; + let p_mask = percussive_mask[f][b]; + + if b < bass_cutoff { + // Low frequencies → bass (harmonic content below 250Hz) + bass_frame[b] = orig * h_mask; + drums_frame[b] = orig * p_mask; + } else if b >= vocal_lo && b <= vocal_hi { + // Mid-range → vocals (harmonic) + drums (percussive) + vocals_frame[b] = orig * h_mask * 0.7; + drums_frame[b] = drums_frame[b] + orig * p_mask; + other_frame[b] = orig * h_mask * 0.3; + } else { + // High frequencies → other + other_frame[b] = orig * h_mask; + drums_frame[b] = drums_frame[b] + orig * p_mask; + } + + // Mirror for IFFT + if b > 0 && b < num_bins - 1 { + bass_frame[FFT_SIZE - b] = bass_frame[b].conj(); + drums_frame[FFT_SIZE - b] = drums_frame[b].conj(); + vocals_frame[FFT_SIZE - b] = vocals_frame[b].conj(); + other_frame[FFT_SIZE - b] = other_frame[b].conj(); + } + } + + bass_spec.push(bass_frame); + drums_spec.push(drums_frame); + vocals_spec.push(vocals_frame); + other_spec.push(other_frame); + } + + // ISTFT each stem + let bass_mono = istft(&bass_spec, n); + let drums_mono = istft(&drums_spec, n); + let vocals_mono = istft(&vocals_spec, n); + let other_mono = istft(&other_spec, n); + + // Apply same separation ratios to L/R channels + // Use the mono masks to scale left and right independently + let bass_l = apply_mono_mask(left, &mono, &bass_mono, n); + let bass_r = apply_mono_mask(right, &mono, &bass_mono, n); + let drums_l = apply_mono_mask(left, &mono, &drums_mono, n); + let drums_r = apply_mono_mask(right, &mono, &drums_mono, n); + let vocals_l = apply_mono_mask(left, &mono, &vocals_mono, n); + let vocals_r = apply_mono_mask(right, &mono, &vocals_mono, n); + let other_l = apply_mono_mask(left, &mono, &other_mono, n); + let other_r = apply_mono_mask(right, &mono, &other_mono, n); + + StemSplit { + bass: (bass_l, bass_r), + drums: (drums_l, drums_r), + vocals: (vocals_l, vocals_r), + other: (other_l, other_r), + } +} + +fn apply_mono_mask(original: &[f32], mono: &[f32], stem_mono: &[f32], n: usize) -> Vec { + let eps = 1e-10f32; + (0..n).map(|i| { + let ratio = stem_mono[i] / (mono[i].abs() + eps); + original[i] * ratio.clamp(-2.0, 2.0) + }).collect() +} + +fn hann_window(size: usize) -> Vec { + (0..size).map(|i| { + 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / size as f32).cos()) + }).collect() +} + +fn stft(signal: &[f32]) -> Vec>> { + let mut planner = FftPlanner::new(); + let fft = planner.plan_fft_forward(FFT_SIZE); + let window = hann_window(FFT_SIZE); + + let mut frames = Vec::new(); + let mut pos = 0; + + while pos + FFT_SIZE <= signal.len() { + let mut buf: Vec> = (0..FFT_SIZE) + .map(|i| Complex::new(signal[pos + i] * window[i], 0.0)) + .collect(); + fft.process(&mut buf); + frames.push(buf); + pos += HOP_SIZE; + } + + frames +} + +fn istft(frames: &[Vec>], output_len: usize) -> Vec { + let mut planner = FftPlanner::new(); + let ifft = planner.plan_fft_inverse(FFT_SIZE); + let window = hann_window(FFT_SIZE); + let scale = 1.0 / FFT_SIZE as f32; + + let mut output = vec![0.0f32; output_len]; + let mut window_sum = vec![0.0f32; output_len]; + + for (frame_idx, frame) in frames.iter().enumerate() { + let pos = frame_idx * HOP_SIZE; + let mut buf = frame.clone(); + ifft.process(&mut buf); + + for i in 0..FFT_SIZE { + let out_idx = pos + i; + if out_idx < output_len { + output[out_idx] += buf[i].re * scale * window[i]; + window_sum[out_idx] += window[i] * window[i]; + } + } + } + + // Normalize by window sum + for i in 0..output_len { + if window_sum[i] > 1e-8 { + output[i] /= window_sum[i]; + } + } + + output +} + +fn median_filter_time(mag: &[Vec], kernel: usize) -> Vec> { + let num_frames = mag.len(); + let num_bins = if num_frames > 0 { mag[0].len() } else { 0 }; + let half = kernel / 2; + + let mut result = vec![vec![0.0f32; num_bins]; num_frames]; + let mut buf = Vec::with_capacity(kernel); + + for b in 0..num_bins { + for f in 0..num_frames { + buf.clear(); + let lo = if f > half { f - half } else { 0 }; + let hi = (f + half + 1).min(num_frames); + for t in lo..hi { + buf.push(mag[t][b]); + } + buf.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + result[f][b] = buf[buf.len() / 2]; + } + } + result +} + +fn median_filter_freq(mag: &[Vec], kernel: usize) -> Vec> { + let num_frames = mag.len(); + let num_bins = if num_frames > 0 { mag[0].len() } else { 0 }; + let half = kernel / 2; + + let mut result = vec![vec![0.0f32; num_bins]; num_frames]; + let mut buf = Vec::with_capacity(kernel); + + for f in 0..num_frames { + for b in 0..num_bins { + buf.clear(); + let lo = if b > half { b - half } else { 0 }; + let hi = (b + half + 1).min(num_bins); + for k in lo..hi { + buf.push(mag[f][k]); + } + buf.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + result[f][b] = buf[buf.len() / 2]; + } + } + result +} diff --git a/au-o2-gui/src/export.rs b/au-o2-gui/src/export.rs new file mode 100644 index 0000000..f3c82f8 --- /dev/null +++ b/au-o2-gui/src/export.rs @@ -0,0 +1,268 @@ +use std::path::Path; +use crate::codec::{XtcDecoder, XtcEncoder}; +use crate::track::Track; +use flacenc::error::Verify; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportFormat { + Wav, + Flac, + Xtc, +} + +impl ExportFormat { + pub const ALL: [ExportFormat; 3] = [ExportFormat::Wav, ExportFormat::Flac, ExportFormat::Xtc]; + + pub fn label(&self) -> &'static str { + match self { + ExportFormat::Wav => "WAV", + ExportFormat::Flac => "FLAC", + ExportFormat::Xtc => "XTC", + } + } + + pub fn extension(&self) -> &'static str { + match self { + ExportFormat::Wav => "wav", + ExportFormat::Flac => "flac", + ExportFormat::Xtc => "xtc", + } + } +} + +impl std::fmt::Display for ExportFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.label()) + } +} + +#[derive(Debug, Clone)] +pub struct ExportConfig { + pub format: ExportFormat, + pub sample_rate: u32, + pub bit_depth: u16, + pub normalize: bool, + pub filename: String, +} + +impl Default for ExportConfig { + fn default() -> Self { + Self { + format: ExportFormat::Wav, + sample_rate: 48000, + bit_depth: 24, + normalize: false, + filename: "export".to_string(), + } + } +} + +pub fn bounce_track( + track: &Track, + project_path: &Path, +) -> Option<(Vec, Vec, u64, u64)> { + let mut start_sample = u64::MAX; + let mut end_sample: u64 = 0; + + for region in track.visible_regions() { + if region.length_samples == 0 { continue; } + if region.audio_file.is_none() { continue; } + start_sample = start_sample.min(region.start_sample); + end_sample = end_sample.max(region.start_sample + region.length_samples); + } + + if end_sample == 0 || start_sample == u64::MAX { + return None; + } + + let len = (end_sample - start_sample) as usize; + let mut mix_l = vec![0.0f32; len]; + let mut mix_r = vec![0.0f32; len]; + + for region in track.visible_regions() { + let audio_file = match region.audio_file.as_ref() { + Some(f) => f, + None => continue, + }; + let abs_path = project_path.join(audio_file); + let (audio_l, audio_r) = match XtcDecoder::open(&abs_path) + .and_then(|d| d.decode_real(&abs_path)) + { + Ok(data) => data, + Err(_) => continue, + }; + + let offset = (region.start_sample - start_sample) as usize; + let region_len = audio_l.len().min(audio_r.len()); + for i in 0..region_len { + let dst = offset + i; + if dst >= len { break; } + mix_l[dst] += audio_l[i]; + mix_r[dst] += audio_r[i]; + } + } + + Some((mix_l, mix_r, start_sample, end_sample - start_sample)) +} + +pub fn bounce_offline( + tracks: &[Track], + project_path: &Path, + _tempo: f32, + _sample_rate: u32, + _beats_per_bar: u32, +) -> Option<(Vec, Vec)> { + let mut end_sample: u64 = 0; + + for track in tracks { + if track.muted { continue; } + for region in &track.regions { + let region_start = region.start_sample; + let region_end = region_start + region.length_samples; + end_sample = end_sample.max(region_end); + } + } + + if end_sample == 0 { + return None; + } + + let len = end_sample as usize; + let mut mix_l = vec![0.0f32; len]; + let mut mix_r = vec![0.0f32; len]; + + let any_soloed = tracks.iter().any(|t| t.soloed); + + for track in tracks { + if track.muted { continue; } + if any_soloed && !track.soloed { continue; } + + let vol = track.volume; + let pan = track.pan; + let vol_l = vol * (1.0 - pan.max(0.0)); + let vol_r = vol * (1.0 + pan.min(0.0)); + + for region in &track.regions { + if region.length_samples == 0 { continue; } + let audio_file = match region.audio_file.as_ref() { + Some(f) => f, + None => continue, + }; + let abs_path = project_path.join(audio_file); + let (audio_l, audio_r) = match XtcDecoder::open(&abs_path) + .and_then(|d| d.decode_real(&abs_path)) + { + Ok(data) => data, + Err(_) => continue, + }; + + let start = region.start_sample as usize; + let region_len = audio_l.len().min(audio_r.len()); + + for i in 0..region_len { + let dst = start + i; + if dst >= len { break; } + mix_l[dst] += audio_l[i] * vol_l; + mix_r[dst] += audio_r[i] * vol_r; + } + } + } + + Some((mix_l, mix_r)) +} + +pub fn export_wav(path: &Path, left: &[f32], right: &[f32], sample_rate: u32, bit_depth: u16) -> Result<(), String> { + let spec = hound::WavSpec { + channels: 2, + sample_rate, + bits_per_sample: bit_depth, + sample_format: if bit_depth <= 16 { + hound::SampleFormat::Int + } else { + hound::SampleFormat::Float + }, + }; + + let mut writer = hound::WavWriter::create(path, spec) + .map_err(|e| format!("failed to create WAV: {}", e))?; + + if bit_depth <= 16 { + let max = ((1i32 << (bit_depth - 1)) - 1) as f32; + for i in 0..left.len() { + writer.write_sample((left[i].clamp(-1.0, 1.0) * max) as i16) + .map_err(|e| format!("write error: {}", e))?; + writer.write_sample((right[i].clamp(-1.0, 1.0) * max) as i16) + .map_err(|e| format!("write error: {}", e))?; + } + } else { + for i in 0..left.len() { + writer.write_sample(left[i].clamp(-1.0, 1.0)) + .map_err(|e| format!("write error: {}", e))?; + writer.write_sample(right[i].clamp(-1.0, 1.0)) + .map_err(|e| format!("write error: {}", e))?; + } + } + + writer.finalize() + .map_err(|e| format!("finalize error: {}", e))?; + Ok(()) +} + +pub fn export_flac(path: &Path, left: &[f32], right: &[f32], sample_rate: u32, bit_depth: u16) -> Result<(), String> { + let max = ((1i64 << (bit_depth - 1)) - 1) as f32; + let mut interleaved = Vec::with_capacity(left.len() * 2); + for i in 0..left.len() { + interleaved.push((left[i].clamp(-1.0, 1.0) * max) as i32); + interleaved.push((right[i].clamp(-1.0, 1.0) * max) as i32); + } + + let config = flacenc::config::Encoder::default() + .into_verified() + .map_err(|e| format!("{:?}", e))?; + + let source = flacenc::source::MemSource::from_samples( + &interleaved, + 2, + bit_depth as usize, + sample_rate as usize, + ); + + let stream = flacenc::encode_with_fixed_block_size(&config, source, config.block_size) + .map_err(|e| format!("{:?}", e))?; + + use flacenc::component::BitRepr; + let mut sink = flacenc::bitsink::ByteSink::new(); + stream.write(&mut sink) + .map_err(|_| "failed to write FLAC stream".to_string())?; + + std::fs::write(path, sink.as_slice()) + .map_err(|e| format!("I/O error: {}", e))?; + Ok(()) +} + +pub fn export_xtc( + path: &Path, + left: &[f32], + right: &[f32], + sample_rate: u32, + bit_depth: u16, + fft_size: u32, +) -> Result<(), String> { + let imag_l = vec![0.0f32; left.len()]; + let imag_r = vec![0.0f32; right.len()]; + let encoder = XtcEncoder::new(sample_rate, bit_depth, fft_size); + encoder.encode_to_file(path, left, right, &imag_l, &imag_r) + .map_err(|e| format!("{}", e)) +} + +pub fn normalize(left: &mut [f32], right: &mut [f32]) { + let mut peak = 0.0f32; + for &s in left.iter().chain(right.iter()) { + peak = peak.max(s.abs()); + } + if peak > 0.0 && peak != 1.0 { + let gain = 1.0 / peak; + for s in left.iter_mut() { *s *= gain; } + for s in right.iter_mut() { *s *= gain; } + } +} diff --git a/au-o2-gui/src/first_run.rs b/au-o2-gui/src/first_run.rs new file mode 100644 index 0000000..cbc1d9c --- /dev/null +++ b/au-o2-gui/src/first_run.rs @@ -0,0 +1,70 @@ +use crate::config::AudioOxideConfig; +use std::{fs, path::PathBuf}; + +fn get_config_path() -> Option { + dirs::home_dir().map(|h| h.join(".oxide-audio/config.toml")) +} + +pub fn load_or_initialize_config() -> AudioOxideConfig { + let config_path = match get_config_path() { + Some(p) => p, + None => { + debug_log!("no home directory found, using default config"); + return AudioOxideConfig::default(); + } + }; + + if !config_path.exists() { + let config = AudioOxideConfig::default(); + + if let Some(parent) = config_path.parent() { + if let Err(_e) = fs::create_dir_all(parent) { + debug_log!("failed to create config directory: {}", _e); + return config; + } + } + + match toml::to_string_pretty(&config) { + Ok(toml_string) => { + if let Err(_e) = fs::write(&config_path, toml_string) { + debug_log!("failed to write config file: {}", _e); + } + } + Err(_e) => debug_log!("failed to serialize config: {}", _e), + } + + return config; + } + + match fs::read_to_string(&config_path) { + Ok(toml_string) => match toml::from_str(&toml_string) { + Ok(config) => config, + Err(_e) => { + debug_log!("failed to parse config file: {}", _e); + AudioOxideConfig::default() + } + }, + Err(_e) => { + debug_log!("failed to read config file: {}", _e); + AudioOxideConfig::default() + } + } +} + +pub fn save_config(config: &AudioOxideConfig) { + let config_path = match get_config_path() { + Some(p) => p, + None => { + debug_log!("no home directory, cannot save config"); + return; + } + }; + match toml::to_string_pretty(config) { + Ok(toml_string) => { + if let Err(_e) = fs::write(&config_path, toml_string) { + debug_log!("failed to write config file: {}", _e); + } + } + Err(_e) => debug_log!("failed to serialize config: {}", _e), + } +} \ No newline at end of file diff --git a/au-o2-gui/src/gui/editor/control_bar.rs b/au-o2-gui/src/gui/editor/control_bar.rs new file mode 100644 index 0000000..8a50720 --- /dev/null +++ b/au-o2-gui/src/gui/editor/control_bar.rs @@ -0,0 +1,163 @@ +use crate::editor::{BottomPanelMode, Message}; +use crate::engine::TransportState; +use crate::gui::icon_button::{button_group, IconButton}; +use crate::gui::icons::{Icon, IconSet}; +use crate::timing::MusicalTime; +use iced::widget::{container, row, text, Space}; +use iced::{Alignment, Background, Color, Element, Length, Theme}; + +pub fn view<'a>( + transport: &TransportState, + position: &MusicalTime, + tempo: f32, + ts_num: u8, + ts_den: u8, + cycle_enabled: bool, + metronome_enabled: bool, + count_in_enabled: bool, + record_armed: bool, + show_inspector: bool, + show_bottom_panel: bool, + bottom_panel_mode: &BottomPanelMode, + icons: &'a IconSet, +) -> Element<'a, Message> { + let is_playing = *transport == TransportState::Playing; + + let pos_text = format!( + "{:03}.{}.{:03}", + position.bar, position.beat, position.tick + ); + let lcd = container(text(pos_text).size(32).font(iced::Font::MONOSPACE)) + .padding([5, 14]) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8(0x1A, 0x1C, 0x1E))), + border: iced::Border { + radius: 5.0.into(), + color: Color::from_rgb8(0x44, 0x46, 0x48), + width: 1.0, + }, + ..container::Style::default() + }); + + let (rw_u, rw_f) = icons.get(Icon::Rewind); + let (stop_u, stop_f) = icons.get(Icon::Stop); + let (play_u, play_f) = icons.get(Icon::Play); + let (rec_u, rec_f) = icons.get(Icon::Record); + + let transport_controls = button_group(vec![ + IconButton::new(rw_u, rw_f, Message::RewindPressed) + .size(37.0) + .hint("Rewind") + .into(), + IconButton::new(stop_u, stop_f, Message::StopPressed) + .size(37.0) + .hint("Stop") + .into(), + IconButton::new(play_u, play_f, Message::PlayPressed) + .size(37.0) + .toggled(is_playing) + .hint("Play") + .into(), + IconButton::new(rec_u, rec_f, Message::RecordPressed) + .size(37.0) + .toggled(record_armed) + .active_tint(Color::from_rgb8(0xCC, 0x33, 0x33)) + .hint("Record") + .into(), + ]); + + let tempo_display = text(format!("{:.1} BPM", tempo)).size(18); + let time_sig_display = text(format!("{}/{}", ts_num, ts_den)).size(18); + + let (cyc_u, cyc_f) = icons.get(Icon::Cycle); + let (met_u, met_f) = icons.get(Icon::Metronome); + let (cnt_u, cnt_f) = icons.get(Icon::CountIn); + + let mode_toggles = button_group(vec![ + IconButton::new(cyc_u, cyc_f, Message::CycleToggled) + .size(37.0) + .toggled(cycle_enabled) + .active_tint(Color::from_rgb8(0xFF, 0xA5, 0x00)) + .hint("Cycle") + .into(), + IconButton::new(met_u, met_f, Message::MetronomeToggled) + .size(37.0) + .toggled(metronome_enabled) + .hint("Metronome") + .into(), + IconButton::new(cnt_u, cnt_f, Message::CountInToggled) + .size(37.0) + .toggled(count_in_enabled) + .hint("Count In") + .into(), + ]); + + let (insp_u, insp_f) = icons.get(Icon::ViewInspector); + let (edit_u, edit_f) = icons.get(Icon::ViewEditor); + let (mix_u, mix_f) = icons.get(Icon::ViewMixer); + let (viz_u, viz_f) = icons.get(Icon::ViewVisualizer); + + let view_toggles = button_group(vec![ + IconButton::new(insp_u, insp_f, Message::ToggleInspector) + .size(37.0) + .toggled(show_inspector) + .active_tint(Color::from_rgb8(0x66, 0x66, 0x66)) + .hint("Inspector") + .into(), + IconButton::new( + edit_u, + edit_f, + Message::SetBottomPanelMode(BottomPanelMode::Editor), + ) + .size(37.0) + .toggled(show_bottom_panel && *bottom_panel_mode == BottomPanelMode::Editor) + .active_tint(Color::from_rgb8(0x66, 0x66, 0x66)) + .hint("Editor") + .into(), + IconButton::new( + mix_u, + mix_f, + Message::SetBottomPanelMode(BottomPanelMode::Mixer), + ) + .size(37.0) + .toggled(show_bottom_panel && *bottom_panel_mode == BottomPanelMode::Mixer) + .active_tint(Color::from_rgb8(0x66, 0x66, 0x66)) + .hint("Mixer") + .into(), + IconButton::new( + viz_u, + viz_f, + Message::SetBottomPanelMode(BottomPanelMode::Visualizer), + ) + .size(37.0) + .toggled(show_bottom_panel && *bottom_panel_mode == BottomPanelMode::Visualizer) + .active_tint(Color::from_rgb8(0x55, 0x88, 0xBB)) + .hint("Visualizer") + .into(), + ]); + + let left = Space::new(Length::Fill, 0); + let right = Space::new(Length::Fill, 0); + + let bar = row![ + view_toggles, + left, + lcd, + transport_controls, + tempo_display, + time_sig_display, + mode_toggles, + right, + ] + .spacing(14) + .padding([9, 16]) + .align_y(Alignment::Center); + + container(bar) + .width(Length::Fill) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8(0x28, 0x2A, 0x2C))), + ..container::Style::default() + }) + .into() +} diff --git a/au-o2-gui/src/gui/editor/editor_pane.rs b/au-o2-gui/src/gui/editor/editor_pane.rs new file mode 100644 index 0000000..208122c --- /dev/null +++ b/au-o2-gui/src/gui/editor/editor_pane.rs @@ -0,0 +1,45 @@ +use crate::editor::Message; +use crate::track::Track; +use iced::widget::{column, container, horizontal_rule, text}; +use iced::{Background, Color, Element, Length, Theme}; + +pub fn view<'a>(selected_track: Option<&'a Track>) -> Element<'a, Message> { + let header = text("Editor").size(16).color(Color::from_rgb8(0xAA, 0xAA, 0xAA)); + + let content = if let Some(track) = selected_track { + let type_label = match track.track_type { + crate::track::TrackType::Audio => "Waveform Editor", + crate::track::TrackType::Midi => "Piano Roll", + }; + column![ + header, + horizontal_rule(1), + text(format!("{} - {}", track.name, type_label)).size(12), + text("(editor canvas placeholder)").size(10).color(Color::from_rgb8(0x77, 0x77, 0x77)), + ] + .spacing(4) + .padding(8) + } else { + column![ + header, + horizontal_rule(1), + text("Select a track to edit").size(12).color(Color::from_rgb8(0x77, 0x77, 0x77)), + ] + .spacing(4) + .padding(8) + }; + + container(content) + .width(Length::Fill) + .height(Length::Fill) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8(0x28, 0x2A, 0x2C))), + border: iced::Border { + color: Color::from_rgb8(0x44, 0x46, 0x48), + width: 1.0, + ..iced::Border::default() + }, + ..container::Style::default() + }) + .into() +} diff --git a/au-o2-gui/src/gui/editor/inspector.rs b/au-o2-gui/src/gui/editor/inspector.rs new file mode 100644 index 0000000..8693f00 --- /dev/null +++ b/au-o2-gui/src/gui/editor/inspector.rs @@ -0,0 +1,166 @@ +use crate::config::ProjectConfig; +use crate::editor::Message; +use crate::modules::registry::BUILTIN_MODULES; +use crate::track::Track; +use iced::widget::{ + button, column, container, horizontal_rule, pick_list, row, text, vertical_rule, Column, +}; +use iced::{Background, Color, Element, Length, Theme}; +use std::collections::HashMap; + +pub fn view<'a>( + selected_track: Option<&'a Track>, + project_config: &'a ProjectConfig, + module_names: &'a HashMap, + track_index: Option, + hilbert_fft_size: usize, + visualizer_buffer_size: usize, +) -> Element<'a, Message> { + let header = text("Inspector").size(16).color(Color::from_rgb8(0xAA, 0xAA, 0xAA)); + + let content = if let Some(track) = selected_track { + let color_swatch = container(text("").width(12).height(12)) + .style(move |_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8( + track.color.r, + track.color.g, + track.color.b, + ))), + border: iced::Border { + radius: 4.0.into(), + ..iced::Border::default() + }, + ..container::Style::default() + }); + + let mut modules_col = Column::new().spacing(2); + for &module_id in &track.module_chain { + let name = module_names + .get(&module_id) + .map(|s| s.as_str()) + .unwrap_or("Unknown"); + let track_idx = track_index.unwrap_or(0); + let remove_btn = button(text("x").size(10)) + .on_press(Message::RemoveModuleFromTrack(track_idx, module_id)) + .padding([1, 4]) + .style(|_theme: &Theme, _status| button::Style { + background: Some(Background::Color(Color::TRANSPARENT)), + text_color: Color::from_rgb8(0xCC, 0x55, 0x55), + ..button::Style::default() + }); + modules_col = modules_col.push( + row![text(name).size(10), remove_btn] + .spacing(4) + .align_y(iced::Alignment::Center), + ); + } + + let mut add_col = Column::new().spacing(2); + if let Some(idx) = track_index { + for desc in BUILTIN_MODULES.iter().filter(|d| !d.system) { + add_col = add_col.push( + button(text(desc.display_name).size(10)) + .on_press(Message::AddModuleToTrack(idx, desc.type_name.to_string())) + .padding([2, 6]) + .style(|_theme: &Theme, status| { + let bg = match status { + button::Status::Hovered => Color::from_rgb8(0x44, 0x66, 0x88), + _ => Color::from_rgb8(0x38, 0x3A, 0x3C), + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: Color::from_rgb8(0xDD, 0xDD, 0xDD), + border: iced::Border { + radius: 5.0.into(), + ..iced::Border::default() + }, + ..button::Style::default() + } + }), + ); + } + } + + let has_visualizer = track.module_chain.iter().any(|id| { + module_names.get(id).is_some_and(|n| n == "spiral_visualizer") + }); + + let mut col = column![ + header, + horizontal_rule(1), + row![color_swatch, text(&track.name).size(16)].spacing(6), + text(format!("Type: {}", track.track_type)).size(13), + horizontal_rule(1), + text("Volume").size(10).color(Color::from_rgb8(0x99, 0x99, 0x99)), + text(format!("{:.0}%", track.volume * 100.0)).size(12), + text("Pan").size(10).color(Color::from_rgb8(0x99, 0x99, 0x99)), + text(format!("{:+.0}", track.pan * 100.0)).size(12), + horizontal_rule(1), + text("Modules").size(10).color(Color::from_rgb8(0x99, 0x99, 0x99)), + modules_col, + text("Add Module").size(10).color(Color::from_rgb8(0x99, 0x99, 0x99)), + add_col, + ] + .spacing(4) + .padding(8); + + let fft_sizes: Vec = vec![512, 1024, 2048, 4096, 8192]; + let fft_picker = pick_list( + fft_sizes, + Some(hilbert_fft_size), + |s| Message::SetHilbertFftSize(s), + ).width(80); + + col = col.push(horizontal_rule(1)); + col = col.push(text("Analysis").size(10).color(Color::from_rgb8(0x99, 0x99, 0x99))); + col = col.push( + row![text("FFT Size").size(10).width(80), fft_picker] + .spacing(4).align_y(iced::Alignment::Center), + ); + + if has_visualizer { + let viz_sizes: Vec = vec![1024, 2048, 4096, 8192]; + let viz_picker = pick_list( + viz_sizes, + Some(visualizer_buffer_size), + |s| Message::SetVisualizerBufferSize(s), + ).width(80); + + col = col.push( + row![text("Viz Buffer").size(10).width(80), viz_picker] + .spacing(4).align_y(iced::Alignment::Center), + ); + } + + col = col.push(horizontal_rule(1)); + col = col.push(text("Bus").size(10).color(Color::from_rgb8(0x99, 0x99, 0x99))); + col = col.push(text(&track.bus_name).size(10)); + col = col.push(text(format!("Regions: {}", track.regions.len())).size(10)); + + col + } else { + column![ + header, + horizontal_rule(1), + text(&project_config.name).size(14), + text(format!("{}Hz / {} buf", project_config.sample_rate, project_config.output_buffer_size)).size(10), + text(format!("{:.1} BPM", project_config.tempo)).size(10), + text(format!("{}/{}", project_config.time_signature_numerator, project_config.time_signature_denominator)).size(10), + text(format!("Device: {}", project_config.audio_device)).size(10), + ] + .spacing(4) + .padding(8) + }; + + row![ + container(content) + .width(230) + .height(Length::Fill) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8(0x2C, 0x2E, 0x30))), + ..container::Style::default() + }), + vertical_rule(1), + ] + .into() +} diff --git a/au-o2-gui/src/gui/editor/menu_bar.rs b/au-o2-gui/src/gui/editor/menu_bar.rs new file mode 100644 index 0000000..a5cb543 --- /dev/null +++ b/au-o2-gui/src/gui/editor/menu_bar.rs @@ -0,0 +1,395 @@ +use crate::behaviors; +use crate::editor::Message as EditorMessage; +use iced::widget::{button, container, row, text, Column, Space}; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme}; + +pub const BAR_HEIGHT: f32 = 28.0; +const TITLE_WIDTH: f32 = 86.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MenuId { + File, + Edit, + Transport, + View, +} + +impl MenuId { + const ALL: [MenuId; 4] = [MenuId::File, MenuId::Edit, MenuId::Transport, MenuId::View]; + + fn label(&self) -> &'static str { + match self { + MenuId::File => "File", + MenuId::Edit => "Edit", + MenuId::Transport => "Transport", + MenuId::View => "View", + } + } +} + +pub struct State { + pub open: Option, +} + +impl State { + pub fn new() -> Self { + Self { open: None } + } +} + +#[derive(Debug, Clone)] +pub enum Message { + Open(MenuId), + Close, + Action(behaviors::Action), + ShowNewTrackWizard, +} + +struct MenuItem { + label: &'static str, + shortcut: &'static str, + message: Message, +} + +enum MenuEntry { + Item(MenuItem), + Separator, +} + +fn file_entries() -> Vec { + use behaviors::Action::*; + vec![ + MenuEntry::Item(MenuItem { + label: "New Project", + shortcut: "\u{2318}N", + message: Message::Action(NewProject), + }), + MenuEntry::Item(MenuItem { + label: "Open\u{2026}", + shortcut: "\u{2318}O", + message: Message::Action(OpenProject), + }), + MenuEntry::Separator, + MenuEntry::Item(MenuItem { + label: "Save", + shortcut: "\u{2318}S", + message: Message::Action(SaveProject), + }), + MenuEntry::Item(MenuItem { + label: "Save As\u{2026}", + shortcut: "\u{21E7}\u{2318}S", + message: Message::Action(SaveProjectAs), + }), + MenuEntry::Separator, + MenuEntry::Item(MenuItem { + label: "Close", + shortcut: "\u{2318}W", + message: Message::Action(CloseProject), + }), + MenuEntry::Separator, + MenuEntry::Item(MenuItem { + label: "Settings", + shortcut: "\u{2318},", + message: Message::Action(OpenSettings), + }), + ] +} + +fn edit_entries() -> Vec { + use behaviors::Action::*; + vec![ + MenuEntry::Item(MenuItem { + label: "Undo", + shortcut: "\u{2318}Z", + message: Message::Action(Undo), + }), + MenuEntry::Item(MenuItem { + label: "Redo", + shortcut: "\u{21E7}\u{2318}Z", + message: Message::Action(Redo), + }), + MenuEntry::Separator, + MenuEntry::Item(MenuItem { + label: "Cut", + shortcut: "\u{2318}X", + message: Message::Action(Cut), + }), + MenuEntry::Item(MenuItem { + label: "Copy", + shortcut: "\u{2318}C", + message: Message::Action(Copy), + }), + MenuEntry::Item(MenuItem { + label: "Paste", + shortcut: "\u{2318}V", + message: Message::Action(Paste), + }), + MenuEntry::Item(MenuItem { + label: "Duplicate", + shortcut: "\u{2318}D", + message: Message::Action(Duplicate), + }), + MenuEntry::Separator, + MenuEntry::Item(MenuItem { + label: "Select All", + shortcut: "\u{2318}A", + message: Message::Action(SelectAll), + }), + MenuEntry::Item(MenuItem { + label: "Delete", + shortcut: "\u{232B}", + message: Message::Action(Delete), + }), + ] +} + +fn transport_entries() -> Vec { + use behaviors::Action::*; + vec![ + MenuEntry::Item(MenuItem { + label: "Play/Pause", + shortcut: "Space", + message: Message::Action(EditorTogglePlayback), + }), + MenuEntry::Item(MenuItem { + label: "Stop", + shortcut: "", + message: Message::Action(EditorStop), + }), + MenuEntry::Item(MenuItem { + label: "Record", + shortcut: "R", + message: Message::Action(EditorToggleRecord), + }), + MenuEntry::Separator, + MenuEntry::Item(MenuItem { + label: "From Start", + shortcut: "\u{21B5}", + message: Message::Action(EditorPlayFromBeginning), + }), + MenuEntry::Item(MenuItem { + label: "Rewind", + shortcut: ",", + message: Message::Action(EditorRewind), + }), + MenuEntry::Separator, + MenuEntry::Item(MenuItem { + label: "New Track\u{2026}", + shortcut: "", + message: Message::ShowNewTrackWizard, + }), + ] +} + +fn view_entries() -> Vec { + use behaviors::Action::*; + vec![ + MenuEntry::Item(MenuItem { + label: "Inspector", + shortcut: "I", + message: Message::Action(EditorToggleInspector), + }), + MenuEntry::Item(MenuItem { + label: "Bottom Panel", + shortcut: "E", + message: Message::Action(EditorToggleBottomPanel), + }), + MenuEntry::Item(MenuItem { + label: "Mixer", + shortcut: "X", + message: Message::Action(EditorToggleMixer), + }), + MenuEntry::Separator, + MenuEntry::Item(MenuItem { + label: "Cycle", + shortcut: "C", + message: Message::Action(EditorToggleCycle), + }), + MenuEntry::Item(MenuItem { + label: "Metronome", + shortcut: "K", + message: Message::Action(EditorToggleMetronome), + }), + MenuEntry::Separator, + MenuEntry::Item(MenuItem { + label: "Zoom In H", + shortcut: "\u{2318}\u{2192}", + message: Message::Action(ZoomInH), + }), + MenuEntry::Item(MenuItem { + label: "Zoom Out H", + shortcut: "\u{2318}\u{2190}", + message: Message::Action(ZoomOutH), + }), + MenuEntry::Item(MenuItem { + label: "Zoom In V", + shortcut: "\u{2318}\u{2191}", + message: Message::Action(ZoomInV), + }), + MenuEntry::Item(MenuItem { + label: "Zoom Out V", + shortcut: "\u{2318}\u{2193}", + message: Message::Action(ZoomOutV), + }), + ] +} + +fn entries_for(id: MenuId) -> Vec { + match id { + MenuId::File => file_entries(), + MenuId::Edit => edit_entries(), + MenuId::Transport => transport_entries(), + MenuId::View => view_entries(), + } +} + +fn dropdown_x_offset(id: MenuId) -> f32 { + match id { + MenuId::File => 0.0, + MenuId::Edit => TITLE_WIDTH, + MenuId::Transport => TITLE_WIDTH * 2.0, + MenuId::View => TITLE_WIDTH * 3.0, + } +} + +pub fn view(state: &State) -> Element<'_, EditorMessage> { + let titles: Vec> = MenuId::ALL + .iter() + .map(|&id| { + let is_open = state.open == Some(id); + let label_color = if is_open { + Color::WHITE + } else { + Color::from_rgb8(0xCC, 0xCC, 0xCC) + }; + + button(text(id.label()).size(14).color(label_color)) + .on_press(EditorMessage::MenuBar(Message::Open(id))) + .padding([5, 14]) + .width(TITLE_WIDTH) + .style(move |_theme: &Theme, status| { + let bg = match status { + button::Status::Hovered | button::Status::Pressed => { + Color::from_rgb8(0x3A, 0x3C, 0x3E) + } + _ if is_open => Color::from_rgb8(0x3A, 0x3C, 0x3E), + _ => Color::TRANSPARENT, + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: label_color, + border: Border::default(), + ..button::Style::default() + } + }) + .into() + }) + .collect(); + + let bar = row(titles).spacing(0).align_y(Alignment::Center); + + container(bar) + .width(Length::Fill) + .height(BAR_HEIGHT) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8(0x28, 0x2A, 0x2C))), + ..container::Style::default() + }) + .into() +} + +pub fn dropdown_view(state: &State) -> Option> { + let menu_id = state.open?; + let entries = entries_for(menu_id); + + let mut items: Column<'_, EditorMessage> = Column::new().spacing(0).width(220); + + for entry in entries { + match entry { + MenuEntry::Item(item) => { + let msg = EditorMessage::MenuBar(item.message); + let shortcut_el: Element<'_, EditorMessage> = if item.shortcut.is_empty() { + Space::new(0, 0).into() + } else { + text(item.shortcut) + .size(13) + .color(Color::from_rgb8(0x88, 0x88, 0x88)) + .into() + }; + + let item_row = row![ + text(item.label).size(14), + Space::with_width(Length::Fill), + shortcut_el, + ] + .align_y(Alignment::Center) + .spacing(8); + + let item_btn = button(item_row) + .on_press(msg) + .width(Length::Fill) + .padding([6, 14]) + .style(|_theme: &Theme, status| { + let bg = match status { + button::Status::Hovered => Color::from_rgb8(0x00, 0x7A, 0xFF), + button::Status::Pressed => Color::from_rgb8(0x00, 0x6A, 0xDD), + _ => Color::TRANSPARENT, + }; + let text_color = match status { + button::Status::Hovered | button::Status::Pressed => Color::WHITE, + _ => Color::from_rgb8(0xDD, 0xDD, 0xDD), + }; + button::Style { + background: Some(Background::Color(bg)), + text_color, + border: Border::default(), + ..button::Style::default() + } + }); + + items = items.push(item_btn); + } + MenuEntry::Separator => { + let line = container(Space::new(0, 0)) + .width(Length::Fill) + .height(1) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8(0x44, 0x46, 0x48))), + ..container::Style::default() + }); + items = items.push(container(line).padding([4, 8])); + } + } + } + + let dropdown = container(items) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8(0x32, 0x34, 0x36))), + border: Border { + color: Color::from_rgb8(0x48, 0x4A, 0x4C), + width: 1.0, + radius: 6.0.into(), + }, + ..container::Style::default() + }) + .padding(5); + + let offset = dropdown_x_offset(menu_id); + + let positioned: Element<'_, EditorMessage> = container(row![ + Space::new(offset, 0), + dropdown, + Space::with_width(Length::Fill), + ]) + .width(Length::Fill) + .height(Length::Fill) + .padding(Padding { + top: BAR_HEIGHT, + right: 0.0, + bottom: 0.0, + left: 0.0, + }) + .into(); + + Some(positioned) +} diff --git a/au-o2-gui/src/gui/editor/mixer.rs b/au-o2-gui/src/gui/editor/mixer.rs new file mode 100644 index 0000000..3d8c08f --- /dev/null +++ b/au-o2-gui/src/gui/editor/mixer.rs @@ -0,0 +1,180 @@ +use crate::editor::Message; +use crate::gui::editor::track_header; +use crate::gui::icon_button::IconButton; +use crate::gui::icons::{Icon, IconSet}; +use crate::gui::styles::oxide_slider; +use crate::track::Track; +use iced::widget::{column, container, horizontal_rule, scrollable, slider, text, Row}; +use iced::{Alignment, Background, Color, Element, Length, Theme}; + +fn vol_db(v: f32) -> String { + if v > 0.0 { + format!("{:.1} dB", 20.0 * v.log10()) + } else { + "-inf".into() + } +} + +fn pan_label(pan: f32) -> String { + if pan == 0.0 { + "C".into() + } else if pan < 0.0 { + format!("L{:.0}", -pan * 100.0) + } else { + format!("R{:.0}", pan * 100.0) + } +} + +pub fn view<'a>(tracks: &'a [Track], icons: &'a IconSet) -> Element<'a, Message> { + let header = container( + text("Mixer").size(14).color(Color::from_rgb8(0xAA, 0xAA, 0xAA)), + ) + .padding([4, 8]); + + let (mute_u, mute_f) = icons.get(Icon::Mute); + let (solo_u, solo_f) = icons.get(Icon::Solo); + let (rec_u, rec_f) = icons.get(Icon::RecordArm); + + let strips: Vec> = tracks + .iter() + .enumerate() + .map(|(i, track)| { + let color_bar = container(text("").width(Length::Fill).height(4)) + .style(move |_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8( + track.color.r, + track.color.g, + track.color.b, + ))), + ..container::Style::default() + }); + + let name = container( + text(&track.name).size(10).color(Color::from_rgb8(0xDD, 0xDD, 0xDD)), + ) + .width(Length::Fill) + .align_x(Alignment::Center); + + let mute_btn: Element<'a, Message> = IconButton::new( + mute_u, + mute_f, + Message::TrackHeader(i, track_header::Message::MuteToggled), + ) + .size(20.0) + .toggled(track.muted) + .active_tint(Color::from_rgb8(0xCC, 0xA7, 0x00)) + .hint("Mute") + .into(); + + let solo_btn: Element<'a, Message> = IconButton::new( + solo_u, + solo_f, + Message::TrackHeader(i, track_header::Message::SoloToggled), + ) + .size(20.0) + .toggled(track.soloed) + .active_tint(Color::from_rgb8(0x00, 0x7A, 0xFF)) + .hint("Solo") + .into(); + + let rec_btn: Element<'a, Message> = IconButton::new( + rec_u, + rec_f, + Message::TrackHeader(i, track_header::Message::RecordArmToggled), + ) + .size(20.0) + .toggled(track.record_armed) + .active_tint(Color::from_rgb8(0xCC, 0x33, 0x33)) + .hint("Rec") + .into(); + + let msr_row = iced::widget::row![mute_btn, solo_btn, rec_btn] + .spacing(2) + .align_y(Alignment::Center); + + let vol_slider = slider(0.0..=1.0, track.volume, move |v| { + Message::TrackHeader(i, track_header::Message::VolumeChanged(v)) + }) + .step(0.01) + .default(0.75) + .style(oxide_slider) + .width(Length::Fill); + + let vol_text = text(vol_db(track.volume)) + .size(9) + .color(Color::from_rgb8(0xBB, 0xBB, 0xBB)); + + let pan_slider = slider(-1.0..=1.0, track.pan, move |p| { + Message::TrackHeader(i, track_header::Message::PanChanged(p)) + }) + .step(0.01) + .default(0.0) + .style(oxide_slider) + .width(Length::Fill); + + let pan_text = text(pan_label(track.pan)) + .size(9) + .color(Color::from_rgb8(0xBB, 0xBB, 0xBB)); + + let bus_label = text(&track.bus_name) + .size(8) + .color(Color::from_rgb8(0x77, 0x77, 0x77)); + + container( + column![ + color_bar, + name, + msr_row, + vol_slider, + vol_text, + pan_slider, + pan_text, + bus_label, + ] + .spacing(4) + .padding([4, 6]) + .align_x(Alignment::Center), + ) + .width(100) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8(0x32, 0x34, 0x36))), + border: iced::Border { + color: Color::from_rgb8(0x44, 0x46, 0x48), + width: 1.0, + radius: 2.0.into(), + }, + ..container::Style::default() + }) + .into() + }) + .collect(); + + let strip_row = scrollable( + Row::with_children(strips) + .spacing(2) + .padding(4), + ) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::default(), + )) + .width(Length::Fill); + + container( + column![header, horizontal_rule(1), strip_row] + .spacing(0) + .width(Length::Fill) + .height(Length::Fill), + ) + .width(Length::Fill) + .height(Length::Fill) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8(0x28, 0x2A, 0x2C))), + border: iced::Border { + color: Color::from_rgb8(0x44, 0x46, 0x48), + width: 1.0, + ..iced::Border::default() + }, + ..container::Style::default() + }) + .into() +} diff --git a/au-o2-gui/src/gui/editor/mod.rs b/au-o2-gui/src/gui/editor/mod.rs new file mode 100644 index 0000000..82b42fc --- /dev/null +++ b/au-o2-gui/src/gui/editor/mod.rs @@ -0,0 +1,9 @@ +pub mod control_bar; +pub mod editor_pane; +pub mod inspector; +pub mod mixer; +pub mod new_track_wizard; +pub mod timeline; +pub mod toolbar; +pub mod track_header; +pub mod visualizer; diff --git a/au-o2-gui/src/gui/editor/new_track_wizard.rs b/au-o2-gui/src/gui/editor/new_track_wizard.rs new file mode 100644 index 0000000..7c0214e --- /dev/null +++ b/au-o2-gui/src/gui/editor/new_track_wizard.rs @@ -0,0 +1,80 @@ +use crate::track::{TrackConfig, TrackType}; +use iced::widget::{button, column, container, pick_list, row, text, text_input}; +use iced::{Alignment, Background, Border, Element, Length, Theme}; + +#[derive(Debug, Clone)] +pub struct State { + pub config: TrackConfig, +} + +impl Default for State { + fn default() -> Self { + Self { + config: TrackConfig::default(), + } + } +} + +const BUFFER_SIZES: [u32; 8] = [32, 64, 128, 256, 512, 1024, 2048, 4096]; + +#[derive(Debug, Clone)] +pub enum Message { + NameChanged(String), + TrackTypeSelected(TrackType), + InputBufferSizeSelected(u32), + Cancel, + Create, +} + +pub fn view(state: &State) -> Element<'_, Message> { + let title = text("Create New Track").size(26); + + let name_input = text_input("Track Name", &state.config.name) + .on_input(Message::NameChanged); + + let type_picker = pick_list( + &TrackType::ALL[..], + Some(state.config.track_type), + Message::TrackTypeSelected, + ); + + let buf_picker = pick_list( + &BUFFER_SIZES[..], + Some(state.config.input_buffer_size), + Message::InputBufferSizeSelected, + ) + .width(120); + + let controls = column![ + row![text("Name:").width(120), name_input].spacing(10).align_y(Alignment::Center), + row![text("Type:").width(120), type_picker].spacing(10).align_y(Alignment::Center), + row![text("Input Buffer:").width(120), buf_picker].spacing(10).align_y(Alignment::Center), + ] + .spacing(15); + + let action_buttons = row![ + button("Cancel").on_press(Message::Cancel), + button("Create").on_press(Message::Create), + ] + .spacing(10); + + let content = column![title, controls, action_buttons] + .spacing(20) + .padding(20) + .align_x(Alignment::Center); + + container(content) + .max_width(440) + .height(Length::Shrink) + .style(|theme: &Theme| container::Style { + background: Some(Background::from( + theme.extended_palette().background.weak.color, + )), + border: Border { + radius: 8.0.into(), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} diff --git a/au-o2-gui/src/gui/editor/timeline.rs b/au-o2-gui/src/gui/editor/timeline.rs new file mode 100644 index 0000000..7909b91 --- /dev/null +++ b/au-o2-gui/src/gui/editor/timeline.rs @@ -0,0 +1,672 @@ +use crate::config::ProjectConfig; +use crate::editor::Tool; +use crate::timing::{MusicalTime, TICKS_PER_BEAT}; +use crate::track::{Track, TRACK_HEIGHT}; +use crate::waveform::WaveformCache; +use iced::widget::canvas::{self, Path, Stroke, Text}; +use iced::widget::Canvas; +use iced::{alignment, mouse, Color, Element, Length, Point, Rectangle, Renderer, Size, Theme}; +use uuid::Uuid; + +const RULER_HEIGHT: f32 = 34.0; +const ZOOM_SENSITIVITY: f32 = 0.005; +const _RESIZE_HANDLE_WIDTH: f32 = 6.0; + +fn bar_groupings(num: u8, den: u8) -> (u32, u32) { + match (num, den) { + (4, 4) => (4, 16), + (3, 4) => (3, 12), + (5, 4) => (2, 10), + (6, 8) => (2, 6), + (3, 8) => (3, 12), + (5, 8) => (4, 20), + (7, 8) => (2, 14), + (2, 4) => (4, 16), + (2, 2) => (4, 16), + (n, _) if n % 4 == 0 => (4, 16), + (n, _) if n % 3 == 0 => (3, 12), + (n, _) if n % 2 == 0 => (2, 8), + _ => (4, 16), + } +} + +#[derive(Debug, Clone)] +pub enum Message { + ZoomChanged(f32, f32), + PlayheadMoved(MusicalTime), + RegionClicked { track_index: usize, region_id: Uuid, shift: bool }, + RegionMoved { region_id: Uuid, track_index: usize, new_start: MusicalTime, new_start_sample: u64 }, + RegionSplit { track_index: usize, region_id: Uuid, split_sample: u64 }, + RegionDelete { track_index: usize, region_id: Uuid }, + DeselectAll, +} + +pub fn view<'a>( + project_config: &'a ProjectConfig, + tracks: &'a [Track], + playhead_position: MusicalTime, + active_tool: Tool, + h_zoom: f32, + v_zoom: f32, + recording: bool, + waveforms: &'a WaveformCache, +) -> Element<'a, Message> { + let effective_track_height = TRACK_HEIGHT * v_zoom; + let timeline_height = tracks.len() as f32 * effective_track_height; + + Canvas::new(Timeline { + config: project_config, + tracks, + playhead_position, + active_tool, + h_zoom, + v_zoom, + recording, + waveforms, + }) + .width(Length::Fill) + .height(timeline_height + RULER_HEIGHT) + .into() +} + +#[derive(Debug)] +pub struct Timeline<'a> { + config: &'a ProjectConfig, + tracks: &'a [Track], + playhead_position: MusicalTime, + active_tool: Tool, + h_zoom: f32, + v_zoom: f32, + recording: bool, + waveforms: &'a WaveformCache, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +enum DragMode { + Playhead, + MoveRegion { + region_id: Uuid, + track_index: usize, + offset_x: f32, + start_x: f32, + start_track: usize, + }, +} + +pub struct TimelineState { + right_drag_start: Option, + right_drag_zoom_start: (f32, f32), + drag_mode: Option, +} + +impl Default for TimelineState { + fn default() -> Self { + Self { + right_drag_start: None, + right_drag_zoom_start: (100.0, 1.0), + drag_mode: None, + } + } +} + +impl<'a> canvas::Program for Timeline<'a> { + type State = TimelineState; + + fn update( + &self, + state: &mut Self::State, + event: canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> (canvas::event::Status, Option) { + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if let Some(pos) = cursor.position_in(bounds) { + if pos.y < RULER_HEIGHT { + let time = self.x_to_time(pos.x); + state.drag_mode = Some(DragMode::Playhead); + return (canvas::event::Status::Captured, Some(Message::PlayheadMoved(time))); + } + + if let Some((track_idx, region_id, region_x)) = self.hit_test_region(pos) { + match self.active_tool { + Tool::Pointer => { + let offset = pos.x - region_x; + state.drag_mode = Some(DragMode::MoveRegion { + region_id, + track_index: track_idx, + offset_x: offset, + start_x: region_x, + start_track: track_idx, + }); + return (canvas::event::Status::Captured, Some(Message::RegionClicked { + track_index: track_idx, + region_id, + shift: false, + })); + } + Tool::Scissors => { + let split_sample = self.x_to_sample(pos.x); + return (canvas::event::Status::Captured, Some(Message::RegionSplit { + track_index: track_idx, + region_id, + split_sample, + })); + } + Tool::Eraser => { + return (canvas::event::Status::Captured, Some(Message::RegionDelete { + track_index: track_idx, + region_id, + })); + } + _ => { + return (canvas::event::Status::Captured, Some(Message::RegionClicked { + track_index: track_idx, + region_id, + shift: false, + })); + } + } + } + + // Click on empty timeline area + if pos.y >= RULER_HEIGHT { + state.drag_mode = Some(DragMode::Playhead); + return (canvas::event::Status::Captured, Some(Message::DeselectAll)); + } + } + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if let Some(pos) = cursor.position_in(bounds) { + match &state.drag_mode { + Some(DragMode::Playhead) => { + let time = self.x_to_time(pos.x.max(0.0)); + return (canvas::event::Status::Captured, Some(Message::PlayheadMoved(time))); + } + Some(DragMode::MoveRegion { region_id, offset_x, .. }) => { + let new_x = (pos.x - offset_x).max(0.0); + let new_time = self.x_to_time(new_x); + let new_sample = self.x_to_sample(new_x); + let th = self.effective_track_height(); + let new_track = ((pos.y - RULER_HEIGHT) / th).floor().max(0.0) as usize; + let new_track = new_track.min(self.tracks.len().saturating_sub(1)); + return (canvas::event::Status::Captured, Some(Message::RegionMoved { + region_id: *region_id, + track_index: new_track, + new_start: new_time, + new_start_sample: new_sample, + })); + } + None => {} + } + } + if let (Some(start), Some(pos)) = (state.right_drag_start, cursor.position_in(bounds)) { + let dx = pos.x - start.x; + let dy = start.y - pos.y; + let (start_h, start_v) = state.right_drag_zoom_start; + let new_h = (start_h * (1.0 + dx * ZOOM_SENSITIVITY)).clamp(10.0, 1000.0); + let new_v = (start_v * (1.0 + dy * ZOOM_SENSITIVITY)).clamp(0.3, 5.0); + return (canvas::event::Status::Captured, Some(Message::ZoomChanged(new_h, new_v))); + } + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + if state.drag_mode.is_some() { + state.drag_mode = None; + return (canvas::event::Status::Captured, None); + } + } + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { + if let Some(pos) = cursor.position_in(bounds) { + state.right_drag_start = Some(pos); + state.right_drag_zoom_start = (self.h_zoom, self.v_zoom); + return (canvas::event::Status::Captured, None); + } + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) => { + if state.right_drag_start.is_some() { + state.right_drag_start = None; + return (canvas::event::Status::Captured, None); + } + } + _ => {} + } + (canvas::event::Status::Ignored, None) + } + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + let palette = theme.palette(); + + self.draw_track_backgrounds(&mut frame, bounds, &palette); + self.draw_grid(&mut frame, bounds); + self.draw_regions(&mut frame, bounds); + if self.recording { + self.draw_recording_indicator(&mut frame); + } + self.draw_ruler(&mut frame, bounds, &palette); + self.draw_playhead(&mut frame, bounds); + + vec![frame.into_geometry()] + } +} + +impl<'a> Timeline<'a> { + fn beats_per_bar(&self) -> f32 { + self.config.time_signature_numerator as f32 + } + + fn effective_track_height(&self) -> f32 { + TRACK_HEIGHT * self.v_zoom + } + + fn time_to_x(&self, time: &MusicalTime) -> f32 { + let total_beats = (time.bar as f32 - 1.0) * self.beats_per_bar() + + (time.beat as f32 - 1.0) + + time.tick as f32 / TICKS_PER_BEAT as f32; + total_beats * self.h_zoom + } + + fn x_to_time(&self, x: f32) -> MusicalTime { + let total_beats = x / self.h_zoom; + let beats_per_bar = self.beats_per_bar(); + let bar = (total_beats / beats_per_bar).floor() as u32 + 1; + let beat_in_bar = total_beats - (bar as f32 - 1.0) * beats_per_bar; + let beat = beat_in_bar.floor() as u32 + 1; + let tick = ((beat_in_bar - beat_in_bar.floor()) * TICKS_PER_BEAT as f32) as u32; + MusicalTime::new(bar, beat, tick) + } + + fn samples_to_width(&self, length_samples: u64) -> f32 { + let beats_per_second = self.config.tempo / 60.0; + let total_beats = length_samples as f32 / self.config.sample_rate as f32 * beats_per_second; + total_beats * self.h_zoom + } + + fn x_to_sample(&self, x: f32) -> u64 { + let total_beats = (x / self.h_zoom).max(0.0); + let beats_per_second = self.config.tempo as f64 / 60.0; + let samples_per_beat = self.config.sample_rate as f64 / beats_per_second; + (total_beats as f64 * samples_per_beat) as u64 + } + + fn sample_to_x(&self, sample: u64) -> f32 { + let beats_per_second = self.config.tempo / 60.0; + let total_beats = sample as f32 / self.config.sample_rate as f32 * beats_per_second; + total_beats * self.h_zoom + } + + fn region_rect(&self, track_index: usize, region: &crate::region::Region) -> (f32, f32, f32, f32) { + let th = self.effective_track_height(); + let x = self.time_to_x(®ion.start_time); + let w = if region.length_samples > 0 { + self.samples_to_width(region.length_samples) + } else { + self.time_to_x(®ion.duration) + }; + let y = RULER_HEIGHT + (track_index as f32 * th) + 4.0; + let h = th - 8.0; + (x, y, w.max(4.0), h) + } + + fn hit_test_region(&self, pos: Point) -> Option<(usize, Uuid, f32)> { + let th = self.effective_track_height(); + let track_index = ((pos.y - RULER_HEIGHT) / th).floor() as usize; + if track_index >= self.tracks.len() { return None; } + + let track = &self.tracks[track_index]; + for region in track.regions.iter().rev() { + let (rx, ry, rw, rh) = self.region_rect(track_index, region); + if pos.x >= rx && pos.x <= rx + rw && pos.y >= ry && pos.y <= ry + rh { + return Some((track_index, region.id, rx)); + } + } + None + } + + fn draw_track_backgrounds( + &self, + frame: &mut canvas::Frame, + bounds: Rectangle, + palette: &iced::theme::Palette, + ) { + let th = self.effective_track_height(); + for (i, track) in self.tracks.iter().enumerate() { + let y = RULER_HEIGHT + (i as f32 * th); + let track_bounds = + Rectangle::new(Point::new(0.0, y), Size::new(bounds.width, th)); + + let bg_color = if i % 2 == 1 { + Color::from_rgb8(0x35, 0x38, 0x3A) + } else { + Color::from_rgb8(0x30, 0x32, 0x34) + }; + frame.fill( + &Path::rectangle(track_bounds.position(), track_bounds.size()), + bg_color, + ); + + if track.selected { + frame.stroke( + &Path::rectangle(track_bounds.position(), track_bounds.size()), + Stroke::default() + .with_width(2.0) + .with_color(Color::from_rgb8(0x00, 0x7A, 0xFF)), + ); + } + + let line_path = Path::line( + Point::new(0.0, y + th), + Point::new(bounds.width, y + th), + ); + frame.stroke( + &line_path, + Stroke::default() + .with_width(1.0) + .with_color(palette.background), + ); + } + } + + fn draw_grid(&self, frame: &mut canvas::Frame, bounds: Rectangle) { + let ppb = self.h_zoom; + let beats_per_bar = self.beats_per_bar(); + let (med_group, big_group) = bar_groupings( + self.config.time_signature_numerator, + self.config.time_signature_denominator, + ); + + let show_beats = ppb >= 20.0; + let show_8th = (ppb / 2.0) >= 20.0; + let show_16th = (ppb / 4.0) >= 20.0; + + let subdiv: f32 = if show_16th { + 4.0 + } else if show_8th { + 2.0 + } else if show_beats { + 1.0 + } else { + beats_per_bar + }; + + let step = ppb / subdiv; + let count = (bounds.width / step).ceil() as i32; + + for i in 0..=count { + let x = i as f32 * step; + let beat_index = i as f32 / subdiv; + let is_bar = beat_index > 0.0 && beat_index % beats_per_bar == 0.0; + let is_beat = beat_index.fract() == 0.0; + + let bar_num = if is_bar { + (beat_index / beats_per_bar) as u32 + } else { + 0 + }; + let is_big_group = is_bar && bar_num > 0 && bar_num % big_group == 0; + let is_med_group = is_bar && bar_num > 0 && bar_num % med_group == 0; + + let (width, alpha) = if is_big_group { + (2.0, 0.25) + } else if is_med_group { + (1.5, 0.18) + } else if is_bar { + (1.0, 0.12) + } else if is_beat { + (0.5, 0.08) + } else { + (0.5, 0.04) + }; + + let line = Path::line(Point::new(x, RULER_HEIGHT), Point::new(x, bounds.height)); + frame.stroke( + &line, + Stroke::default() + .with_width(width) + .with_color(Color::from_rgba(1.0, 1.0, 1.0, alpha)), + ); + } + } + + fn draw_ruler( + &self, + frame: &mut canvas::Frame, + bounds: Rectangle, + palette: &iced::theme::Palette, + ) { + let ruler_bg = Path::rectangle(Point::new(0.0, 0.0), Size::new(bounds.width, RULER_HEIGHT)); + frame.fill(&ruler_bg, Color::from_rgb8(0x26, 0x28, 0x2A)); + + let sep = Path::line( + Point::new(0.0, RULER_HEIGHT - 1.0), + Point::new(bounds.width, RULER_HEIGHT - 1.0), + ); + frame.stroke( + &sep, + Stroke::default() + .with_width(1.0) + .with_color(Color::from_rgb8(0x4A, 0x4C, 0x4E)), + ); + + let beats_per_bar = self.config.time_signature_numerator as u32; + let (med_group, big_group) = bar_groupings( + self.config.time_signature_numerator, + self.config.time_signature_denominator, + ); + let last_bar = + ((bounds.width / self.h_zoom) / beats_per_bar as f32).ceil() as u32 + 1; + + if self.h_zoom >= 20.0 { + for bar in 1..=last_bar { + for beat in 1..beats_per_bar { + let x = ((bar - 1) as f32 * beats_per_bar as f32 + beat as f32) * self.h_zoom; + if x > bounds.width { break; } + let tick = Path::line( + Point::new(x, RULER_HEIGHT - 5.0), + Point::new(x, RULER_HEIGHT - 1.0), + ); + frame.stroke( + &tick, + Stroke::default() + .with_width(1.0) + .with_color(Color::from_rgba(1.0, 1.0, 1.0, 0.15)), + ); + } + } + } + + for bar in 1..=last_bar { + let x = (bar - 1) as f32 * beats_per_bar as f32 * self.h_zoom; + if x > bounds.width { break; } + + let bar_idx = bar - 1; + let is_big = bar_idx > 0 && bar_idx % big_group == 0; + let is_med = bar_idx > 0 && bar_idx % med_group == 0; + + let (tick_height, tick_width, tick_alpha) = if is_big { + (RULER_HEIGHT - 4.0, 2.0, 0.6) + } else if is_med { + (RULER_HEIGHT * 0.6, 1.5, 0.4) + } else { + (RULER_HEIGHT * 0.35, 1.0, 0.25) + }; + + let tick = Path::line( + Point::new(x, RULER_HEIGHT - 1.0 - tick_height), + Point::new(x, RULER_HEIGHT - 1.0), + ); + frame.stroke( + &tick, + Stroke::default() + .with_width(tick_width) + .with_color(Color::from_rgba(1.0, 1.0, 1.0, tick_alpha)), + ); + + let ppbar = beats_per_bar as f32 * self.h_zoom; + let show_label = if ppbar >= 40.0 { + true + } else if ppbar >= 20.0 { + is_med || bar == 1 + } else { + is_big || bar == 1 + }; + + if show_label { + let font_size = if is_big { 14.0 } else if is_med { 12.0 } else { 11.0 }; + let label_alpha = if is_big { 1.0 } else if is_med { 0.8 } else { 0.6 }; + let label = Text { + content: bar.to_string(), + position: Point::new(x + 3.0, 3.0), + color: Color { a: label_alpha, ..palette.text }, + size: iced::Pixels(font_size), + horizontal_alignment: alignment::Horizontal::Left, + ..Text::default() + }; + frame.fill_text(label); + } + } + } + + fn draw_regions(&self, frame: &mut canvas::Frame, _bounds: Rectangle) { + let th = self.effective_track_height(); + for (i, track) in self.tracks.iter().enumerate() { + let region_color = Color::from_rgba8( + track.color.r, + track.color.g, + track.color.b, + 0.7, + ); + let wave_color = Color::from_rgba8( + track.color.r.saturating_add(60), + track.color.g.saturating_add(60), + track.color.b.saturating_add(60), + 0.86, + ); + + for region in &track.regions { + let (x, y, w, h) = self.region_rect(i, region); + + // Region background + frame.fill( + &Path::rectangle(Point::new(x, y), Size::new(w, h)), + region_color, + ); + + // Waveform + if let Some(peaks) = self.waveforms.get(®ion.id) { + let num_pixels = w.ceil() as usize; + if num_pixels > 0 { + let display_peaks = peaks.peaks_for_pixel_range(0, peaks.total_samples, num_pixels); + let center_y = y + h * 0.5; + let half_h = h * 0.45; + + let waveform = Path::new(|builder| { + // Top half (max values) + for (px, &(_mn, mx)) in display_peaks.iter().enumerate() { + let px_x = x + px as f32; + let py = center_y - mx * half_h; + if px == 0 { + builder.move_to(Point::new(px_x, py)); + } else { + builder.line_to(Point::new(px_x, py)); + } + } + // Bottom half (min values, reversed) + for (px, &(mn, _mx)) in display_peaks.iter().enumerate().rev() { + let px_x = x + px as f32; + let py = center_y - mn * half_h; + builder.line_to(Point::new(px_x, py)); + } + builder.close(); + }); + frame.fill(&waveform, wave_color); + } + } + + // Selection border + if region.selected { + frame.stroke( + &Path::rectangle(Point::new(x, y), Size::new(w, h)), + Stroke::default() + .with_width(2.0) + .with_color(Color::WHITE), + ); + } + + // Region name (top-left, if wide enough) + if w > 40.0 { + if let Some(ref audio_file) = region.audio_file { + let name = std::path::Path::new(audio_file) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let label = Text { + content: name.to_string(), + position: Point::new(x + 4.0, y + 2.0), + color: Color::from_rgba(1.0, 1.0, 1.0, 0.8), + size: iced::Pixels(10.0), + horizontal_alignment: alignment::Horizontal::Left, + ..Text::default() + }; + frame.fill_text(label); + } + } + } + } + } + + fn draw_recording_indicator(&self, frame: &mut canvas::Frame) { + let th = self.effective_track_height(); + let playhead_x = self.time_to_x(&self.playhead_position); + let start_x = 0.0_f32; + + for (i, track) in self.tracks.iter().enumerate() { + if !track.record_armed { continue; } + let y = RULER_HEIGHT + (i as f32 * th) + 4.0; + let h = th - 8.0; + let w = (playhead_x - start_x).max(0.0); + if w > 0.0 { + frame.fill( + &Path::rectangle(Point::new(start_x, y), Size::new(w, h)), + Color::from_rgba(1.0, 0.19, 0.19, 0.2), + ); + frame.stroke( + &Path::rectangle(Point::new(start_x, y), Size::new(w, h)), + Stroke::default() + .with_width(1.0) + .with_color(Color::from_rgba(1.0, 0.19, 0.19, 0.53)), + ); + } + } + } + + fn draw_playhead(&self, frame: &mut canvas::Frame, bounds: Rectangle) { + let x = self.time_to_x(&self.playhead_position); + if x < 0.0 || x > bounds.width { + return; + } + let line = Path::line(Point::new(x, 0.0), Point::new(x, bounds.height)); + frame.stroke( + &line, + Stroke::default() + .with_width(2.0) + .with_color(Color::from_rgb8(0xFF, 0x30, 0x30)), + ); + + let tri = Path::new(|b| { + b.move_to(Point::new(x - 6.0, 0.0)); + b.line_to(Point::new(x + 6.0, 0.0)); + b.line_to(Point::new(x, 9.0)); + b.close(); + }); + frame.fill(&tri, Color::from_rgb8(0xFF, 0x30, 0x30)); + } +} diff --git a/au-o2-gui/src/gui/editor/toolbar.rs b/au-o2-gui/src/gui/editor/toolbar.rs new file mode 100644 index 0000000..9c7ba7b --- /dev/null +++ b/au-o2-gui/src/gui/editor/toolbar.rs @@ -0,0 +1,42 @@ +use crate::editor::{Message, Tool}; +use crate::gui::icon_button::{button_group, IconButton}; +use crate::gui::icons::{Icon, IconSet}; +use iced::widget::container; +use iced::{Background, Color, Element, Length, Theme}; + +fn tool_icon(tool: &Tool) -> Icon { + match tool { + Tool::Pointer => Icon::ToolPointer, + Tool::Pencil => Icon::ToolPencil, + Tool::Eraser => Icon::ToolEraser, + Tool::Scissors => Icon::ToolScissors, + Tool::Glue => Icon::ToolGlue, + Tool::Zoom => Icon::ToolZoom, + } +} + +pub fn view<'a>(active_tool: &Tool, icons: &'a IconSet) -> Element<'a, Message> { + let buttons: Vec> = Tool::ALL + .iter() + .map(|tool| { + let icon = tool_icon(tool); + let (u, f) = icons.get(icon); + IconButton::new(u, f, Message::ToolSelected(*tool)) + .size(37.0) + .toggled(tool == active_tool) + .hint(tool.hint()) + .into() + }) + .collect(); + + let tool_group = button_group(buttons); + + container(tool_group) + .width(Length::Fill) + .padding([7, 16]) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8(0x32, 0x34, 0x36))), + ..container::Style::default() + }) + .into() +} diff --git a/au-o2-gui/src/gui/editor/track_header.rs b/au-o2-gui/src/gui/editor/track_header.rs new file mode 100644 index 0000000..cae3fd3 --- /dev/null +++ b/au-o2-gui/src/gui/editor/track_header.rs @@ -0,0 +1,135 @@ +use crate::gui::icon_button::IconButton; +use crate::gui::icons::{Icon, IconSet}; +use crate::gui::styles::oxide_slider; +use crate::track::Track; +use iced::widget::{button, column, container, row, slider, text}; +use iced::{Alignment, Background, Color, Element, Length, Theme}; + +#[derive(Debug, Clone, Copy)] +pub enum Message { + MuteToggled, + SoloToggled, + RecordArmToggled, + VolumeChanged(f32), + PanChanged(f32), + Delete, + Select, +} + +pub fn view<'a>(track: &'a Track, icons: &'a IconSet, height: f32) -> Element<'a, Message> { + let color_strip = container(text("").width(3).height(Length::Fill)) + .height(Length::Fill) + .style(move |_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8( + track.color.r, + track.color.g, + track.color.b, + ))), + ..container::Style::default() + }); + + let track_name = text(&track.name).size(17).width(Length::Fill); + let track_type_label = text(format!("{}", track.track_type)) + .size(10) + .color(Color::from_rgb8(0x99, 0x99, 0x99)); + + let (mute_u, mute_f) = icons.get(Icon::Mute); + let (solo_u, solo_f) = icons.get(Icon::Solo); + let (rec_u, rec_f) = icons.get(Icon::RecordArm); + + let mute_btn: Element<'a, Message> = IconButton::new(mute_u, mute_f, Message::MuteToggled) + .size(32.0) + .toggled(track.muted) + .active_tint(Color::from_rgb8(0xCC, 0xA7, 0x00)) + .hint("Mute") + .into(); + let solo_btn: Element<'a, Message> = IconButton::new(solo_u, solo_f, Message::SoloToggled) + .size(32.0) + .toggled(track.soloed) + .active_tint(Color::from_rgb8(0x00, 0x7A, 0xFF)) + .hint("Solo") + .into(); + let rec_btn: Element<'a, Message> = + IconButton::new(rec_u, rec_f, Message::RecordArmToggled) + .size(32.0) + .toggled(track.record_armed) + .active_tint(Color::from_rgb8(0xCC, 0x33, 0x33)) + .hint("Record Arm") + .into(); + + let controls = row![mute_btn, solo_btn, rec_btn].spacing(2); + + let volume_slider = slider(0.0..=1.0, track.volume, Message::VolumeChanged) + .step(0.01) + .default(0.75) + .style(oxide_slider) + .width(Length::Fill); + let pan_slider = slider(-1.0..=1.0, track.pan, Message::PanChanged) + .step(0.01) + .default(0.0) + .style(oxide_slider) + .width(Length::Fill); + + let del_btn = button(text("x").size(10)) + .on_press(Message::Delete) + .padding([2, 5]) + .style(|_theme: &Theme, _status| button::Style { + background: Some(Background::Color(Color::TRANSPARENT)), + text_color: Color::from_rgb8(0x88, 0x88, 0x88), + ..button::Style::default() + }); + + let info = column![ + row![track_name, del_btn].align_y(Alignment::Center), + row![track_type_label, controls] + .spacing(6) + .align_y(Alignment::Center), + row![text("Vol").size(10), volume_slider] + .spacing(4) + .align_y(Alignment::Center), + row![text("Pan").size(10), pan_slider] + .spacing(4) + .align_y(Alignment::Center), + ] + .spacing(5) + .padding([7, 9]) + .width(Length::Fill); + + let inner = row![color_strip, info]; + + let content = button(inner) + .on_press(Message::Select) + .width(Length::Fill) + .style(|_theme: &Theme, _status: button::Status| button::Style { + background: Some(Background::Color(Color::TRANSPARENT)), + text_color: Color::from_rgb8(0xDD, 0xDD, 0xDD), + ..button::Style::default() + }); + + container(content) + .style(move |theme: &Theme| { + if track.selected { + container::Style { + border: iced::Border { + color: theme.extended_palette().primary.strong.color, + width: 2.0, + radius: 4.0.into(), + }, + background: Some(Background::Color(Color::from_rgb8(0x30, 0x38, 0x42))), + ..container::Style::default() + } + } else { + container::Style { + border: iced::Border { + color: Color::from_rgb8(0x3E, 0x40, 0x42), + width: 1.0, + radius: 0.0.into(), + }, + ..container::Style::default() + } + } + }) + .width(Length::Fill) + .height(height) + .into() +} diff --git a/au-o2-gui/src/gui/editor/visualizer/mod.rs b/au-o2-gui/src/gui/editor/visualizer/mod.rs new file mode 100644 index 0000000..21052e5 --- /dev/null +++ b/au-o2-gui/src/gui/editor/visualizer/mod.rs @@ -0,0 +1,6 @@ +pub mod spiral; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VisualizerKind { + Spiral, +} diff --git a/au-o2-gui/src/gui/editor/visualizer/shaders/spiral.wgsl b/au-o2-gui/src/gui/editor/visualizer/shaders/spiral.wgsl new file mode 100644 index 0000000..af2c71a --- /dev/null +++ b/au-o2-gui/src/gui/editor/visualizer/shaders/spiral.wgsl @@ -0,0 +1,28 @@ +struct Uniforms { + mvp: mat4x4, +}; + +@group(0) @binding(0) var uniforms: Uniforms; + +struct VertexInput { + @location(0) position: vec3, + @location(1) color: vec4, +}; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) frag_color: vec4, +}; + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + out.clip_position = uniforms.mvp * vec4(in.position, 1.0); + out.frag_color = in.color; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + return in.frag_color; +} diff --git a/au-o2-gui/src/gui/editor/visualizer/spiral.rs b/au-o2-gui/src/gui/editor/visualizer/spiral.rs new file mode 100644 index 0000000..05fa876 --- /dev/null +++ b/au-o2-gui/src/gui/editor/visualizer/spiral.rs @@ -0,0 +1,420 @@ +use iced::widget::shader; +use iced::widget::shader::wgpu; +use iced::{mouse, Element, Length, Rectangle}; + +use crate::editor::Message as EditorMessage; +use crate::modules::{PhasePoint, VisualizationFrame}; + +// Vertex: [x, y, z, r, g, b, a] = 7 floats = 28 bytes +const VERTEX_SIZE: u64 = 28; +const MAX_VERTICES: u64 = 8192; + +pub struct SpiralProgram { + frame: Option, + rotation: f32, +} + +impl SpiralProgram { + pub fn new(frame: Option<&VisualizationFrame>, rotation: f32) -> Self { + Self { + frame: frame.cloned(), + rotation, + } + } +} + +#[derive(Debug)] +pub struct SpiralPrimitive { + vertices_l: Vec<[f32; 7]>, + vertices_r: Vec<[f32; 7]>, + rotation: f32, + aspect: f32, +} + +#[derive(Default)] +pub struct SpiralState { + drag_start: Option<(f32, f32)>, + rotation: f32, +} + +impl shader::Program for SpiralProgram { + type State = SpiralState; + type Primitive = SpiralPrimitive; + + fn draw( + &self, + state: &Self::State, + _cursor: mouse::Cursor, + bounds: Rectangle, + ) -> Self::Primitive { + let aspect = if bounds.height > 0.0 { + bounds.width / bounds.height + } else { + 1.0 + }; + + let (verts_l, verts_r) = match &self.frame { + Some(frame) => ( + points_to_vertices(&frame.left, &GRADIENT_BLUE), + points_to_vertices(&frame.right, &GRADIENT_PINK), + ), + None => (Vec::new(), Vec::new()), + }; + + SpiralPrimitive { + vertices_l: verts_l, + vertices_r: verts_r, + rotation: self.rotation + state.rotation, + aspect, + } + } + + fn update( + &self, + state: &mut Self::State, + event: shader::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + _shell: &mut iced::advanced::Shell<'_, EditorMessage>, + ) -> (iced::event::Status, Option) { + match event { + shader::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if let Some(pos) = cursor.position_in(bounds) { + state.drag_start = Some((pos.x, pos.y)); + return (iced::event::Status::Captured, None); + } + } + shader::Event::Mouse(mouse::Event::CursorMoved { position }) => { + if let Some((start_x, _)) = state.drag_start { + let dx = position.x - bounds.x - start_x; + state.rotation = dx * 0.01; + return (iced::event::Status::Captured, None); + } + } + shader::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + state.drag_start = None; + } + _ => {} + } + (iced::event::Status::Ignored, None) + } + + fn mouse_interaction( + &self, + state: &Self::State, + _bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> mouse::Interaction { + if state.drag_start.is_some() { + mouse::Interaction::Grabbing + } else { + mouse::Interaction::default() + } + } +} + +struct SpiralPipeline { + pipeline: wgpu::RenderPipeline, + vertex_buffer: wgpu::Buffer, + uniform_buffer: wgpu::Buffer, + bind_group: wgpu::BindGroup, +} + +impl shader::Primitive for SpiralPrimitive { + fn prepare( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + format: wgpu::TextureFormat, + storage: &mut shader::Storage, + _bounds: &Rectangle, + _viewport: &shader::Viewport, + ) { + if !storage.has::() { + let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("spiral_shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("shaders/spiral.wgsl").into()), + }); + + let bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("spiral_bind_group_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("spiral_pipeline_layout"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }); + + let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("spiral_vertex_buffer"), + size: MAX_VERTICES * VERTEX_SIZE, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("spiral_uniform_buffer"), + size: 64, // mat4x4 + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("spiral_bind_group"), + layout: &bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }], + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("spiral_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader_module, + entry_point: "vs_main", + buffers: &[wgpu::VertexBufferLayout { + array_stride: VERTEX_SIZE, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: 12, + shader_location: 1, + format: wgpu::VertexFormat::Float32x4, + }, + ], + }], + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::LineStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader_module, + entry_point: "fs_main", + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview: None, + }); + + storage.store(SpiralPipeline { + pipeline, + vertex_buffer, + uniform_buffer, + bind_group, + }); + } + + let pipeline_data = storage.get_mut::().unwrap(); + + // Update uniform (MVP matrix) + let mvp = build_mvp(self.rotation, self.aspect); + queue.write_buffer(&pipeline_data.uniform_buffer, 0, cast_mat4(&mvp)); + + // Upload vertices + let mut all_verts: Vec<[f32; 7]> = Vec::new(); + all_verts.extend_from_slice(&self.vertices_l); + // Separator (degenerate) between L and R channels + if !self.vertices_l.is_empty() && !self.vertices_r.is_empty() { + let last_l = *self.vertices_l.last().unwrap(); + let first_r = self.vertices_r[0]; + // Degenerate vertices with zero alpha for line strip break + all_verts.push([last_l[0], last_l[1], last_l[2], 0.0, 0.0, 0.0, 0.0]); + all_verts.push([first_r[0], first_r[1], first_r[2], 0.0, 0.0, 0.0, 0.0]); + } + all_verts.extend_from_slice(&self.vertices_r); + + let vert_count = all_verts.len().min(MAX_VERTICES as usize); + if vert_count > 0 { + let byte_data = cast_vertex_slice(&all_verts[..vert_count]); + queue.write_buffer(&pipeline_data.vertex_buffer, 0, byte_data); + } + } + + fn render( + &self, + encoder: &mut wgpu::CommandEncoder, + storage: &shader::Storage, + target: &wgpu::TextureView, + clip_bounds: &Rectangle, + ) { + let pipeline_data = storage.get::().unwrap(); + + let total_verts = self.vertices_l.len() + self.vertices_r.len() + + if !self.vertices_l.is_empty() && !self.vertices_r.is_empty() { 2 } else { 0 }; + let vert_count = total_verts.min(MAX_VERTICES as usize) as u32; + if vert_count == 0 { + return; + } + + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("spiral_render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + + pass.set_scissor_rect( + clip_bounds.x, + clip_bounds.y, + clip_bounds.width, + clip_bounds.height, + ); + pass.set_pipeline(&pipeline_data.pipeline); + pass.set_bind_group(0, &pipeline_data.bind_group, &[]); + pass.set_vertex_buffer(0, pipeline_data.vertex_buffer.slice(..)); + pass.draw(0..vert_count, 0..1); + } +} + +// Amplitude-mapped color gradient: purple -> blue -> green -> yellow -> red +const GRADIENT_BLUE: [(f32, f32, f32); 5] = [ + (0.5, 0.0, 1.0), // purple + (0.0, 0.3, 1.0), // blue + (0.0, 1.0, 0.3), // green + (1.0, 1.0, 0.0), // yellow + (1.0, 0.0, 0.0), // red +]; + +const GRADIENT_PINK: [(f32, f32, f32); 5] = [ + (0.6, 0.0, 0.8), + (0.8, 0.2, 0.6), + (1.0, 0.4, 0.4), + (1.0, 0.7, 0.2), + (1.0, 0.2, 0.2), +]; + +fn amplitude_color(amp: f32, gradient: &[(f32, f32, f32); 5]) -> (f32, f32, f32, f32) { + let t = amp.clamp(0.0, 1.0) * 4.0; + let idx = (t as usize).min(3); + let frac = t - idx as f32; + let (r0, g0, b0) = gradient[idx]; + let (r1, g1, b1) = gradient[idx + 1]; + ( + r0 + (r1 - r0) * frac, + g0 + (g1 - g0) * frac, + b0 + (b1 - b0) * frac, + 0.8, + ) +} + +fn points_to_vertices(points: &[PhasePoint], gradient: &[(f32, f32, f32); 5]) -> Vec<[f32; 7]> { + let total = points.len().max(1) as f32; + points + .iter() + .enumerate() + .map(|(i, p)| { + let z = (i as f32 / total) * 2.0 - 1.0; // map to [-1, 1] + let (r, g, b, a) = amplitude_color(p.amplitude, gradient); + [p.x, p.y, z, r, g, b, a] + }) + .collect() +} + +fn build_mvp(rotation_y: f32, aspect: f32) -> [f32; 16] { + let fov = std::f32::consts::FRAC_PI_4; + let near = 0.1; + let far = 100.0; + let f = 1.0 / (fov / 2.0).tan(); + + // Perspective projection + let proj = [ + f / aspect, 0.0, 0.0, 0.0, + 0.0, f, 0.0, 0.0, + 0.0, 0.0, (far + near) / (near - far), -1.0, + 0.0, 0.0, (2.0 * far * near) / (near - far), 0.0, + ]; + + // View: translate back + let view = [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, -3.0, 1.0, + ]; + + // Y-axis rotation + let cos = rotation_y.cos(); + let sin = rotation_y.sin(); + let rot = [ + cos, 0.0, sin, 0.0, + 0.0, 1.0, 0.0, 0.0, + -sin, 0.0, cos, 0.0, + 0.0, 0.0, 0.0, 1.0, + ]; + + // MVP = proj * view * rot + let vr = mat4_mul(&view, &rot); + mat4_mul(&proj, &vr) +} + +fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { + let mut out = [0.0f32; 16]; + for row in 0..4 { + for col in 0..4 { + let mut sum = 0.0; + for k in 0..4 { + sum += a[row + k * 4] * b[k + col * 4]; + } + out[row + col * 4] = sum; + } + } + out +} + +fn cast_vertex_slice(data: &[[f32; 7]]) -> &[u8] { + unsafe { + std::slice::from_raw_parts(data.as_ptr() as *const u8, data.len() * std::mem::size_of::<[f32; 7]>()) + } +} + +fn cast_mat4(data: &[f32; 16]) -> &[u8] { + unsafe { + std::slice::from_raw_parts(data.as_ptr() as *const u8, std::mem::size_of::<[f32; 16]>()) + } +} + +pub fn view(frame: Option<&VisualizationFrame>, rotation: f32) -> Element<'_, EditorMessage> { + shader::Shader::new(SpiralProgram::new(frame, rotation)) + .width(Length::Fill) + .height(Length::Fill) + .into() +} diff --git a/au-o2-gui/src/gui/first_run_wizard.rs b/au-o2-gui/src/gui/first_run_wizard.rs new file mode 100644 index 0000000..63e0938 --- /dev/null +++ b/au-o2-gui/src/gui/first_run_wizard.rs @@ -0,0 +1,20 @@ +use crate::entry::Message; +use iced::widget::{button, column, text, text_input}; +use iced::{Alignment, Element}; +use std::path::PathBuf; + +pub fn view(project_dir: &PathBuf) -> Element<'static, Message> { + let current_path_str = project_dir.to_str().unwrap_or("").to_string(); + + let content = column![ + text("Welcome to Audio Oxide").size(30), + text("Please choose a directory to store your projects."), + text_input("Project Directory", ¤t_path_str) + .on_input(Message::FirstRunProjectDirChanged), + button("Continue").on_press(Message::FirstRunComplete), + ] + .spacing(15) + .align_x(Alignment::Center); + + content.into() +} \ No newline at end of file diff --git a/au-o2-gui/src/gui/icon_button.rs b/au-o2-gui/src/gui/icon_button.rs new file mode 100644 index 0000000..53f926b --- /dev/null +++ b/au-o2-gui/src/gui/icon_button.rs @@ -0,0 +1,436 @@ +use iced::advanced::layout::{self, Layout}; +use iced::advanced::overlay; +use iced::advanced::renderer::{self, Quad}; +use iced::advanced::text::Renderer as TextRenderer; +use iced::advanced::widget::tree::{self, Tree}; +use iced::advanced::widget::Widget; +use iced::advanced::Renderer as _; +use iced::advanced::{Clipboard, Shell, Text as AdvText}; +use iced::event::{self, Event}; +use iced::keyboard::{self, Key}; +use iced::mouse; +use iced::widget::svg; +use iced::{ + Border, Color, Element, Font, Length, Point, Radians, Rectangle, Shadow, Size, Theme, Vector, +}; +use std::time::{Duration, Instant}; + +const FLASH_DURATION: Duration = Duration::from_millis(100); +const CANCEL_MARGIN: f32 = 0.2; +const TOOLTIP_DELAY: Duration = Duration::from_secs(3); + +pub struct IconButton<'a, Message> { + unfilled: &'a svg::Handle, + filled: &'a svg::Handle, + on_press: Message, + tint: Color, + active_tint: Color, + size: f32, + toggled: bool, + hint: Option<&'a str>, +} + +impl<'a, Message: Clone> IconButton<'a, Message> { + pub fn new( + unfilled: &'a svg::Handle, + filled: &'a svg::Handle, + on_press: Message, + ) -> Self { + Self { + unfilled, + filled, + on_press, + tint: Color::from_rgb8(0xDD, 0xDD, 0xDD), + active_tint: Color::from_rgb8(0x00, 0x7A, 0xFF), + size: 38.0, + toggled: false, + hint: None, + } + } + + pub fn tint(mut self, color: Color) -> Self { + self.tint = color; + self + } + + pub fn active_tint(mut self, color: Color) -> Self { + self.active_tint = color; + self + } + + pub fn size(mut self, size: f32) -> Self { + self.size = size; + self + } + + pub fn toggled(mut self, toggled: bool) -> Self { + self.toggled = toggled; + self + } + + pub fn hint(mut self, hint: &'a str) -> Self { + self.hint = Some(hint); + self + } +} + +#[derive(Debug)] +struct State { + is_pressed: bool, + cancelled: bool, + flash_until: Option, + hover_start: Option, + show_help: bool, +} + +impl Default for State { + fn default() -> Self { + Self { + is_pressed: false, + cancelled: false, + flash_until: None, + hover_start: None, + show_help: false, + } + } +} + +impl<'a, Message> Widget for IconButton<'a, Message> +where + Message: Clone, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn size(&self) -> Size { + Size::new(Length::Fixed(self.size), Length::Fixed(self.size)) + } + + fn layout( + &self, + _tree: &mut Tree, + _renderer: &iced::Renderer, + _limits: &layout::Limits, + ) -> layout::Node { + layout::Node::new(Size::new(self.size, self.size)) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut iced::Renderer, + _theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let state = tree.state.downcast_ref::(); + let bounds = layout.bounds(); + + let now = Instant::now(); + let flashing = state.flash_until.is_some_and(|t| now < t); + let pressed = state.is_pressed && !state.cancelled; + + let bg_color = if pressed { + Color::from_rgba8(0xFF, 0xFF, 0xFF, 0.1) + } else if flashing { + Color { + a: 0.3, + ..self.active_tint + } + } else if self.toggled { + self.active_tint + } else { + Color::TRANSPARENT + }; + + renderer.fill_quad( + Quad { + bounds, + border: Border { + radius: 6.0.into(), + ..Border::default() + }, + shadow: Shadow::default(), + }, + bg_color, + ); + + let show_filled = pressed || self.toggled; + let handle = if show_filled { + self.filled + } else { + self.unfilled + }; + + let icon_size = self.size - 4.0; + let icon_bounds = Rectangle { + x: bounds.x + (bounds.width - icon_size) / 2.0, + y: bounds.y + (bounds.height - icon_size) / 2.0, + width: icon_size, + height: icon_size, + }; + + let tint = if self.toggled && !pressed { + Color::WHITE + } else { + self.tint + }; + + use iced::advanced::svg::Renderer as _; + renderer.draw_svg( + iced::advanced::svg::Svg { + handle: handle.clone(), + color: Some(tint), + rotation: Radians(0.0), + opacity: 1.0, + }, + icon_bounds, + ); + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &iced::Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) -> event::Status { + let state = tree.state.downcast_mut::(); + let bounds = layout.bounds(); + let over = cursor.is_over(bounds); + + // Hover tracking + match &event { + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if over { + if state.hover_start.is_none() { + state.hover_start = Some(Instant::now()); + } + } else { + state.hover_start = None; + state.show_help = false; + } + } + Event::Mouse(mouse::Event::CursorLeft) => { + state.hover_start = None; + state.show_help = false; + } + _ => {} + } + + // ? key toggles help + if over { + if let Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Character(ref c), + .. + }) = event + { + if c.as_str() == "?" { + state.show_help = !state.show_help; + return event::Status::Captured; + } + } + } + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if over { + state.is_pressed = true; + state.cancelled = false; + return event::Status::Captured; + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if state.is_pressed && !state.cancelled { + let margin = bounds.width * CANCEL_MARGIN; + let expanded = Rectangle { + x: bounds.x - margin, + y: bounds.y - margin, + width: bounds.width + margin * 2.0, + height: bounds.height + margin * 2.0, + }; + if !cursor.is_over(expanded) { + state.cancelled = true; + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + if state.is_pressed { + if !state.cancelled { + shell.publish(self.on_press.clone()); + if !self.toggled { + state.flash_until = Some(Instant::now() + FLASH_DURATION); + } + } + state.is_pressed = false; + state.cancelled = false; + return event::Status::Captured; + } + } + _ => {} + } + event::Status::Ignored + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &iced::Renderer, + ) -> mouse::Interaction { + if cursor.is_over(layout.bounds()) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::None + } + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + _renderer: &iced::Renderer, + translation: Vector, + ) -> Option> { + let hint = self.hint?; + let state = tree.state.downcast_ref::(); + + let show_tooltip = state.show_help + || state + .hover_start + .is_some_and(|t| t.elapsed() >= TOOLTIP_DELAY); + + if !show_tooltip { + return None; + } + + let bounds = layout.bounds(); + let anchor = Point::new( + bounds.x + bounds.width / 2.0 + translation.x, + bounds.y + bounds.height + translation.y + 4.0, + ); + + Some(overlay::Element::new(Box::new(TooltipOverlay { + text: hint, + anchor, + is_help: state.show_help, + }))) + } +} + +impl<'a, Message: Clone + 'a> From> for Element<'a, Message> { + fn from(btn: IconButton<'a, Message>) -> Self { + Element::new(btn) + } +} + +struct TooltipOverlay<'a> { + text: &'a str, + anchor: Point, + is_help: bool, +} + +impl<'a, Message> overlay::Overlay for TooltipOverlay<'a> { + fn layout(&mut self, _renderer: &iced::Renderer, bounds: Size) -> layout::Node { + let padding = if self.is_help { 8.0 } else { 6.0 }; + let font_size = if self.is_help { 13.0 } else { 11.0 }; + + let char_width = font_size * 0.6; + let w = self.text.len() as f32 * char_width + padding * 2.0; + let h = font_size + padding * 2.0; + + let x = (self.anchor.x - w / 2.0).clamp(0.0, (bounds.width - w).max(0.0)); + let y = self.anchor.y.min((bounds.height - h).max(0.0)); + + layout::Node::new(Size::new(w, h)).move_to(Point::new(x, y)) + } + + fn draw( + &self, + renderer: &mut iced::Renderer, + _theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + ) { + let bounds = layout.bounds(); + let font_size = if self.is_help { 13.0 } else { 11.0 }; + + renderer.fill_quad( + Quad { + bounds, + border: Border { + radius: 4.0.into(), + ..Border::default() + }, + shadow: Shadow::default(), + }, + Color::from_rgba8(0x2A, 0x2A, 0x2A, 0.96), + ); + + let padding = if self.is_help { 8.0 } else { 6.0 }; + renderer.fill_text( + AdvText { + content: self.text.to_string(), + bounds: Size::new(bounds.width - padding * 2.0, bounds.height - padding * 2.0), + size: iced::Pixels(font_size), + line_height: iced::advanced::text::LineHeight::default(), + font: Font::DEFAULT, + horizontal_alignment: iced::alignment::Horizontal::Left, + vertical_alignment: iced::alignment::Vertical::Top, + shaping: iced::advanced::text::Shaping::Basic, + wrapping: iced::advanced::text::Wrapping::None, + }, + Point::new(bounds.x + padding, bounds.y + padding), + Color::from_rgb8(0xEE, 0xEE, 0xEE), + bounds, + ); + } + + fn is_over( + &self, + _layout: Layout<'_>, + _renderer: &iced::Renderer, + _cursor_position: Point, + ) -> bool { + self.is_help + } +} + +pub fn button_group<'a, Message: Clone + 'a>( + buttons: Vec>, +) -> Element<'a, Message> { + use iced::widget::{container, Row}; + use iced::Alignment; + + let row = buttons + .into_iter() + .fold(Row::new().spacing(4).align_y(Alignment::Center), |r, btn| { + r.push(btn) + }); + + container(row) + .padding([4, 6]) + .style(|_theme: &Theme| container::Style { + border: Border { + color: Color::from_rgb8(0x4C, 0x4E, 0x50), + width: 1.0, + radius: 10.0.into(), + }, + ..container::Style::default() + }) + .into() +} diff --git a/au-o2-gui/src/gui/icons.rs b/au-o2-gui/src/gui/icons.rs new file mode 100644 index 0000000..d0475c1 --- /dev/null +++ b/au-o2-gui/src/gui/icons.rs @@ -0,0 +1,249 @@ +use iced::widget::svg; +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Icon { + // Transport + Play, + Stop, + Record, + Rewind, + FastForward, + Pause, + ReturnToZero, + // Mode toggles + Cycle, + Metronome, + CountIn, + // Tools + ToolPointer, + ToolPencil, + ToolEraser, + ToolScissors, + ToolGlue, + ToolZoom, + // Track controls + Mute, + Solo, + RecordArm, + InputMonitor, + Freeze, + Lock, + // Track types + TrackAudio, + TrackMidi, + TrackAux, + TrackBus, + // View toggles + ViewInspector, + ViewEditor, + ViewMixer, + ViewLibrary, + ViewToolbar, + ViewNotepad, + ViewVisualizer, + // Mixer + Eq, + Send, + Insert, + Io, + Automation, + Pan, + // General + Add, + Remove, + Close, + Settings, + Search, + Undo, + Redo, + Cut, + Copy, + Paste, + Folder, + Save, +} + +pub struct IconSet { + unfilled: HashMap, + filled: HashMap, +} + +fn make_filled(bytes: &[u8]) -> Vec { + let s = String::from_utf8_lossy(bytes); + s.replace( + r#"class="fillable" fill="none""#, + r#"class="fillable" fill="currentColor""#, + ) + .into_bytes() +} + +impl IconSet { + pub fn load() -> Self { + let entries: &[(Icon, &[u8])] = &[ + // Transport + (Icon::Play, include_bytes!("../../assets/icons/play.svg")), + (Icon::Stop, include_bytes!("../../assets/icons/stop.svg")), + (Icon::Record, include_bytes!("../../assets/icons/record.svg")), + (Icon::Rewind, include_bytes!("../../assets/icons/rewind.svg")), + ( + Icon::FastForward, + include_bytes!("../../assets/icons/fast-forward.svg"), + ), + (Icon::Pause, include_bytes!("../../assets/icons/pause.svg")), + ( + Icon::ReturnToZero, + include_bytes!("../../assets/icons/rtz.svg"), + ), + // Mode toggles + (Icon::Cycle, include_bytes!("../../assets/icons/cycle.svg")), + ( + Icon::Metronome, + include_bytes!("../../assets/icons/metronome.svg"), + ), + ( + Icon::CountIn, + include_bytes!("../../assets/icons/count-in.svg"), + ), + // Tools + ( + Icon::ToolPointer, + include_bytes!("../../assets/icons/tool-pointer.svg"), + ), + ( + Icon::ToolPencil, + include_bytes!("../../assets/icons/tool-pencil.svg"), + ), + ( + Icon::ToolEraser, + include_bytes!("../../assets/icons/tool-eraser.svg"), + ), + ( + Icon::ToolScissors, + include_bytes!("../../assets/icons/tool-scissors.svg"), + ), + ( + Icon::ToolGlue, + include_bytes!("../../assets/icons/tool-glue.svg"), + ), + ( + Icon::ToolZoom, + include_bytes!("../../assets/icons/tool-zoom.svg"), + ), + // Track controls + (Icon::Mute, include_bytes!("../../assets/icons/mute.svg")), + (Icon::Solo, include_bytes!("../../assets/icons/solo.svg")), + ( + Icon::RecordArm, + include_bytes!("../../assets/icons/record-arm.svg"), + ), + ( + Icon::InputMonitor, + include_bytes!("../../assets/icons/input-monitor.svg"), + ), + ( + Icon::Freeze, + include_bytes!("../../assets/icons/freeze.svg"), + ), + (Icon::Lock, include_bytes!("../../assets/icons/lock.svg")), + // Track types + ( + Icon::TrackAudio, + include_bytes!("../../assets/icons/track-audio.svg"), + ), + ( + Icon::TrackMidi, + include_bytes!("../../assets/icons/track-midi.svg"), + ), + ( + Icon::TrackAux, + include_bytes!("../../assets/icons/track-aux.svg"), + ), + ( + Icon::TrackBus, + include_bytes!("../../assets/icons/track-bus.svg"), + ), + // View toggles + ( + Icon::ViewInspector, + include_bytes!("../../assets/icons/view-inspector.svg"), + ), + ( + Icon::ViewEditor, + include_bytes!("../../assets/icons/view-editor.svg"), + ), + ( + Icon::ViewMixer, + include_bytes!("../../assets/icons/view-mixer.svg"), + ), + ( + Icon::ViewLibrary, + include_bytes!("../../assets/icons/view-library.svg"), + ), + ( + Icon::ViewToolbar, + include_bytes!("../../assets/icons/view-toolbar.svg"), + ), + ( + Icon::ViewNotepad, + include_bytes!("../../assets/icons/view-notepad.svg"), + ), + ( + Icon::ViewVisualizer, + include_bytes!("../../assets/icons/view-visualizer.svg"), + ), + // Mixer + (Icon::Eq, include_bytes!("../../assets/icons/eq.svg")), + (Icon::Send, include_bytes!("../../assets/icons/send.svg")), + ( + Icon::Insert, + include_bytes!("../../assets/icons/insert.svg"), + ), + (Icon::Io, include_bytes!("../../assets/icons/io.svg")), + ( + Icon::Automation, + include_bytes!("../../assets/icons/automation.svg"), + ), + (Icon::Pan, include_bytes!("../../assets/icons/pan.svg")), + // General + (Icon::Add, include_bytes!("../../assets/icons/add.svg")), + ( + Icon::Remove, + include_bytes!("../../assets/icons/remove.svg"), + ), + (Icon::Close, include_bytes!("../../assets/icons/close.svg")), + ( + Icon::Settings, + include_bytes!("../../assets/icons/settings.svg"), + ), + ( + Icon::Search, + include_bytes!("../../assets/icons/search.svg"), + ), + (Icon::Undo, include_bytes!("../../assets/icons/undo.svg")), + (Icon::Redo, include_bytes!("../../assets/icons/redo.svg")), + (Icon::Cut, include_bytes!("../../assets/icons/cut.svg")), + (Icon::Copy, include_bytes!("../../assets/icons/copy.svg")), + (Icon::Paste, include_bytes!("../../assets/icons/paste.svg")), + ( + Icon::Folder, + include_bytes!("../../assets/icons/folder.svg"), + ), + (Icon::Save, include_bytes!("../../assets/icons/save.svg")), + ]; + + let mut unfilled = HashMap::with_capacity(entries.len()); + let mut filled = HashMap::with_capacity(entries.len()); + + for &(icon, bytes) in entries { + unfilled.insert(icon, svg::Handle::from_memory(bytes)); + filled.insert(icon, svg::Handle::from_memory(make_filled(bytes))); + } + + Self { unfilled, filled } + } + + pub fn get(&self, icon: Icon) -> (&svg::Handle, &svg::Handle) { + (&self.unfilled[&icon], &self.filled[&icon]) + } +} diff --git a/au-o2-gui/src/gui/mod.rs b/au-o2-gui/src/gui/mod.rs new file mode 100644 index 0000000..a3a1862 --- /dev/null +++ b/au-o2-gui/src/gui/mod.rs @@ -0,0 +1,11 @@ +pub mod editor; +pub mod first_run_wizard; +pub mod icon_button; +pub mod icons; +pub mod native_menu; +pub mod new_project; +pub mod project_viewer; +pub mod settings; +pub mod splash; +pub mod styles; +pub mod time_utility; diff --git a/au-o2-gui/src/gui/native_menu.rs b/au-o2-gui/src/gui/native_menu.rs new file mode 100644 index 0000000..60cee7a --- /dev/null +++ b/au-o2-gui/src/gui/native_menu.rs @@ -0,0 +1,163 @@ +use std::collections::HashMap; + +use muda::{ + accelerator::{Accelerator, Code, Modifiers}, + Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu, +}; + +use crate::behaviors::Action; + +#[allow(dead_code)] +pub struct NativeMenu { + menu: Menu, + submenus: Vec, + items: Vec, + action_map: HashMap, + attached: bool, +} + +#[derive(Debug, Clone)] +pub enum NativeMenuAction { + Action(Action), + ShowNewTrackWizard, +} + +impl NativeMenu { + pub fn init() -> Self { + let mut action_map = HashMap::new(); + let mut items = Vec::new(); + let menu = Menu::new(); + + let app_menu = Submenu::new("Audio Oxide", true); + let about = PredefinedMenuItem::about(Some("About Audio Oxide"), None); + let settings = MenuItem::new( + "Settings\u{2026}", + true, + Some(Accelerator::new(Some(Modifiers::META), Code::Comma)), + ); + action_map.insert(settings.id().clone(), NativeMenuAction::Action(Action::OpenSettings)); + let _ = app_menu.append_items(&[&about, &PredefinedMenuItem::separator(), &settings, &PredefinedMenuItem::separator(), &PredefinedMenuItem::quit(None)]); + items.push(settings); + + let file_menu = Submenu::new("File", true); + let new_proj = MenuItem::new("New Project", true, Some(Accelerator::new(Some(Modifiers::META), Code::KeyN))); + let open_proj = MenuItem::new("Open\u{2026}", true, Some(Accelerator::new(Some(Modifiers::META), Code::KeyO))); + let save = MenuItem::new("Save", true, Some(Accelerator::new(Some(Modifiers::META), Code::KeyS))); + let save_as = MenuItem::new("Save As\u{2026}", true, Some(Accelerator::new(Some(Modifiers::META | Modifiers::SHIFT), Code::KeyS))); + let close = MenuItem::new("Close", true, Some(Accelerator::new(Some(Modifiers::META), Code::KeyW))); + action_map.insert(new_proj.id().clone(), NativeMenuAction::Action(Action::NewProject)); + action_map.insert(open_proj.id().clone(), NativeMenuAction::Action(Action::OpenProject)); + action_map.insert(save.id().clone(), NativeMenuAction::Action(Action::SaveProject)); + action_map.insert(save_as.id().clone(), NativeMenuAction::Action(Action::SaveProjectAs)); + action_map.insert(close.id().clone(), NativeMenuAction::Action(Action::CloseProject)); + let _ = file_menu.append_items(&[ + &new_proj, &open_proj, + &PredefinedMenuItem::separator(), + &save, &save_as, + &PredefinedMenuItem::separator(), + &close, + ]); + items.extend([new_proj, open_proj, save, save_as, close]); + + let edit_menu = Submenu::new("Edit", true); + let undo = MenuItem::new("Undo", true, Some(Accelerator::new(Some(Modifiers::META), Code::KeyZ))); + let redo = MenuItem::new("Redo", true, Some(Accelerator::new(Some(Modifiers::META | Modifiers::SHIFT), Code::KeyZ))); + let cut = MenuItem::new("Cut", true, Some(Accelerator::new(Some(Modifiers::META), Code::KeyX))); + let copy = MenuItem::new("Copy", true, Some(Accelerator::new(Some(Modifiers::META), Code::KeyC))); + let paste = MenuItem::new("Paste", true, Some(Accelerator::new(Some(Modifiers::META), Code::KeyV))); + let dup = MenuItem::new("Duplicate", true, Some(Accelerator::new(Some(Modifiers::META), Code::KeyD))); + let sel_all = MenuItem::new("Select All", true, Some(Accelerator::new(Some(Modifiers::META), Code::KeyA))); + let del = MenuItem::new("Delete", true, Some(Accelerator::new(None, Code::Backspace))); + action_map.insert(undo.id().clone(), NativeMenuAction::Action(Action::Undo)); + action_map.insert(redo.id().clone(), NativeMenuAction::Action(Action::Redo)); + action_map.insert(cut.id().clone(), NativeMenuAction::Action(Action::Cut)); + action_map.insert(copy.id().clone(), NativeMenuAction::Action(Action::Copy)); + action_map.insert(paste.id().clone(), NativeMenuAction::Action(Action::Paste)); + action_map.insert(dup.id().clone(), NativeMenuAction::Action(Action::Duplicate)); + action_map.insert(sel_all.id().clone(), NativeMenuAction::Action(Action::SelectAll)); + action_map.insert(del.id().clone(), NativeMenuAction::Action(Action::Delete)); + let _ = edit_menu.append_items(&[ + &undo, &redo, + &PredefinedMenuItem::separator(), + &cut, ©, &paste, &dup, + &PredefinedMenuItem::separator(), + &sel_all, &del, + ]); + items.extend([undo, redo, cut, copy, paste, dup, sel_all, del]); + + let transport_menu = Submenu::new("Transport", true); + let play = MenuItem::new("Play/Pause", true, None); + let stop = MenuItem::new("Stop", true, None); + let record = MenuItem::new("Record", true, None); + let from_start = MenuItem::new("From Start", true, None); + let rewind = MenuItem::new("Rewind", true, None); + let new_track = MenuItem::new("New Track\u{2026}", true, None); + action_map.insert(play.id().clone(), NativeMenuAction::Action(Action::EditorTogglePlayback)); + action_map.insert(stop.id().clone(), NativeMenuAction::Action(Action::EditorStop)); + action_map.insert(record.id().clone(), NativeMenuAction::Action(Action::EditorToggleRecord)); + action_map.insert(from_start.id().clone(), NativeMenuAction::Action(Action::EditorPlayFromBeginning)); + action_map.insert(rewind.id().clone(), NativeMenuAction::Action(Action::EditorRewind)); + action_map.insert(new_track.id().clone(), NativeMenuAction::ShowNewTrackWizard); + let _ = transport_menu.append_items(&[ + &play, &stop, &record, + &PredefinedMenuItem::separator(), + &from_start, &rewind, + &PredefinedMenuItem::separator(), + &new_track, + ]); + items.extend([play, stop, record, from_start, rewind, new_track]); + + let view_menu = Submenu::new("View", true); + let inspector = MenuItem::new("Inspector", true, None); + let bottom = MenuItem::new("Bottom Panel", true, None); + let mixer = MenuItem::new("Mixer", true, None); + let cycle = MenuItem::new("Cycle", true, None); + let metro = MenuItem::new("Metronome", true, None); + let zh_in = MenuItem::new("Zoom In H", true, Some(Accelerator::new(Some(Modifiers::META), Code::ArrowRight))); + let zh_out = MenuItem::new("Zoom Out H", true, Some(Accelerator::new(Some(Modifiers::META), Code::ArrowLeft))); + let zv_in = MenuItem::new("Zoom In V", true, Some(Accelerator::new(Some(Modifiers::META), Code::ArrowDown))); + let zv_out = MenuItem::new("Zoom Out V", true, Some(Accelerator::new(Some(Modifiers::META), Code::ArrowUp))); + action_map.insert(inspector.id().clone(), NativeMenuAction::Action(Action::EditorToggleInspector)); + action_map.insert(bottom.id().clone(), NativeMenuAction::Action(Action::EditorToggleBottomPanel)); + action_map.insert(mixer.id().clone(), NativeMenuAction::Action(Action::EditorToggleMixer)); + action_map.insert(cycle.id().clone(), NativeMenuAction::Action(Action::EditorToggleCycle)); + action_map.insert(metro.id().clone(), NativeMenuAction::Action(Action::EditorToggleMetronome)); + action_map.insert(zh_in.id().clone(), NativeMenuAction::Action(Action::ZoomInH)); + action_map.insert(zh_out.id().clone(), NativeMenuAction::Action(Action::ZoomOutH)); + action_map.insert(zv_in.id().clone(), NativeMenuAction::Action(Action::ZoomInV)); + action_map.insert(zv_out.id().clone(), NativeMenuAction::Action(Action::ZoomOutV)); + let _ = view_menu.append_items(&[ + &inspector, &bottom, &mixer, + &PredefinedMenuItem::separator(), + &cycle, &metro, + &PredefinedMenuItem::separator(), + &zh_in, &zh_out, &zv_in, &zv_out, + ]); + items.extend([inspector, bottom, mixer, cycle, metro, zh_in, zh_out, zv_in, zv_out]); + + let submenus = vec![app_menu, file_menu, edit_menu, transport_menu, view_menu]; + let refs: Vec<&dyn muda::IsMenuItem> = submenus.iter().map(|s| s as &dyn muda::IsMenuItem).collect(); + let _ = menu.append_items(&refs); + + Self { menu, submenus, items, action_map, attached: false } + } + + pub fn ensure_attached(&mut self) { + if !self.attached { + #[cfg(target_os = "macos")] + self.menu.init_for_nsapp(); + self.attached = true; + } + } + + pub fn poll_events(&mut self) -> Vec { + self.ensure_attached(); + let mut actions = Vec::new(); + while let Ok(event) = MenuEvent::receiver().try_recv() { + if let Some(action) = self.action_map.get(event.id()) { + actions.push(action.clone()); + } + } + actions + } +} diff --git a/au-o2-gui/src/gui/new_project.rs b/au-o2-gui/src/gui/new_project.rs new file mode 100644 index 0000000..0fcadd3 --- /dev/null +++ b/au-o2-gui/src/gui/new_project.rs @@ -0,0 +1,150 @@ +use crate::config::ProjectConfig; +use crate::entry::Message; +use cpal::traits::{DeviceTrait, HostTrait}; +use iced::widget::{button, column, container, pick_list, row, slider, text, text_input}; +use iced::{Alignment, Element}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone)] +pub struct State { + pub config: ProjectConfig, + pub available_output_devices: BTreeSet, + pub available_input_devices: BTreeSet, +} + +impl Default for State { + fn default() -> Self { + let host = cpal::default_host(); + let output_devices = host + .output_devices() + .ok() + .into_iter() + .flatten() + .filter_map(|d| d.name().ok()) + .collect(); + let input_devices = host + .input_devices() + .ok() + .into_iter() + .flatten() + .filter_map(|d| d.name().ok()) + .collect(); + + Self { + config: ProjectConfig { + name: "New Project".to_string(), + sample_rate: 48000, + output_buffer_size: 512, + input_buffer_size: 512, + audio_device: "Default".to_string(), + audio_input_device: "Default".to_string(), + auto_oversample: true, + auto_undersample: true, + tempo: 120.0, + time_signature_numerator: 4, + time_signature_denominator: 4, + tracks: Vec::new(), + }, + available_output_devices: output_devices, + available_input_devices: input_devices, + } + } +} + +pub fn view(state: &State) -> Element<'static, Message> { + let config = &state.config; + + let sample_rates = vec![44100, 48000, 96000]; + let buffer_sizes = vec![256, 512, 1024]; + let output_devices: Vec = state.available_output_devices.iter().cloned().collect(); + let input_devices: Vec = state.available_input_devices.iter().cloned().collect(); + + let controls = column![ + row![ + text("Project Name:").width(150), + text_input("My Awesome Track", &config.name).on_input(Message::ProjectNameChanged) + ] + .spacing(10), + row![ + text("Sample Rate:").width(150), + pick_list( + sample_rates, + Some(config.sample_rate), + Message::SampleRateSelected + ) + ] + .spacing(10), + row![ + text("Output Buffer:").width(150), + pick_list( + buffer_sizes.clone(), + Some(config.output_buffer_size), + Message::OutputBufferSizeSelected + ) + ] + .spacing(10), + row![ + text("Input Buffer:").width(150), + pick_list( + buffer_sizes, + Some(config.input_buffer_size), + Message::InputBufferSizeSelected + ) + ] + .spacing(10), + row![ + text("Output Device:").width(150), + pick_list( + output_devices, + Some(config.audio_device.clone()), + Message::AudioDeviceSelected + ) + ] + .spacing(10), + row![ + text("Input Device:").width(150), + pick_list( + input_devices, + Some(config.audio_input_device.clone()), + Message::InputDeviceSelected + ) + ] + .spacing(10), + row![ + text("Time Signature:").width(150), + text_input("4", &config.time_signature_numerator.to_string()) + .on_input(Message::TimeSignatureNumeratorChanged) + .width(50), + text("/").width(20).align_x(Alignment::Center), + text_input("4", &config.time_signature_denominator.to_string()) + .on_input(Message::TimeSignatureDenominatorChanged) + .width(50), + ] + .spacing(10) + .align_y(Alignment::Center), + row![ + text("Tempo (BPM):").width(150), + slider(40.0..=240.0, config.tempo, Message::TempoChanged).step(0.1), + text(format!("{:.1}", config.tempo)) + ] + .spacing(10), + container(row![ + button("Time Utility").on_press(Message::ViewTimeUtility) + ]) + .align_x(Alignment::End), + ] + .spacing(10); + + column![ + text("Create New Project").size(30), + controls, + row![ + button("Create Project").on_press(Message::CreateProject), + button("Cancel").on_press(Message::ViewRecentProjects) + ] + .spacing(10) + ] + .spacing(20) + .align_x(Alignment::Center) + .into() +} diff --git a/au-o2-gui/src/gui/project_viewer.rs b/au-o2-gui/src/gui/project_viewer.rs new file mode 100644 index 0000000..f7e9ab6 --- /dev/null +++ b/au-o2-gui/src/gui/project_viewer.rs @@ -0,0 +1,159 @@ +// File: audio-oxide/src/gui/project_viewer.rs + +use crate::entry::{Message, ProjectInfo, ProjectViewState}; +use iced::widget::{button, column, container, row, scrollable, stack, text, text_input}; +use iced::{alignment, Alignment, Background, Border, Color, Element, Length, Padding, Theme}; +use std::path::PathBuf; + +// Helper function for the main navigation button style +fn nav_button_style(theme: &Theme, status: button::Status) -> button::Style { + let mut style = button::Style { + background: Some(Background::Color(Color::from_rgba(0.15, 0.15, 0.15, 0.6))), + border: Border { + radius: 10.0.into(), + ..Border::default() + }, + text_color: theme.palette().text, + ..button::Style::default() + }; + + if let button::Status::Hovered = status { + style.background = Some(Background::Color( + theme.extended_palette().primary.weak.color, + )); + style.text_color = theme.extended_palette().primary.weak.text; + } + + style +} + +// Helper function for the project list item style +fn project_list_item_style(theme: &Theme, status: button::Status) -> button::Style { + let mut style = button::Style { + background: Some(Background::Color(Color::from_rgba(1.0, 1.0, 1.0, 0.08))), + border: Border { + radius: 6.0.into(), + ..Border::default() + }, + ..button::Style::default() + }; + + if let button::Status::Hovered = status { + style.background = Some(Background::Color( + theme.extended_palette().primary.weak.color, + )); + style.text_color = theme.extended_palette().primary.weak.text; + } + + style +} + +pub fn view<'a>(state: &'a ProjectViewState) -> Element<'a, Message> { + if let ProjectViewState::Splash = state { + return super::splash::view(); + } + + let main_content = container(match state { + ProjectViewState::Splash => unreachable!(), + ProjectViewState::Recent { projects } => view_recent_projects(projects), + ProjectViewState::Find { path_input } => view_find_project(path_input), + }) + .width(Length::Fill) + .height(Length::Fill) + .padding(Padding { + top: 30.0, + right: 30.0, + bottom: 30.0, + left: 300.0, + }); + + let nav_panel = { + let recent_button = button(text("Recent Projects").size(24)) + .on_press(Message::ViewRecentProjects) + .style(nav_button_style) + .padding(20); + + let find_button = button(text("Find Project").size(24)) + .on_press(Message::ViewFindProject) + .style(nav_button_style) + .padding(20); + + let new_button = button(text("New Project...").size(24)) + .on_press(Message::ViewNewProject) + .style(nav_button_style) + .padding(20); + + container( + column![ + container(recent_button).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 0.0 }), + container(find_button).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 40.0 }), + container(new_button).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 80.0 }), + ] + .spacing(15) + .align_x(Alignment::Start), + ) + .padding(80) + }; + + stack![main_content, nav_panel].into() +} + +fn view_recent_projects<'a>(projects: &'a [ProjectInfo]) -> Element<'a, Message> { + let title = text("Recent Projects").size(40); + + if projects.is_empty() { + return column![ + title, + text("No projects found. Create one to get started!") + ] + .spacing(20) + .padding(20) + .into(); + } + + let project_list = projects.iter().fold(column!().spacing(10), |col, p| { + let project_entry = button( + row![ + text(&p.name).size(20).width(Length::Fill), + text(format!("Modified: {}", p.modified.format("%Y-%m-%d %H:%M"))), + ] + .align_y(Alignment::Center) + .padding(15), + ) + .width(Length::Fill) + .on_press(Message::OpenProject(p.path.clone())) + .style(project_list_item_style); + + col.push(project_entry) + }); + + column![title, scrollable(project_list)] + .spacing(30) + .padding(20) + .into() +} + +fn view_find_project<'a>(path_input: &'a str) -> Element<'a, Message> { + let title = text("Find Project").size(40); + let path_input_field = text_input("Enter path to project .xtc directory...", path_input) + .on_input(Message::FindPathChanged) + .padding(10) + .size(20); + + let open_button = button("Open Project") + .on_press(Message::OpenProject(PathBuf::from(path_input.to_string()))) + .padding(10); + + column![ + title, + text("Enter the full path to a project directory to open it."), + path_input_field, + container(open_button) + .width(Length::Fill) + .align_x(alignment::Horizontal::Right), + ] + .spacing(20) + .padding(20) + .max_width(600) + .into() +} \ No newline at end of file diff --git a/au-o2-gui/src/gui/settings.rs b/au-o2-gui/src/gui/settings.rs new file mode 100644 index 0000000..8340e8f --- /dev/null +++ b/au-o2-gui/src/gui/settings.rs @@ -0,0 +1,414 @@ +use crate::config::{AudioOxideConfig, RecordingFormat}; +use crate::engine::device::{self, DeviceCache}; +use iced::widget::{ + button, column, container, horizontal_rule, pick_list, row, text, text_input, toggler, Space, +}; +use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsTab { + General, + Audio, + Display, + Advanced, +} + +impl SettingsTab { + pub const ALL: [SettingsTab; 4] = [ + SettingsTab::General, + SettingsTab::Audio, + SettingsTab::Display, + SettingsTab::Advanced, + ]; + + fn label(&self) -> &'static str { + match self { + SettingsTab::General => "General", + SettingsTab::Audio => "Audio", + SettingsTab::Display => "Display", + SettingsTab::Advanced => "Advanced", + } + } +} + +#[derive(Debug, Clone)] +pub struct State { + pub config: AudioOxideConfig, + pub active_tab: SettingsTab, + pub device_cache: DeviceCache, +} + +impl State { + pub fn new(config: &AudioOxideConfig) -> Self { + Self { + config: config.clone(), + active_tab: SettingsTab::General, + device_cache: device::query_all_devices(), + } + } +} + +#[derive(Debug, Clone)] +pub enum Message { + TabSelected(SettingsTab), + // General + ProjectDirChanged(String), + AutoSaveToggled(bool), + AutoSaveIntervalChanged(String), + AskToSaveToggled(bool), + // Audio - Devices + DefaultOutputDeviceSelected(String), + DefaultInputDeviceSelected(String), + DefaultSampleRateSelected(u32), + DefaultOutputBufferSizeSelected(u32), + DefaultInputBufferSizeSelected(u32), + RecordingFormatSelected(RecordingFormat), + RecordingBitDepthSelected(u16), + AutoOversampleToggled(bool), + AutoUndersampleToggled(bool), + // Display + DefaultTrackHeightChanged(String), + ShowToolbarOnOpenToggled(bool), + ShowInspectorOnOpenToggled(bool), + // Actions + Save, + Cancel, +} + +fn section_header(label: &str) -> Element<'_, Message> { + column![ + text(label) + .size(13) + .color(Color::from_rgb8(0xCC, 0xCC, 0xCC)), + horizontal_rule(1), + ] + .spacing(4) + .into() +} + +fn setting_row<'a>( + label: &'a str, + control: Element<'a, Message>, +) -> Element<'a, Message> { + row![ + text(label).size(13).width(200), + control, + ] + .spacing(12) + .align_y(Alignment::Center) + .into() +} + +fn tab_button<'a>(tab: SettingsTab, active: SettingsTab) -> Element<'a, Message> { + let is_active = tab == active; + let btn = button(text(tab.label()).size(13)) + .on_press(Message::TabSelected(tab)) + .padding([6, 16]); + if is_active { + btn.style(|_theme: &Theme, _status| button::Style { + background: Some(Background::Color(Color::from_rgb8(0x00, 0x7A, 0xFF))), + text_color: Color::WHITE, + border: Border { + radius: 6.0.into(), + ..Border::default() + }, + ..button::Style::default() + }) + .into() + } else { + btn.style(|_theme: &Theme, _status| button::Style { + background: Some(Background::Color(Color::from_rgb8(0x52, 0x54, 0x56))), + text_color: Color::from_rgb8(0xCC, 0xCC, 0xCC), + border: Border { + radius: 6.0.into(), + ..Border::default() + }, + ..button::Style::default() + }) + .into() + } +} + +fn view_general(config: &AudioOxideConfig) -> Element<'_, Message> { + let project_dir_input = text_input( + "Project directory", + config.project_dir.to_str().unwrap_or(""), + ) + .on_input(Message::ProjectDirChanged) + .width(300); + + let auto_save_toggle = toggler(config.auto_save) + .on_toggle(Message::AutoSaveToggled) + .width(Length::Shrink); + + let auto_save_interval = text_input( + "seconds", + &config.auto_save_interval_secs.to_string(), + ) + .on_input(Message::AutoSaveIntervalChanged) + .width(80); + + let ask_save_toggle = toggler(config.ask_to_save_on_close) + .on_toggle(Message::AskToSaveToggled) + .width(Length::Shrink); + + column![ + section_header("Project Handling"), + setting_row("Default project directory", project_dir_input.into()), + setting_row("Auto-save", auto_save_toggle.into()), + setting_row("Auto-save interval (sec)", auto_save_interval.into()), + setting_row("Ask to save on close", ask_save_toggle.into()), + ] + .spacing(10) + .padding(16) + .into() +} + +fn view_audio<'a>(config: &'a AudioOxideConfig, device_cache: &'a DeviceCache) -> Element<'a, Message> { + let output_names: Vec = device_cache.output_devices.iter().map(|d| d.name.clone()).collect(); + let input_names: Vec = device_cache.input_devices.iter().map(|d| d.name.clone()).collect(); + + let out_caps = device::find_device(&config.default_audio_device, &device_cache.output_devices); + let in_caps = device::find_device(&config.default_input_device, &device_cache.input_devices); + + // Filter sample rates to intersection of both devices + let sample_rates: Vec = match (out_caps, in_caps) { + (Some(o), Some(i)) => device::negotiate_sample_rates(o, i), + (Some(o), None) => o.supported_sample_rates.clone(), + (None, Some(i)) => i.supported_sample_rates.clone(), + (None, None) => vec![22050, 44100, 48000, 88200, 96000, 176400, 192000], + }; + + // Filter buffer sizes by device capability + let out_buf_sizes = device::buffer_size_options(out_caps.and_then(|c| c.buffer_size_range)); + let in_buf_sizes = device::buffer_size_options(in_caps.and_then(|c| c.buffer_size_range)); + + // Negotiated bit depth + let negotiated_bd = match (out_caps, in_caps) { + (Some(o), Some(i)) => Some(device::negotiate_bit_depth(o, i)), + _ => None, + }; + + let out_device_picker = pick_list( + output_names, + Some(config.default_audio_device.clone()), + Message::DefaultOutputDeviceSelected, + ).width(200); + + let in_device_picker = pick_list( + input_names, + Some(config.default_input_device.clone()), + Message::DefaultInputDeviceSelected, + ).width(200); + + let sr_picker = pick_list( + sample_rates, + Some(config.default_sample_rate), + Message::DefaultSampleRateSelected, + ).width(120); + + let out_bs_picker = pick_list( + out_buf_sizes, + Some(config.default_output_buffer_size), + Message::DefaultOutputBufferSizeSelected, + ).width(120); + + let in_bs_picker = pick_list( + in_buf_sizes, + Some(config.default_input_buffer_size), + Message::DefaultInputBufferSizeSelected, + ).width(120); + + let oversample_toggle = toggler(config.auto_oversample) + .on_toggle(Message::AutoOversampleToggled) + .width(Length::Shrink); + + let undersample_toggle = toggler(config.auto_undersample) + .on_toggle(Message::AutoUndersampleToggled) + .width(Length::Shrink); + + let format_picker = pick_list( + &RecordingFormat::ALL[..], + Some(config.recording_format), + Message::RecordingFormatSelected, + ).width(120); + + let bit_depths: Vec = vec![16, 24, 32, 64]; + let bd_picker = pick_list( + bit_depths, + Some(config.recording_bit_depth), + Message::RecordingBitDepthSelected, + ).width(120); + + let bd_note: Element<_> = if let Some(neg) = negotiated_bd { + if config.recording_bit_depth > neg { + text(format!("Device max: {}bit (converting)", neg)) + .size(10).color(Color::from_rgb8(0xCC, 0x99, 0x33)).into() + } else { + text(format!("Negotiated: {}bit", neg)) + .size(10).color(Color::from_rgb8(0x66, 0x88, 0x66)).into() + } + } else { + text("").into() + }; + + column![ + section_header("Output"), + setting_row("Output device", out_device_picker.into()), + setting_row("Output buffer size", out_bs_picker.into()), + section_header("Input"), + setting_row("Input device", in_device_picker.into()), + setting_row("Input buffer size", in_bs_picker.into()), + section_header("Sample Rate"), + setting_row("Default sample rate", sr_picker.into()), + setting_row("Auto oversample", oversample_toggle.into()), + setting_row("Auto undersample", undersample_toggle.into()), + section_header("Recording"), + setting_row("File format", format_picker.into()), + setting_row("Bit depth", row![bd_picker, bd_note].spacing(8).align_y(Alignment::Center).into()), + ] + .spacing(8) + .padding(16) + .into() +} + +fn view_display(config: &AudioOxideConfig) -> Element<'_, Message> { + let track_height_input = text_input("pixels", &format!("{:.0}", config.default_track_height)) + .on_input(Message::DefaultTrackHeightChanged) + .width(80); + + let toolbar_toggle = toggler(config.show_toolbar_on_open) + .on_toggle(Message::ShowToolbarOnOpenToggled) + .width(Length::Shrink); + + let inspector_toggle = toggler(config.show_inspector_on_open) + .on_toggle(Message::ShowInspectorOnOpenToggled) + .width(Length::Shrink); + + column![ + section_header("Layout"), + setting_row("Default track height (px)", track_height_input.into()), + setting_row("Show toolbar on open", toolbar_toggle.into()), + setting_row("Show inspector on open", inspector_toggle.into()), + ] + .spacing(10) + .padding(16) + .into() +} + +fn view_advanced(_config: &AudioOxideConfig) -> Element<'_, Message> { + column![ + section_header("Engine"), + text("Engine configuration options will appear here.") + .size(12) + .color(Color::from_rgb8(0x88, 0x88, 0x88)), + section_header("Plugins"), + text("Module/plugin scan paths and loading options will appear here.") + .size(12) + .color(Color::from_rgb8(0x88, 0x88, 0x88)), + ] + .spacing(10) + .padding(16) + .into() +} + +pub fn view(state: &State) -> Element<'_, Message> { + let title = text("Settings").size(20); + + let tabs = SettingsTab::ALL.iter().fold( + row![].spacing(4), + |r, tab| r.push(tab_button(*tab, state.active_tab)), + ); + + let content: Element<_> = match state.active_tab { + SettingsTab::General => view_general(&state.config), + SettingsTab::Audio => view_audio(&state.config, &state.device_cache), + SettingsTab::Display => view_display(&state.config), + SettingsTab::Advanced => view_advanced(&state.config), + }; + + let content_area = container(content) + .width(Length::Fill) + .height(Length::Fill) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb8(0x32, 0x34, 0x36))), + border: Border { + radius: 6.0.into(), + color: Color::from_rgb8(0x48, 0x4A, 0x4C), + width: 1.0, + }, + ..container::Style::default() + }); + + let cancel_btn = button(text("Cancel").size(13)).on_press(Message::Cancel); + let save_btn = button(text("Save").size(13)) + .on_press(Message::Save) + .style(|_theme: &Theme, _status| button::Style { + background: Some(Background::Color(Color::from_rgb8(0x00, 0x7A, 0xFF))), + text_color: Color::WHITE, + border: Border { + radius: 6.0.into(), + ..Border::default() + }, + ..button::Style::default() + }); + + let actions = row![Space::new(Length::Fill, 0), cancel_btn, save_btn] + .spacing(8) + .align_y(Alignment::Center); + + let dialog = column![title, tabs, content_area, actions] + .spacing(12) + .padding(20); + + container(dialog) + .max_width(660) + .max_height(540) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color, + )), + border: Border { + radius: 10.0.into(), + color: Color::from_rgb8(0x52, 0x54, 0x56), + width: 1.0, + }, + ..container::Style::default() + }) + .into() +} + +pub fn handle_message(state: &mut State, message: Message) -> bool { + match message { + Message::TabSelected(tab) => state.active_tab = tab, + Message::ProjectDirChanged(p) => state.config.project_dir = p.into(), + Message::AutoSaveToggled(v) => state.config.auto_save = v, + Message::AutoSaveIntervalChanged(s) => { + if let Ok(v) = s.parse::() { + state.config.auto_save_interval_secs = v; + } + } + Message::AskToSaveToggled(v) => state.config.ask_to_save_on_close = v, + Message::DefaultOutputDeviceSelected(d) => state.config.default_audio_device = d, + Message::DefaultInputDeviceSelected(d) => state.config.default_input_device = d, + Message::DefaultSampleRateSelected(sr) => state.config.default_sample_rate = sr, + Message::DefaultOutputBufferSizeSelected(bs) => state.config.default_output_buffer_size = bs, + Message::DefaultInputBufferSizeSelected(bs) => state.config.default_input_buffer_size = bs, + Message::RecordingFormatSelected(f) => state.config.recording_format = f, + Message::RecordingBitDepthSelected(bd) => state.config.recording_bit_depth = bd, + Message::AutoOversampleToggled(v) => state.config.auto_oversample = v, + Message::AutoUndersampleToggled(v) => state.config.auto_undersample = v, + Message::DefaultTrackHeightChanged(s) => { + if let Ok(v) = s.parse::() { + if v > 0.0 { + state.config.default_track_height = v; + } + } + } + Message::ShowToolbarOnOpenToggled(v) => state.config.show_toolbar_on_open = v, + Message::ShowInspectorOnOpenToggled(v) => state.config.show_inspector_on_open = v, + Message::Save => return true, + Message::Cancel => return true, + } + false +} diff --git a/au-o2-gui/src/gui/splash.rs b/au-o2-gui/src/gui/splash.rs new file mode 100644 index 0000000..f8a5faa --- /dev/null +++ b/au-o2-gui/src/gui/splash.rs @@ -0,0 +1,80 @@ +use crate::entry::Message; +use iced::widget::{button, column, container, row, svg, text, Space}; +use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; + +const LOGO_SVG: &[u8] = include_bytes!("../../assets/logo-placeholder.svg"); + +fn nav_button_style(_theme: &Theme, status: button::Status) -> button::Style { + let bg = match status { + button::Status::Hovered => Color::from_rgb8(0x00, 0x7A, 0xFF), + button::Status::Pressed => Color::from_rgb8(0x00, 0x6A, 0xDD), + _ => Color::from_rgba(1.0, 1.0, 1.0, 0.12), + }; + let text_color = match status { + button::Status::Hovered | button::Status::Pressed => Color::WHITE, + _ => Color::from_rgb8(0xDD, 0xDD, 0xDD), + }; + button::Style { + background: Some(Background::Color(bg)), + text_color, + border: Border { + radius: 8.0.into(), + ..Border::default() + }, + ..button::Style::default() + } +} + +pub fn view() -> Element<'static, Message> { + let logo = svg(svg::Handle::from_memory(LOGO_SVG)) + .width(180) + .height(180); + + let title = text("Audio Oxide") + .size(36) + .color(Color::from_rgb8(0xEE, 0xEE, 0xEE)); + + let author = text("pszsh / jess@else-if.org") + .size(14) + .color(Color::from_rgb8(0xAA, 0xAA, 0xAA)); + + let url = text("www.else-if.org") + .size(13) + .color(Color::from_rgb8(0x00, 0x7A, 0xFF)); + + let nav = row![ + button(text("Recent").size(14)) + .on_press(Message::ViewRecentProjects) + .style(nav_button_style) + .padding([10, 28]), + button(text("Find").size(14)) + .on_press(Message::ViewFindProject) + .style(nav_button_style) + .padding([10, 28]), + button(text("New Project").size(14)) + .on_press(Message::ViewNewProject) + .style(nav_button_style) + .padding([10, 28]), + ] + .spacing(12); + + let content = column![ + logo, + Space::with_height(20), + title, + Space::with_height(8), + author, + Space::with_height(4), + url, + Space::with_height(40), + nav, + ] + .align_x(Alignment::Center); + + container(content) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .into() +} diff --git a/au-o2-gui/src/gui/styles.rs b/au-o2-gui/src/gui/styles.rs new file mode 100644 index 0000000..703ca13 --- /dev/null +++ b/au-o2-gui/src/gui/styles.rs @@ -0,0 +1,30 @@ +use iced::widget::slider; +use iced::{Background, Border, Color, Theme}; + +pub fn oxide_slider(_theme: &Theme, status: slider::Status) -> slider::Style { + let handle_color = match status { + slider::Status::Hovered | slider::Status::Dragged => { + Color::from_rgb8(0x5A, 0x9E, 0xFC) + } + _ => Color::from_rgb8(0xAA, 0xAA, 0xAA), + }; + slider::Style { + rail: slider::Rail { + backgrounds: ( + Background::Color(Color::from_rgb8(0x55, 0x88, 0xAA)), + Background::Color(Color::from_rgb8(0x3E, 0x40, 0x42)), + ), + width: 3.0, + border: Border { + radius: 1.5.into(), + ..Border::default() + }, + }, + handle: slider::Handle { + shape: slider::HandleShape::Circle { radius: 5.0 }, + background: Background::Color(handle_color), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + } +} diff --git a/au-o2-gui/src/gui/time_utility.rs b/au-o2-gui/src/gui/time_utility.rs new file mode 100644 index 0000000..7baa4e5 --- /dev/null +++ b/au-o2-gui/src/gui/time_utility.rs @@ -0,0 +1,365 @@ +use crate::entry::Message; +use iced::widget::{button, column, mouse_area, row, text}; +use iced::{Alignment, Element, Length, Task}; +use std::time::{Duration, Instant}; +use tokio::time::sleep; + +// --- State for the Time Utility --- + +/// Represents a single tap event, capturing its start and end time. +#[derive(Debug, Clone, Copy)] +pub struct TapEvent { + pub start_time: Instant, + pub end_time: Instant, +} + +/// Holds the results of the tempo analysis. +#[derive(Debug, Clone)] +pub struct AnalysisResult { + pub bpm: u32, + pub grid_name: String, + pub grid_fraction: String, + pub suggested_time_signature: String, +} + +/// Contains all state related to the tempo tapping feature. +#[derive(Default, Debug, Clone)] +pub struct State { + pub tap_events: Vec, + pub tap_start_time: Option, + pub result: Option, +} + +/// Extracts the primary time signature from a suggestion string like "6/8 or 3/4". +fn get_primary_time_signature(suggestion: &str) -> String { + suggestion.split_whitespace().next().unwrap_or("").to_string() +} + +// --- View for the Time Utility --- + +pub fn view(state: &State) -> Element<'static, Message> { + let tapper_button = mouse_area( + button( + text("Tap and Hold Rhythm Here") + .width(Length::Fill) + .align_x(Alignment::Center), + ) + .width(Length::Fill) + .padding(15), + ) + .on_press(Message::TimeUtilityTapPressed) + .on_release(Message::TimeUtilityTapReleased); + + let (result_display, action_buttons) = if let Some(result) = &state.result { + let display: Element<_> = column![ + text(format!("Detected Tempo: {} BPM", result.bpm)).size(24), + text(format!( + "Rhythmic Grid: {} ({})", + result.grid_name, result.grid_fraction + )) + .size(16), + text(format!( + "Suggested Time Signature: {}", + result.suggested_time_signature + )) + .size(16), + ] + .align_x(Alignment::Center) + .spacing(10) + .into(); + + let primary_sig = get_primary_time_signature(&result.suggested_time_signature); + + let set_tempo_button = button("Set Tempo").on_press(Message::TimeUtilitySet(result.bpm)); + let set_sig_button = button("Set Time Sig") + .on_press(Message::TimeUtilitySetTimeSignature(primary_sig.clone())); + let set_both_button = + button("Set Both").on_press(Message::TimeUtilitySetBoth(result.bpm, primary_sig)); + let cancel_button = button("Cancel").on_press(Message::TimeUtilityCancel); + + let buttons: Element<_> = row![ + set_tempo_button, + set_sig_button, + set_both_button, + cancel_button + ] + .spacing(10) + .into(); + + (display, buttons) + } else { + let message = { + let tap_count = state.tap_events.len(); + if tap_count < 3 { + format!("Tap and hold at least {} more time(s)...", 3 - tap_count) + } else { + "Tap again to refine...".to_string() + } + }; + let display: Element<_> = column![text(message).size(16)] + .align_x(Alignment::Center) + .spacing(10) + .into(); + + let cancel_button = button("Cancel").on_press(Message::TimeUtilityCancel); + let buttons: Element<_> = row![cancel_button].spacing(10).into(); + + (display, buttons) + }; + + let content = column![ + text("Time Utility").size(40), + text("Tap a rhythm to detect its tempo and feel.").size(20), + tapper_button, + result_display, + action_buttons, + ] + .spacing(25) + .align_x(Alignment::Center) + .max_width(500); + + content.into() +} + +// --- Time Utility Logic & Event Handlers --- + +/// Helper function to handle the logic for a tap press. +pub fn handle_tap_pressed(tapper_state: &mut State) { + if tapper_state.tap_start_time.is_none() { + tapper_state.tap_start_time = Some(Instant::now()); + } +} + +/// Helper function to handle the logic for a tap release. +pub fn handle_tap_released(tapper_state: &mut State) -> Task { + if let Some(start_time) = tapper_state.tap_start_time.take() { + let event = TapEvent { + start_time, + end_time: Instant::now(), + }; + tapper_state.tap_events.push(event); + return Task::perform(sleep(Duration::from_millis(350)), |_| { + Message::RunTimeUtilityAnalysis + }); + } + Task::none() +} + +// --- Full, Correct Tempo Analysis Logic --- + +const MIN_TAPS_FOR_ANALYSIS: usize = 3; +const TOLERANCE_FACTOR: f64 = 0.15; +const MIN_TOLERANCE_MS: f64 = 20.0; +const MAX_TOLERANCE_MS: f64 = 75.0; +const OFF_GRID_WEIGHT: f64 = 10.0; +const RESOLUTION_WEIGHT: f64 = 0.08; +const MAX_NUMERATOR: u32 = 64; +const MAX_DENOMINATOR: u32 = 256; + +pub fn run_analysis(events: &[TapEvent]) -> Option { + if events.len() < MIN_TAPS_FOR_ANALYSIS { + return None; + } + + let tempo = calculate_tempo(events)?; + let durations_ms = get_durations_for_analysis(events); + if durations_ms.is_empty() { + return None; + } + + let (num, den, name) = determine_base_subdivision(&durations_ms, tempo as f64)?; + + let suggested_sig = suggest_time_signature(num, den); + + Some(AnalysisResult { + bpm: tempo, + grid_name: name, + grid_fraction: format!("{}/{}", num, den), + suggested_time_signature: suggested_sig, + }) +} + +fn calculate_tempo(events: &[TapEvent]) -> Option { + if events.len() < 2 { + return None; + } + let mut intervals: Vec = events + .windows(2) + .map(|w| w[1].start_time.duration_since(w[0].start_time).as_millis() as f64) + .filter(|&i| i > 60.0 && i < 3500.0) + .collect(); + + if intervals.is_empty() { + return None; + } + intervals.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let median_interval = intervals[intervals.len() / 2]; + + if median_interval <= 0.0 { + return None; + } + Some((60_000.0 / median_interval).round() as u32) +} + +fn get_durations_for_analysis(events: &[TapEvent]) -> Vec { + let mut durations = Vec::new(); + for event in events { + let hold_duration = event.end_time.duration_since(event.start_time).as_millis() as f64; + if hold_duration > 15.0 { + durations.push(hold_duration); + } + } + for window in events.windows(2) { + let rest_duration = window[1] + .start_time + .duration_since(window[0].end_time) + .as_millis() as f64; + if rest_duration > 15.0 { + durations.push(rest_duration); + } + } + durations +} + +fn gcd(a: u32, b: u32) -> u32 { + if b == 0 { + a + } else { + gcd(b, a % b) + } +} + +fn get_subdivision_name(num: u32, den: u32) -> String { + match (num, den) { + (1, 1) => "Quarter Note".to_string(), + (1, 2) => "8th Note".to_string(), + (1, 3) => "Triplet Quarter".to_string(), + (1, 4) => "16th Note".to_string(), + (1, 5) => "Quintuplet 8th".to_string(), + (1, 6) => "Triplet 8th".to_string(), + (1, 7) => "Septuplet 8th".to_string(), + (1, 8) => "32nd Note".to_string(), + (1, 12) => "Triplet 16th".to_string(), + (1, 16) => "64th Note".to_string(), + (3, 2) => "Dotted Half".to_string(), + (3, 4) => "Dotted Quarter".to_string(), + (3, 8) => "Dotted 8th".to_string(), + (3, 16) => "Dotted 16th".to_string(), + _ => format!("{}/{} QN", num, den), + } +} + +fn suggest_time_signature(_numerator: u32, denominator: u32) -> String { + if denominator % 3 == 0 || denominator % 6 == 0 || denominator % 12 == 0 { + "6/8 or 3/4".to_string() + } else if denominator % 5 == 0 { + "5/4 or 5/8".to_string() + } else if denominator % 7 == 0 { + "7/4 or 7/8".to_string() + } else { + "4/4".to_string() + } +} + +fn determine_base_subdivision( + all_durations_ms: &[f64], + bpm: f64, +) -> Option<(u32, u32, String)> { + if all_durations_ms.is_empty() { + return None; + } + + let min_duration_ms = all_durations_ms + .iter() + .filter(|&&d| d > 0.0) + .min_by(|a, b| a.partial_cmp(b).unwrap()) + .copied() + .unwrap_or(f64::MAX); + + if min_duration_ms == f64::MAX { + return None; + } + + let beat_ms = 60000.0 / bpm; + let mut best_score = f64::INFINITY; + let mut best_candidate = None; + + for denominator in 1..=MAX_DENOMINATOR { + let step = if denominator > 128 { + 8 + } else if denominator > 64 { + 4 + } else if denominator > 32 { + 2 + } else { + 1 + }; + + let common_denominators = [1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 24]; + if denominator % step != 0 && !common_denominators.contains(&denominator) { + continue; + } + + for numerator in 1..=MAX_NUMERATOR { + let common_divisor = gcd(numerator, denominator); + let (simple_num, simple_den) = (numerator / common_divisor, denominator / common_divisor); + let qn_val = simple_num as f64 / simple_den as f64; + let base_note_ms = beat_ms * qn_val; + + if base_note_ms <= 5.0 || !base_note_ms.is_finite() { + continue; + } + + let tolerance = (base_note_ms * TOLERANCE_FACTOR) + .max(MIN_TOLERANCE_MS) + .min(MAX_TOLERANCE_MS); + + if base_note_ms > min_duration_ms * 2.0 + tolerance { + continue; + } + if base_note_ms < tolerance / 2.0 && beat_ms < 400.0 { + continue; + } + + let mut off_grid = 0.0; + let mut total_error = 0.0; + let mut count = 0; + + for &d in all_durations_ms { + if d <= 0.0 { + continue; + } + count += 1; + let num_units = (d / base_note_ms).round(); + if num_units < 1.0 { + continue; + } + let quantized_duration = num_units * base_note_ms; + let error = (d - quantized_duration).abs(); + if error > tolerance { + off_grid += 1.0; + } + total_error += error; + } + + if count == 0 { + continue; + } + + let avg_error = total_error / count as f64; + let complexity_penalty = (1.0 / base_note_ms) * (simple_den as f64 / 8.0); + let score = + avg_error + (off_grid * OFF_GRID_WEIGHT) + (complexity_penalty * RESOLUTION_WEIGHT); + + if score < best_score { + best_score = score; + best_candidate = Some(( + simple_num, + simple_den, + get_subdivision_name(simple_num, simple_den), + )); + } + } + } + best_candidate +} \ No newline at end of file diff --git a/au-o2-gui/src/history.rs b/au-o2-gui/src/history.rs new file mode 100644 index 0000000..5b03250 --- /dev/null +++ b/au-o2-gui/src/history.rs @@ -0,0 +1,109 @@ +use uuid::Uuid; +use crate::region::Region; +use crate::track::Track; +use crate::timing::{MusicalTime, TempoMap}; + +#[derive(Debug, Clone)] +pub enum EditCommand { + MoveRegion { + track_index: usize, + region_id: Uuid, + old_start: MusicalTime, + new_start: MusicalTime, + old_start_sample: u64, + new_start_sample: u64, + }, + MoveRegionAcrossTracks { + region_id: Uuid, + old_track: usize, + new_track: usize, + old_start: MusicalTime, + new_start: MusicalTime, + old_start_sample: u64, + new_start_sample: u64, + }, + DeleteRegion { + track_index: usize, + region: Region, + }, + SplitRegion { + track_index: usize, + original_id: Uuid, + original_region: Region, + left_id: Uuid, + right_id: Uuid, + split_sample: u64, + }, + DeleteTrack { + index: usize, + track: Track, + }, + CreateTrack { + index: usize, + }, + DuplicateTrack { + source_index: usize, + new_index: usize, + }, + PasteRegions { + entries: Vec<(usize, Region)>, + }, + CutRegions { + entries: Vec<(usize, Region)>, + }, + AudioQuantize { + track_index: usize, + original_region: Region, + result_regions: Vec, + }, + SetTempo { + old_tempo: f32, + new_tempo: f32, + old_tempo_map: TempoMap, + new_tempo_map: TempoMap, + }, + SplitStems { + track_indices: Vec, + }, +} + +pub struct History { + undo_stack: Vec, + redo_stack: Vec, +} + +impl History { + pub fn new() -> Self { + Self { + undo_stack: Vec::new(), + redo_stack: Vec::new(), + } + } + + pub fn push(&mut self, cmd: EditCommand) { + self.undo_stack.push(cmd); + self.redo_stack.clear(); + } + + pub fn pop_undo(&mut self) -> Option { + self.undo_stack.pop() + } + + pub fn push_redo(&mut self, cmd: EditCommand) { + self.redo_stack.push(cmd); + } + + pub fn pop_redo(&mut self) -> Option { + self.redo_stack.pop() + } + + #[allow(dead_code)] + pub fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + #[allow(dead_code)] + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } +} diff --git a/au-o2-gui/src/main.rs b/au-o2-gui/src/main.rs new file mode 100644 index 0000000..c03f493 --- /dev/null +++ b/au-o2-gui/src/main.rs @@ -0,0 +1,27 @@ +#[macro_use] +pub mod debug; +mod automation; +mod behaviors; +mod clipboard; +mod codec; +mod config; +mod editor; +mod engine; +mod entry; +mod export; +mod first_run; +mod gui; +mod history; +mod module_gui_manager; +mod modules; +mod routing; +mod region; +mod timing; +mod track; +mod triggers; +mod waveform; + +pub fn main() -> iced::Result { + debug::init(); + entry::main() +} \ No newline at end of file diff --git a/au-o2-gui/src/module_gui_manager.rs b/au-o2-gui/src/module_gui_manager.rs new file mode 100644 index 0000000..c9c9b90 --- /dev/null +++ b/au-o2-gui/src/module_gui_manager.rs @@ -0,0 +1,227 @@ +use std::collections::HashMap; + +use iced::window; +use iced::{Element, Task}; +use oxforge::mdk::{ModuleGuiDescriptor, ToGuiMessage}; + +use crate::editor::{Message, ModuleParamState}; +use crate::engine::{EngineCommand, EngineHandle}; +use crate::gui::module_window::ModuleWindowManager; +use crate::modules::plugin_host::FramebufferGuiBridge; + +pub struct ModuleGuiManager { + pub modules_with_gui: std::collections::HashSet, + pub gui_descriptors: HashMap, + module_window_manager: ModuleWindowManager, + pending_gui_opens: std::collections::HashSet, + pending_bridges: HashMap, +} + +impl ModuleGuiManager { + pub fn new() -> Self { + Self { + modules_with_gui: std::collections::HashSet::new(), + gui_descriptors: HashMap::new(), + module_window_manager: ModuleWindowManager::new(), + pending_gui_opens: std::collections::HashSet::new(), + pending_bridges: HashMap::new(), + } + } + + pub fn handle_open_gui( + &mut self, + module_id: u32, + engine: Option<&EngineHandle>, + module_params: &ModuleParamState, + module_names: &HashMap, + ) -> Task { + if self.module_window_manager.is_open(module_id) { + return self.handle_close_gui(module_id, engine); + } + let has_descs = module_params.descriptors.contains_key(&module_id); + let has_gui_desc = self.gui_descriptors.contains_key(&module_id); + if !has_descs { + if let Some(engine) = engine { + engine.send(EngineCommand::QueryModuleParams { module_id }); + } + } + if !has_gui_desc { + if let Some(engine) = engine { + engine.send(EngineCommand::QueryModuleGuiDescriptor { module_id }); + } + } + if has_descs && has_gui_desc { + self.open_module_window(module_id, engine, module_params, module_names) + } else { + self.pending_gui_opens.insert(module_id); + Task::none() + } + } + + pub fn handle_close_gui( + &mut self, + module_id: u32, + engine: Option<&EngineHandle>, + ) -> Task { + if let Some(task) = self.module_window_manager.close(module_id) { + if let Some(engine) = engine { + engine.send(EngineCommand::DetachModuleGuiFence { module_id }); + } + return task; + } + Task::none() + } + + pub fn handle_framebuffer_mouse_down(&self, module_id: u32) { + self.module_window_manager.framebuffer_mouse_down(module_id, 0, 0); + } + + pub fn handle_framebuffer_mouse_up(&self, module_id: u32) { + self.module_window_manager.framebuffer_mouse_up(module_id, 0, 0); + } + + pub fn handle_framebuffer_resize(&mut self, module_id: u32, width: u32, height: u32) { + self.module_window_manager.resize_framebuffer(module_id, width, height); + } + + pub fn write_param(&mut self, module_id: u32, key: &str, value: f32) { + self.module_window_manager.write_param(module_id, key, value); + } + + /// Handle GUI-related engine events during tick. + /// Returns any tasks to batch from pending gui opens becoming ready. + pub fn tick( + &mut self, + engine: Option<&EngineHandle>, + module_params: &mut ModuleParamState, + module_names: &HashMap, + ) -> Vec> { + if let Some(engine) = engine { + for (module_id, msg) in engine.poll_gui_messages() { + match msg { + ToGuiMessage::VisualizationData { data } => { + self.module_window_manager.receive_visualization(module_id, data); + } + ToGuiMessage::Log(_) => {} + ToGuiMessage::UpdateParameterDisplay { .. } => {} + } + } + } + + for (module_id, key, value) in self.module_window_manager.poll_fence_changes() { + module_params.values.insert((module_id, key), value); + } + + if let Some(engine) = engine { + for (module_id, bridge) in engine.poll_bridges() { + self.pending_bridges.insert(module_id, bridge); + } + } + + self.module_window_manager.tick_framebuffers(); + + let mut tasks = Vec::new(); + let ready: Vec = self.pending_gui_opens.iter() + .filter(|id| { + self.gui_descriptors.contains_key(id) + && module_params.descriptors.contains_key(id) + }) + .copied() + .collect(); + for module_id in ready { + self.pending_gui_opens.remove(&module_id); + tasks.push(self.open_module_window(module_id, engine, module_params, module_names)); + } + tasks + } + + /// Handle ModuleGuiDescriptorReady engine event + pub fn handle_gui_descriptor_ready(&mut self, module_id: u32, descriptor: Option) { + if let Some(desc) = descriptor { + self.gui_descriptors.insert(module_id, desc); + } + } + + /// Handle ModuleLoaded: track has_gui and gui_descriptor + pub fn handle_module_loaded(&mut self, module_id: u32, has_gui: bool, gui_descriptor: Option) { + if has_gui { + self.modules_with_gui.insert(module_id); + } + if let Some(desc) = gui_descriptor { + self.gui_descriptors.insert(module_id, desc); + } + } + + /// Clean up GUI state when a module is removed + pub fn handle_module_removed(&mut self, module_id: u32) { + self.modules_with_gui.remove(&module_id); + self.gui_descriptors.remove(&module_id); + if self.module_window_manager.is_open(module_id) { + let _ = self.module_window_manager.close(module_id); + } + } + + fn open_module_window( + &mut self, + module_id: u32, + engine: Option<&EngineHandle>, + module_params: &ModuleParamState, + module_names: &HashMap, + ) -> Task { + let gui_desc = match self.gui_descriptors.get(&module_id) { + Some(d) => d.clone(), + None => return Task::none(), + }; + let descriptors = match module_params.descriptors.get(&module_id) { + Some(d) => d, + None => return Task::none(), + }; + let module_name = module_names.get(&module_id) + .cloned() + .unwrap_or_else(|| "Unknown".into()); + + let toolkit = gui_desc.toolkit; + let bridge = self.pending_bridges.remove(&module_id); + let (_wid, task, audio_handle) = self.module_window_manager.open( + module_id, + module_name, + gui_desc, + descriptors, + toolkit, + bridge, + ); + + if let Some(engine) = engine { + engine.send(EngineCommand::AttachModuleGuiFence { + module_id, + fence: audio_handle, + }); + } + + task + } + + pub fn module_for_window(&self, window_id: window::Id) -> Option { + self.module_window_manager.module_for_window(window_id) + } + + pub fn module_window_view(&self, window_id: window::Id) -> Option> { + self.module_window_manager.view(window_id) + } + + pub fn module_window_title(&self, window_id: window::Id) -> Option { + self.module_window_manager.window_title(window_id) + } + + pub fn close_module_window_by_id(&mut self, window_id: window::Id, engine: Option<&EngineHandle>) -> Task { + if let Some(module_id) = self.module_window_manager.module_for_window(window_id) { + if let Some(task) = self.module_window_manager.close(module_id) { + if let Some(engine) = engine { + engine.send(EngineCommand::DetachModuleGuiFence { module_id }); + } + return task; + } + } + Task::none() + } +} diff --git a/au-o2-gui/src/modules/mod.rs b/au-o2-gui/src/modules/mod.rs new file mode 100644 index 0000000..19d3eb0 --- /dev/null +++ b/au-o2-gui/src/modules/mod.rs @@ -0,0 +1,3 @@ +pub mod registry; + +pub use oxforge::mdk::{AnalyticSignal, PhasePoint, VisualizationFrame}; diff --git a/au-o2-gui/src/modules/registry.rs b/au-o2-gui/src/modules/registry.rs new file mode 100644 index 0000000..565a0eb --- /dev/null +++ b/au-o2-gui/src/modules/registry.rs @@ -0,0 +1,68 @@ +use oxforge::mdk::GlobalConfig; + +use crate::engine::host::ModuleHost; + +use oxide_hilbert::HilbertModule; +use oxide_input_router::InputRouterModule; +use oxide_output_mixer::OutputMixerModule; +use oxide_recorder::RecorderModule; +use oxide_region_player::RegionPlayerModule; +use oxide_spiral_visualizer::SpiralVisualizer; + +pub struct ModuleDescriptor { + pub type_name: &'static str, + pub display_name: &'static str, + pub description: &'static str, + pub system: bool, +} + +pub const BUILTIN_MODULES: &[ModuleDescriptor] = &[ + ModuleDescriptor { + type_name: "region_player", + display_name: "Region Player", + description: "Plays back recorded regions", + system: true, + }, + ModuleDescriptor { + type_name: "input_router", + display_name: "Input Router", + description: "Routes hardware input to armed tracks", + system: true, + }, + ModuleDescriptor { + type_name: "hilbert", + display_name: "Hilbert Transform", + description: "Computes analytic signal via FFT", + system: true, + }, + ModuleDescriptor { + type_name: "recorder", + display_name: "Recorder", + description: "Captures audio for XTC encoding", + system: true, + }, + ModuleDescriptor { + type_name: "output_mixer", + display_name: "Output Mixer", + description: "Mixes all track buses to hw_output", + system: true, + }, + ModuleDescriptor { + type_name: "spiral_visualizer", + display_name: "Spiral Visualizer", + description: "3D phase-space spiral visualization", + system: false, + }, +]; + +pub fn load_builtin(host: &mut ModuleHost, type_name: &str, config: &GlobalConfig) -> Option { + match type_name { + "region_player" => Some(host.load_builtin::("RegionPlayer", config)), + "input_router" => Some(host.load_builtin::("InputRouter", config)), + "hilbert" => Some(host.load_builtin::("HilbertTransform", config)), + "recorder" => Some(host.load_builtin::("Recorder", config)), + "output_mixer" => Some(host.load_builtin::("OutputMixer", config)), + "spiral_visualizer" => Some(host.load_builtin::("SpiralVisualizer", config)), + _ => None, + } +} diff --git a/au-o2-gui/src/region.rs b/au-o2-gui/src/region.rs new file mode 100644 index 0000000..5bf082d --- /dev/null +++ b/au-o2-gui/src/region.rs @@ -0,0 +1,108 @@ +use crate::timing::MusicalTime; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct MidiNote { + pub start_tick: u64, + pub duration_ticks: u64, + pub note: u8, + pub velocity: u8, + pub channel: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Region { + pub id: Uuid, + pub start_time: MusicalTime, + pub duration: MusicalTime, + #[serde(default)] + pub audio_file: Option, + #[serde(default)] + pub start_sample: u64, + #[serde(default)] + pub length_samples: u64, + pub selected: bool, + #[serde(default)] + pub fade_in_samples: u64, + #[serde(default)] + pub fade_out_samples: u64, + #[serde(default)] + pub midi_notes: Vec, + #[serde(default = "default_playback_rate")] + pub playback_rate: f32, +} + +fn default_playback_rate() -> f32 { 1.0 } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TakeFolder { + pub id: Uuid, + pub take_ids: Vec, + pub active_index: usize, +} + +impl TakeFolder { + pub fn new(take_ids: Vec) -> Self { + let active = take_ids.len().saturating_sub(1); + Self { + id: Uuid::new_v4(), + take_ids, + active_index: active, + } + } + + pub fn active_take_id(&self) -> Option { + self.take_ids.get(self.active_index).copied() + } +} + +impl Region { + pub fn with_audio( + start_time: MusicalTime, + duration: MusicalTime, + audio_file: String, + start_sample: u64, + length_samples: u64, + ) -> Self { + Self { + id: Uuid::new_v4(), + start_time, + duration, + audio_file: Some(audio_file), + start_sample, + length_samples, + selected: false, + fade_in_samples: 0, + fade_out_samples: 0, + midi_notes: Vec::new(), + playback_rate: 1.0, + } + } + + pub fn with_midi( + start_time: MusicalTime, + duration: MusicalTime, + start_sample: u64, + length_samples: u64, + notes: Vec, + ) -> Self { + Self { + id: Uuid::new_v4(), + start_time, + duration, + audio_file: None, + start_sample, + length_samples, + selected: false, + fade_in_samples: 0, + fade_out_samples: 0, + midi_notes: notes, + playback_rate: 1.0, + } + } + + pub fn is_midi(&self) -> bool { + !self.midi_notes.is_empty() && self.audio_file.is_none() + } +} diff --git a/au-o2-gui/src/routing.rs b/au-o2-gui/src/routing.rs new file mode 100644 index 0000000..0190e66 --- /dev/null +++ b/au-o2-gui/src/routing.rs @@ -0,0 +1,266 @@ +use std::collections::HashMap; + +use crate::editor::ModuleParamState; +use crate::engine::{EngineCommand, EngineHandle}; +use crate::track::Track; + +pub struct RoutingManager { + pub module_names: HashMap, + pub disabled_modules: std::collections::HashSet, + pub module_picker_track: Option, + pub send_picker_track: Option, +} + +impl RoutingManager { + pub fn new() -> Self { + Self { + module_names: HashMap::new(), + disabled_modules: std::collections::HashSet::new(), + module_picker_track: None, + send_picker_track: None, + } + } + + pub fn handle_add_module( + &mut self, + track_idx: usize, + module_type: String, + tracks: &[Track], + engine: Option<&EngineHandle>, + ) { + if let Some(track) = tracks.get(track_idx) { + let chain_pos = track.module_chain.len(); + if let Some(engine) = engine { + engine.send(EngineCommand::LoadModuleOnBus { + bus_name: track.bus_name.clone(), + module_type, + chain_position: chain_pos, + }); + } + } + self.module_picker_track = None; + } + + pub fn handle_remove_module( + &mut self, + track_idx: usize, + module_id: u32, + tracks: &mut [Track], + engine: Option<&EngineHandle>, + module_params: &mut ModuleParamState, + gui_cleanup: &mut dyn FnMut(u32), + ) -> bool { + if let Some(track) = tracks.get_mut(track_idx) { + track.module_chain.retain(|&id| id != module_id); + self.module_names.remove(&module_id); + self.disabled_modules.remove(&module_id); + module_params.remove_module(module_id); + gui_cleanup(module_id); + if let Some(engine) = engine { + engine.send(EngineCommand::DetachModuleGuiFence { module_id }); + engine.send(EngineCommand::UnloadModule { module_id }); + } + return true; // dirty + } + false + } + + pub fn handle_toggle_disabled( + &mut self, + track_idx: usize, + module_id: u32, + tracks: &[Track], + engine: Option<&EngineHandle>, + ) -> bool { + if tracks.get(track_idx).is_some() { + let disabled = if self.disabled_modules.contains(&module_id) { + self.disabled_modules.remove(&module_id); + false + } else { + self.disabled_modules.insert(module_id); + true + }; + if let Some(engine) = engine { + engine.send(EngineCommand::SetModuleDisabled { module_id, disabled }); + } + return true; // dirty + } + false + } + + pub fn handle_move_module_up( + &mut self, + track_idx: usize, + module_id: u32, + tracks: &mut [Track], + engine: Option<&EngineHandle>, + ) -> bool { + if let Some(track) = tracks.get_mut(track_idx) { + if let Some(pos) = track.module_chain.iter().position(|&id| id == module_id) { + if pos > 0 { + track.module_chain.swap(pos, pos - 1); + for (i, &mid) in track.module_chain.iter().enumerate() { + if let Some(engine) = engine { + engine.send(EngineCommand::SetModuleChainPosition { + module_id: mid, + bus_name: track.bus_name.clone(), + chain_position: i, + }); + } + } + return true; // dirty + } + } + } + false + } + + pub fn handle_move_module_down( + &mut self, + track_idx: usize, + module_id: u32, + tracks: &mut [Track], + engine: Option<&EngineHandle>, + ) -> bool { + if let Some(track) = tracks.get_mut(track_idx) { + if let Some(pos) = track.module_chain.iter().position(|&id| id == module_id) { + if pos + 1 < track.module_chain.len() { + track.module_chain.swap(pos, pos + 1); + for (i, &mid) in track.module_chain.iter().enumerate() { + if let Some(engine) = engine { + engine.send(EngineCommand::SetModuleChainPosition { + module_id: mid, + bus_name: track.bus_name.clone(), + chain_position: i, + }); + } + } + return true; // dirty + } + } + } + false + } + + pub fn handle_load_plugin( + &mut self, + track_idx: usize, + plugin_path: std::path::PathBuf, + tracks: &[Track], + engine: Option<&EngineHandle>, + ) { + if let Some(track) = tracks.get(track_idx) { + let chain_pos = track.module_chain.len(); + if let Some(engine) = engine { + engine.send(EngineCommand::LoadDynamicPlugin { + bus_name: track.bus_name.clone(), + plugin_path, + chain_position: chain_pos, + }); + } + } + self.module_picker_track = None; + } + + pub fn handle_add_send( + &mut self, + track_index: usize, + aux_bus_name: String, + tracks: &mut [Track], + engine: Option<&EngineHandle>, + ) -> bool { + if let Some(track) = tracks.get_mut(track_index) { + if !track.sends.iter().any(|s| s.aux_bus_name == aux_bus_name) { + track.sends.push(crate::track::Send { + aux_bus_name: aux_bus_name.clone(), + level: 0.5, + enabled: true, + }); + if let Some(engine) = engine { + engine.send(EngineCommand::SetSend { + source_bus: track.bus_name.clone(), + aux_bus: aux_bus_name, + level: 0.5, + }); + } + self.send_picker_track = None; + return true; // dirty + } + } + self.send_picker_track = None; + false + } + + pub fn handle_remove_send( + &mut self, + track_index: usize, + send_index: usize, + tracks: &mut [Track], + engine: Option<&EngineHandle>, + ) -> bool { + if let Some(track) = tracks.get_mut(track_index) { + if send_index < track.sends.len() { + let send = track.sends.remove(send_index); + if let Some(engine) = engine { + engine.send(EngineCommand::RemoveSend { + source_bus: track.bus_name.clone(), + aux_bus: send.aux_bus_name, + }); + } + return true; // dirty + } + } + false + } + + pub fn handle_set_send_level( + &mut self, + track_index: usize, + send_index: usize, + level: f32, + tracks: &mut [Track], + engine: Option<&EngineHandle>, + ) -> bool { + if let Some(track) = tracks.get_mut(track_index) { + if let Some(send) = track.sends.get_mut(send_index) { + send.level = level; + if let Some(engine) = engine { + engine.send(EngineCommand::SetSend { + source_bus: track.bus_name.clone(), + aux_bus: send.aux_bus_name.clone(), + level, + }); + } + return true; // dirty + } + } + false + } + + /// Handle a ModuleLoaded engine event: register the module name and chain position + pub fn handle_module_loaded( + &mut self, + bus_name: &str, + module_id: u32, + module_type: String, + plugin_name: Option, + tracks: &mut [Track], + ) -> bool { + let display = if let Some(ref pn) = plugin_name { + format!("{} ({})", module_type, pn) + } else { + module_type + }; + self.module_names.insert(module_id, display); + for track in tracks.iter_mut() { + if track.bus_name == bus_name { + if !track.module_chain.contains(&module_id) { + track.module_chain.push(module_id); + return true; // dirty + } + break; + } + } + false + } +} diff --git a/au-o2-gui/src/timing.rs b/au-o2-gui/src/timing.rs new file mode 100644 index 0000000..d31c05f --- /dev/null +++ b/au-o2-gui/src/timing.rs @@ -0,0 +1,224 @@ +use serde::{Deserialize, Serialize}; +use std::ops::{Add, Sub}; + +pub const TICKS_PER_BEAT: u32 = 960; +pub const DEFAULT_BEATS_PER_BAR: u32 = 4; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct TempoPoint { + pub sample_pos: u64, + pub tempo: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TempoMap { + pub default_tempo: f32, + pub points: Vec, +} + +impl Default for TempoMap { + fn default() -> Self { + Self { default_tempo: 120.0, points: Vec::new() } + } +} + +impl TempoMap { + pub fn new(default_tempo: f32) -> Self { + Self { default_tempo, points: Vec::new() } + } + + pub fn tempo_at(&self, sample_pos: u64) -> f32 { + if self.points.is_empty() { + return self.default_tempo; + } + let idx = self.points.partition_point(|p| p.sample_pos <= sample_pos); + if idx == 0 { + self.default_tempo + } else { + self.points[idx - 1].tempo + } + } + + pub fn beat_pos_at(&self, sample_pos: u64, sample_rate: u32) -> f64 { + if self.points.is_empty() { + let bps = self.default_tempo as f64 / 60.0; + return sample_pos as f64 * bps / sample_rate as f64; + } + + let sr = sample_rate as f64; + let mut beats = 0.0f64; + let mut cursor = 0u64; + let mut tempo = self.default_tempo as f64; + + for pt in &self.points { + if pt.sample_pos >= sample_pos { + break; + } + if pt.sample_pos > cursor { + let span = (pt.sample_pos - cursor) as f64; + beats += span * (tempo / 60.0) / sr; + cursor = pt.sample_pos; + } + tempo = pt.tempo as f64; + } + + if sample_pos > cursor { + let span = (sample_pos - cursor) as f64; + beats += span * (tempo / 60.0) / sr; + } + + beats + } + + pub fn sample_at_beat(&self, target_beat: f64, sample_rate: u32) -> u64 { + if self.points.is_empty() { + let bps = self.default_tempo as f64 / 60.0; + return (target_beat / bps * sample_rate as f64) as u64; + } + + let sr = sample_rate as f64; + let mut beats = 0.0f64; + let mut cursor = 0u64; + let mut tempo = self.default_tempo as f64; + + for pt in &self.points { + let bps = tempo / 60.0; + let span = (pt.sample_pos - cursor) as f64; + let segment_beats = span * bps / sr; + + if beats + segment_beats >= target_beat { + let remaining = target_beat - beats; + return cursor + (remaining / bps * sr) as u64; + } + + beats += segment_beats; + cursor = pt.sample_pos; + tempo = pt.tempo as f64; + } + + let bps = tempo / 60.0; + let remaining = target_beat - beats; + cursor + (remaining / bps * sr) as u64 + } + + pub fn insert_point(&mut self, sample_pos: u64, tempo: f32) { + let idx = self.points.partition_point(|p| p.sample_pos < sample_pos); + if idx < self.points.len() && self.points[idx].sample_pos == sample_pos { + self.points[idx].tempo = tempo; + } else { + self.points.insert(idx, TempoPoint { sample_pos, tempo }); + } + } + + pub fn remove_point(&mut self, index: usize) { + if index < self.points.len() { + self.points.remove(index); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +pub struct MusicalTime { + pub bar: u32, + pub beat: u32, + pub tick: u32, +} + +impl MusicalTime { + pub fn new(bar: u32, beat: u32, tick: u32) -> Self { + Self { bar, beat, tick } + } + + pub fn from_samples(sample_pos: u64, tempo: f32, sample_rate: u32, beats_per_bar: u32) -> Self { + let beats_per_second = tempo as f64 / 60.0; + let samples_per_beat = sample_rate as f64 / beats_per_second; + let total_beats = sample_pos as f64 / samples_per_beat; + Self::from_total_beats(total_beats, beats_per_bar) + } + + pub fn from_samples_mapped(sample_pos: u64, tempo_map: &TempoMap, sample_rate: u32, beats_per_bar: u32) -> Self { + let total_beats = tempo_map.beat_pos_at(sample_pos, sample_rate); + Self::from_total_beats(total_beats, beats_per_bar) + } + + fn from_total_beats(total_beats: f64, beats_per_bar: u32) -> Self { + let total_ticks = (total_beats * TICKS_PER_BEAT as f64) as u64; + let ticks_per_bar = beats_per_bar as u64 * TICKS_PER_BEAT as u64; + let bar = (total_ticks / ticks_per_bar) as u32 + 1; + let remaining = total_ticks % ticks_per_bar; + let beat = (remaining / TICKS_PER_BEAT as u64) as u32 + 1; + let tick = (remaining % TICKS_PER_BEAT as u64) as u32; + Self { bar, beat, tick } + } + + pub fn to_samples(&self, tempo: f32, sample_rate: u32, beats_per_bar: u32) -> u64 { + let beats_per_second = tempo as f64 / 60.0; + let samples_per_beat = sample_rate as f64 / beats_per_second; + let total_beats = self.to_total_beats(beats_per_bar); + (total_beats * samples_per_beat) as u64 + } + + pub fn to_samples_mapped(&self, tempo_map: &TempoMap, sample_rate: u32, beats_per_bar: u32) -> u64 { + if tempo_map.points.is_empty() { + return self.to_samples(tempo_map.default_tempo, sample_rate, beats_per_bar); + } + let total_beats = self.to_total_beats(beats_per_bar); + tempo_map.sample_at_beat(total_beats, sample_rate) + } + + pub fn to_total_beats(&self, beats_per_bar: u32) -> f64 { + let ticks_per_bar = beats_per_bar as u64 * TICKS_PER_BEAT as u64; + let total_ticks = (self.bar.saturating_sub(1)) as u64 * ticks_per_bar + + (self.beat.saturating_sub(1)) as u64 * TICKS_PER_BEAT as u64 + + self.tick as u64; + total_ticks as f64 / TICKS_PER_BEAT as f64 + } + + pub fn sub_with_beats_per_bar(self, rhs: Self, beats_per_bar: u32) -> Self { + let bpb = beats_per_bar as i64; + let lhs_ticks = (self.bar.saturating_sub(1)) as i64 * bpb * TICKS_PER_BEAT as i64 + + (self.beat.saturating_sub(1)) as i64 * TICKS_PER_BEAT as i64 + + self.tick as i64; + let rhs_ticks = (rhs.bar.saturating_sub(1)) as i64 * bpb * TICKS_PER_BEAT as i64 + + (rhs.beat.saturating_sub(1)) as i64 * TICKS_PER_BEAT as i64 + + rhs.tick as i64; + let diff = (lhs_ticks - rhs_ticks).max(0) as u64; + let ticks_per_bar = beats_per_bar as u64 * TICKS_PER_BEAT as u64; + let bars = (diff / ticks_per_bar) as u32; + let remaining = diff % ticks_per_bar; + let beats = (remaining / TICKS_PER_BEAT as u64) as u32; + let ticks = (remaining % TICKS_PER_BEAT as u64) as u32; + Self { bar: bars, beat: beats, tick: ticks } + } +} + +impl Add for MusicalTime { + type Output = Self; + fn add(self, rhs: Self) -> Self::Output { + Self { + bar: self.bar + rhs.bar, + beat: self.beat + rhs.beat, + tick: self.tick + rhs.tick, + } + } +} + +impl Sub for MusicalTime { + type Output = Self; + fn sub(self, rhs: Self) -> Self::Output { + self.sub_with_beats_per_bar(rhs, DEFAULT_BEATS_PER_BAR) + } +} + +impl std::fmt::Display for MusicalTime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}.{:03}", self.bar, self.beat, self.tick) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Marker { + pub id: u32, + pub name: String, + pub position: MusicalTime, +} diff --git a/au-o2-gui/src/track.rs b/au-o2-gui/src/track.rs new file mode 100644 index 0000000..9f9a115 --- /dev/null +++ b/au-o2-gui/src/track.rs @@ -0,0 +1,217 @@ +use crate::automation::{AutomationLane, AutomationMode}; +use crate::region::{Region, TakeFolder}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub const TRACK_HEIGHT: f32 = 160.0; + +pub const TRACK_COLORS: [(u8, u8, u8); 12] = [ + (0xC8, 0xA0, 0x04), // #C8A004 dark gold + (0x52, 0xA1, 0x83), // #52A183 teal + (0x1C, 0x24, 0xC4), // #1C24C4 vivid blue + (0x9C, 0x3C, 0x24), // #9C3C24 rust + (0x14, 0xA6, 0xB9), // #14A6B9 cyan + (0xB2, 0x65, 0x14), // #B26514 burnt orange + (0x4C, 0x3C, 0x68), // #4C3C68 purple + (0x21, 0x72, 0xBF), // #2172BF medium blue + (0xD1, 0xBD, 0x06), // #D1BD06 bright gold + (0xA1, 0x52, 0x70), // #A15270 complementary rose + (0x83, 0x52, 0xA1), // #8352A1 triad purple + (0x0C, 0x4C, 0x1C), // #0C4C1C forest green +]; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct TrackColor { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl TrackColor { + pub fn from_index(index: usize) -> Self { + let (r, g, b) = TRACK_COLORS[index % TRACK_COLORS.len()]; + Self { r, g, b } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TrackType { + Audio, + Midi, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MonitorMode { + Auto, + Input, + Off, +} + +impl Default for MonitorMode { + fn default() -> Self { Self::Auto } +} + +impl std::fmt::Display for MonitorMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MonitorMode::Auto => write!(f, "Auto"), + MonitorMode::Input => write!(f, "Input"), + MonitorMode::Off => write!(f, "Off"), + } + } +} + +impl MonitorMode { + pub const ALL: [MonitorMode; 3] = [MonitorMode::Auto, MonitorMode::Input, MonitorMode::Off]; +} + +impl TrackType { + pub const ALL: [TrackType; 2] = [TrackType::Audio, TrackType::Midi]; +} + +impl std::fmt::Display for TrackType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + TrackType::Audio => "Audio", + TrackType::Midi => "MIDI", + } + ) + } +} + +#[derive(Debug, Clone)] +pub struct TrackConfig { + pub name: String, + pub track_type: TrackType, +} + +impl Default for TrackConfig { + fn default() -> Self { + Self { + name: "New Track".to_string(), + track_type: TrackType::Audio, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Track { + pub id: Uuid, + pub name: String, + pub track_type: TrackType, + pub color: TrackColor, + pub muted: bool, + pub soloed: bool, + pub record_armed: bool, + pub volume: f32, + pub pan: f32, + pub regions: Vec, + pub selected: bool, + pub bus_name: String, + pub module_chain: Vec, + #[serde(default)] + pub automation_lanes: Vec, + #[serde(default)] + pub automation_mode: AutomationMode, + #[serde(default)] + pub show_automation: bool, + #[serde(default)] + pub sends: Vec, + #[serde(default)] + pub take_folders: Vec, + #[serde(default)] + pub group_id: Option, + #[serde(default)] + pub monitor_mode: MonitorMode, + #[serde(default)] + pub frozen: bool, + #[serde(default)] + pub frozen_file: Option, + #[serde(default)] + pub spatial_x: f32, + #[serde(default)] + pub spatial_y: f32, + #[serde(default)] + pub spatial_z: f32, + #[serde(default)] + pub object_size: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Send { + pub aux_bus_name: String, + pub level: f32, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackGroup { + pub id: Uuid, + pub name: String, + pub color: TrackColor, + pub volume: f32, + pub muted: bool, + pub soloed: bool, +} + +impl TrackGroup { + pub fn new(name: String, color_index: usize) -> Self { + Self { + id: Uuid::new_v4(), + name, + color: TrackColor::from_index(color_index), + volume: 1.0, + muted: false, + soloed: false, + } + } +} + +impl Track { + pub fn new(config: TrackConfig, color_index: usize) -> Self { + let id = Uuid::new_v4(); + let bus_name = format!("track_{}", id.as_simple()); + Self { + id, + name: config.name, + track_type: config.track_type, + color: TrackColor::from_index(color_index), + muted: false, + soloed: false, + record_armed: false, + volume: 0.75, + pan: 0.0, + regions: Vec::new(), + selected: false, + bus_name, + module_chain: Vec::new(), + automation_lanes: Vec::new(), + automation_mode: AutomationMode::Off, + show_automation: false, + sends: Vec::new(), + take_folders: Vec::new(), + group_id: None, + monitor_mode: MonitorMode::default(), + frozen: false, + frozen_file: None, + spatial_x: 0.0, + spatial_y: 0.0, + spatial_z: 0.0, + object_size: 0.0, + } + } + + pub fn visible_regions(&self) -> Vec<&Region> { + let hidden: std::collections::HashSet = self.take_folders.iter() + .flat_map(|f| { + f.take_ids.iter().enumerate() + .filter(|&(i, _)| i != f.active_index) + .map(|(_, id)| *id) + }) + .collect(); + self.regions.iter().filter(|r| !hidden.contains(&r.id)).collect() + } +} diff --git a/au-o2-gui/src/triggers.rs b/au-o2-gui/src/triggers.rs new file mode 100644 index 0000000..0b349f0 --- /dev/null +++ b/au-o2-gui/src/triggers.rs @@ -0,0 +1,69 @@ +use crate::behaviors::Action; +use crate::entry::AppState; +use iced::keyboard::{key, Key, Modifiers}; + +pub fn map_key_press_to_action( + app_state: &AppState, + key: Key, + modifiers: Modifiers, +) -> Option { + let cmd = modifiers.command(); + + match app_state { + AppState::TimeUtility { .. } => match key { + Key::Named(key::Named::Space) => Some(Action::TimeUtilityTapPressed), + _ => None, + }, + + AppState::Editor(_) => { + if cmd { + return match key { + Key::Character(ref c) => match c.as_str() { + "x" => Some(Action::Cut), + "c" => Some(Action::Copy), + "v" => Some(Action::Paste), + _ => None, + }, + _ => None, + }; + } + + // Unmodified keys + match key { + Key::Named(key::Named::Space) => Some(Action::EditorTogglePlayback), + Key::Named(key::Named::Enter) => Some(Action::EditorRewind), + Key::Named(key::Named::Delete) | Key::Named(key::Named::Backspace) => { + Some(Action::Delete) + } + Key::Character(ref c) => match c.as_str() { + "r" => Some(Action::EditorToggleRecord), + "i" => Some(Action::EditorToggleInspector), + "e" => Some(Action::EditorToggleBottomPanel), + "x" => Some(Action::EditorToggleMixer), + "c" => Some(Action::EditorToggleCycle), + "k" => Some(Action::EditorToggleMetronome), + "q" => Some(Action::Quantize), + "," => Some(Action::EditorRewind), + _ => None, + }, + _ => None, + } + } + + _ => None, + } +} + +pub fn map_key_release_to_action( + app_state: &AppState, + key: Key, + _modifiers: Modifiers, +) -> Option { + match app_state { + AppState::TimeUtility { .. } => match key { + Key::Named(key::Named::Space) => Some(Action::TimeUtilityTapReleased), + _ => None, + }, + _ => None, + } +} diff --git a/au-o2-gui/src/waveform.rs b/au-o2-gui/src/waveform.rs new file mode 100644 index 0000000..6b7bcf4 --- /dev/null +++ b/au-o2-gui/src/waveform.rs @@ -0,0 +1,126 @@ +use uuid::Uuid; +use std::collections::HashMap; + +const BASE_SAMPLES_PER_PEAK: usize = 64; +const MIP_LEVELS: usize = 8; + +#[derive(Debug, Clone)] +struct MipLevel { + peaks: Vec<(f32, f32)>, + samples_per_peak: usize, +} + +#[derive(Debug, Clone)] +pub struct WaveformPeaks { + mips: Vec, + pub total_samples: usize, +} + +impl WaveformPeaks { + pub fn from_stereo(left: &[f32], right: &[f32]) -> Self { + let n = left.len().min(right.len()); + let base_peaks = compute_merged_peaks(left, right, n, BASE_SAMPLES_PER_PEAK); + let mut mips = Vec::with_capacity(MIP_LEVELS); + mips.push(MipLevel { + peaks: base_peaks, + samples_per_peak: BASE_SAMPLES_PER_PEAK, + }); + + for level in 1..MIP_LEVELS { + let prev = &mips[level - 1]; + let coarsened = coarsen_peaks(&prev.peaks); + let spp = prev.samples_per_peak * 2; + mips.push(MipLevel { peaks: coarsened, samples_per_peak: spp }); + } + + Self { mips, total_samples: n } + } + + pub fn peaks_for_pixel_range( + &self, + start_sample: usize, + end_sample: usize, + num_pixels: usize, + ) -> Vec<(f32, f32)> { + if num_pixels == 0 || end_sample <= start_sample { + return Vec::new(); + } + let samples_per_pixel = (end_sample - start_sample) as f64 / num_pixels as f64; + let mip = self.select_mip(samples_per_pixel); + + let mut result = Vec::with_capacity(num_pixels); + let spp = mip.samples_per_peak as f64; + + for px in 0..num_pixels { + let s0 = start_sample as f64 + px as f64 * samples_per_pixel; + let s1 = start_sample as f64 + (px + 1) as f64 * samples_per_pixel; + let p0 = (s0 / spp) as usize; + let p1 = ((s1 / spp).ceil() as usize).min(mip.peaks.len()); + + let mut mn = 0.0f32; + let mut mx = 0.0f32; + let mut found = false; + + for i in p0..p1 { + let (pmin, pmax) = mip.peaks[i]; + if !found { + mn = pmin; + mx = pmax; + found = true; + } else { + mn = mn.min(pmin); + mx = mx.max(pmax); + } + } + result.push((mn, mx)); + } + result + } + + fn select_mip(&self, samples_per_pixel: f64) -> &MipLevel { + for mip in &self.mips { + if (mip.samples_per_peak as f64) >= samples_per_pixel * 0.5 { + return mip; + } + } + self.mips.last().unwrap() + } +} + +fn compute_merged_peaks(left: &[f32], right: &[f32], n: usize, chunk_size: usize) -> Vec<(f32, f32)> { + let count = (n + chunk_size - 1) / chunk_size; + let mut peaks = Vec::with_capacity(count); + for start in (0..n).step_by(chunk_size) { + let end = (start + chunk_size).min(n); + let mut mn = 0.0f32; + let mut mx = 0.0f32; + let mut first = true; + for i in start..end { + let v = (left[i] + right[i]) * 0.5; + if first { + mn = v; + mx = v; + first = false; + } else { + if v < mn { mn = v; } + if v > mx { mx = v; } + } + } + peaks.push((mn, mx)); + } + peaks +} + +fn coarsen_peaks(fine: &[(f32, f32)]) -> Vec<(f32, f32)> { + fine.chunks(2).map(|pair| { + let (mn0, mx0) = pair[0]; + if pair.len() == 2 { + let (mn1, mx1) = pair[1]; + (mn0.min(mn1), mx0.max(mx1)) + } else { + (mn0, mx0) + } + }).collect() +} + +pub type WaveformCache = HashMap; diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..f5341d2 --- /dev/null +++ b/build.sh @@ -0,0 +1,67 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# --- Icons --- +./icons.sh + +# --- 1. Build oxforge (MDK + CLI) --- +echo "=== Building oxforge ===" +cargo build -p oxforge --release + +# --- 2. Build modules via oxforge --- +echo "=== Building modules ===" +# System modules (lib crates) are built as au-o2-gui dependencies. +# User modules (cdylib) need explicit build + packaging. +cargo build --release \ + -p passthrough \ + -p latency_checker + +for module_dir in oxide-modules/passthrough oxide-modules/latency; do + if [ -f "$module_dir/module.toml" ]; then + echo " Packaging $module_dir" + ./target/release/oxforge build --skip-build "$module_dir" + fi +done + +# --- 3. Build au-o2-gui --- +echo "=== Building au-o2-gui ===" +cargo build -p au-o2-gui --release + +# --- 4. Platform packaging --- +case "$OSTYPE" in + darwin*) + APP_NAME="Audio Oxide" + APP_DIR="target/release/$APP_NAME.app" + rm -rf "$APP_DIR" + mkdir -p "$APP_DIR/Contents/MacOS" + mkdir -p "$APP_DIR/Contents/Resources" + + cp target/release/au-o2-gui "$APP_DIR/Contents/MacOS/" + cp au-o2-gui/assets/Info.plist "$APP_DIR/Contents/" + + if [ -f au-o2-gui/assets/icon.icns ]; then + cp au-o2-gui/assets/icon.icns "$APP_DIR/Contents/Resources/" + fi + + # Bundle packaged user modules + MODULES_DIR="$APP_DIR/Contents/Resources/modules" + mkdir -p "$MODULES_DIR" + for xfile in target/release/modules/*.x; do + [ -f "$xfile" ] && cp "$xfile" "$MODULES_DIR/" + done + + echo "=== Built: $APP_DIR ===" + open "$APP_DIR" + ;; + msys*|cygwin*|win*) + echo "=== Built: target/release/au-o2-gui.exe ===" + ./target/release/au-o2-gui.exe + ;; + *) + echo "=== Built: target/release/au-o2-gui ===" + ./target/release/au-o2-gui + ;; +esac diff --git a/debug.sh b/debug.sh new file mode 100755 index 0000000..215cff8 --- /dev/null +++ b/debug.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +pkill -f "Audio Oxide.app" 2>/dev/null || true +pkill -f "au-o2-gui" 2>/dev/null || true + +cargo build -p au-o2-gui --features debug-log + +APP="target/debug/au-o2-gui" +LOG="$HOME/audio-oxide/debug.log" + +if [[ "$OSTYPE" == "darwin"* ]]; then + "$APP" & +else + "$APP" & +fi + +echo "tailing $LOG" +tail -f "$LOG" diff --git a/icons.sh b/icons.sh new file mode 100755 index 0000000..a7ccd47 --- /dev/null +++ b/icons.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SVG="$SCRIPT_DIR/au-o2-gui/assets/icon.svg" +ASSETS="$SCRIPT_DIR/au-o2-gui/assets" + +if [ ! -f "$SVG" ]; then + echo "No icon.svg found at $SVG — skipping icon generation." + exit 0 +fi + +if ! command -v rsvg-convert &>/dev/null; then + echo "rsvg-convert not found. Install with: brew install librsvg" + exit 1 +fi + +case "$OSTYPE" in + darwin*) + ICONSET="$ASSETS/icon.iconset" + rm -rf "$ICONSET" + mkdir -p "$ICONSET" + + rsvg-convert -w 16 -h 16 "$SVG" -o "$ICONSET/icon_16x16.png" + rsvg-convert -w 32 -h 32 "$SVG" -o "$ICONSET/icon_16x16@2x.png" + cp "$ICONSET/icon_16x16@2x.png" "$ICONSET/icon_32x32.png" + rsvg-convert -w 64 -h 64 "$SVG" -o "$ICONSET/icon_32x32@2x.png" + rsvg-convert -w 128 -h 128 "$SVG" -o "$ICONSET/icon_128x128.png" + rsvg-convert -w 256 -h 256 "$SVG" -o "$ICONSET/icon_128x128@2x.png" + cp "$ICONSET/icon_128x128@2x.png" "$ICONSET/icon_256x256.png" + rsvg-convert -w 512 -h 512 "$SVG" -o "$ICONSET/icon_256x256@2x.png" + cp "$ICONSET/icon_256x256@2x.png" "$ICONSET/icon_512x512.png" + rsvg-convert -w 1024 -h 1024 "$SVG" -o "$ICONSET/icon_512x512@2x.png" + + iconutil -c icns "$ICONSET" -o "$ASSETS/icon.icns" + rm -rf "$ICONSET" + echo "Generated icon.icns" + ;; + msys*|cygwin*|win*) + ICO_DIR="$ASSETS/icon.iconset" + rm -rf "$ICO_DIR" + mkdir -p "$ICO_DIR" + for size in 16 32 48 64 128 256; do + rsvg-convert -w "$size" -h "$size" "$SVG" -o "$ICO_DIR/${size}.png" + done + if command -v magick &>/dev/null; then + magick "$ICO_DIR/16.png" "$ICO_DIR/32.png" "$ICO_DIR/48.png" \ + "$ICO_DIR/64.png" "$ICO_DIR/128.png" "$ICO_DIR/256.png" \ + "$ASSETS/icon.ico" + rm -rf "$ICO_DIR" + echo "Generated icon.ico" + else + echo "PNGs rendered to $ICO_DIR — install ImageMagick to assemble .ico" + fi + ;; + *) + echo "No icon conversion needed for $OSTYPE" + ;; +esac diff --git a/oxforge/.gitignore b/oxforge/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/oxforge/.gitignore @@ -0,0 +1 @@ +/target diff --git a/oxforge/Cargo.toml b/oxforge/Cargo.toml new file mode 100644 index 0000000..ca16149 --- /dev/null +++ b/oxforge/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "oxforge" +version = "0.1.0" +edition = "2024" + +# This section defines the library component (the MDK) +[lib] +name = "oxforge" +path = "src/lib.rs" + +# This section defines the binary component (the command-line tool) +[[bin]] +name = "oxforge" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.5.48", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +toml = "0.9.7" +uuid = { version = "1.18.1", features = ["v4", "serde"] } +zip = "5.1.1" diff --git a/oxforge/src/lib.rs b/oxforge/src/lib.rs new file mode 100644 index 0000000..0dedeb3 --- /dev/null +++ b/oxforge/src/lib.rs @@ -0,0 +1,2 @@ +// can use it with `use oxforge::mdk;` +pub mod mdk; \ No newline at end of file diff --git a/oxforge/src/main.rs b/oxforge/src/main.rs new file mode 100644 index 0000000..87a5583 --- /dev/null +++ b/oxforge/src/main.rs @@ -0,0 +1,160 @@ +use clap::Parser; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use zip::write::{FileOptions, ZipWriter}; + +#[derive(Parser, Debug)] +#[command(version, about = "The Audio Oxide Module Forge")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(clap::Subcommand, Debug)] +enum Commands { + /// Builds a module and packages it into a .x file. + Build(BuildArgs), +} + +#[derive(clap::Args, Debug)] +struct BuildArgs { + /// Path to the module's root directory. + path: Option, + + /// Build in release mode + #[arg(long, default_value_t = true)] + release: bool, + + /// Skip cargo build (use when already built, e.g. via workspace) + #[arg(long)] + skip_build: bool, +} + +fn main() { + let cli = Cli::parse(); + + match &cli.command { + Commands::Build(args) => { + let module_path = match &args.path { + Some(p) => Path::new(p).to_path_buf(), + None => std::env::current_dir().expect("Could not get current directory"), + }; + + if let Err(e) = build_module(module_path, args.skip_build) { + eprintln!("Error building module: {}", e); + std::process::exit(1); + } + } + } +} + +fn build_module(module_path: PathBuf, skip_build: bool) -> Result<(), Box> { + println!("Building module in: {}", module_path.display()); + + let cargo_toml_path = module_path.join("Cargo.toml"); + let module_toml_path = module_path.join("module.toml"); + if !cargo_toml_path.exists() || !module_toml_path.exists() { + return Err(format!( + "'{}' is not a valid module. Missing Cargo.toml or module.toml.", + module_path.display() + ).into()); + } + + if !skip_build { + println!("Running `cargo build --release`..."); + let status = Command::new("cargo") + .arg("build") + .arg("--release") + .current_dir(&module_path) + .status()?; + + if !status.success() { + return Err("`cargo build` failed.".into()); + } + } + + let module_name = get_package_name(&cargo_toml_path)?; + let lib_name = get_dynamic_lib_name(&module_name); + let target_dir = find_target_dir(&module_path)?; + let lib_path = target_dir.join(&lib_name); + + if !lib_path.exists() { + return Err(format!("Could not find compiled library at {}", lib_path.display()).into()); + } + println!("Found compiled library: {}", lib_path.display()); + + let output_dir = target_dir.join("modules"); + fs::create_dir_all(&output_dir)?; + let output_path = output_dir.join(format!("{}.x", module_name)); + let file = File::create(&output_path)?; + let mut zip = ZipWriter::new(file); + let options: FileOptions<()> = + FileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + println!("Packaging into {}", output_path.display()); + + zip.start_file("module.toml", options)?; + let mut module_toml_content = Vec::new(); + File::open(&module_toml_path)?.read_to_end(&mut module_toml_content)?; + zip.write_all(&module_toml_content)?; + + zip.start_file(&lib_name, options)?; + let mut lib_content = Vec::new(); + File::open(&lib_path)?.read_to_end(&mut lib_content)?; + zip.write_all(&lib_content)?; + + zip.finish()?; + + println!("Module packaged at: {}", output_path.display()); + Ok(()) +} + +fn find_target_dir(module_path: &Path) -> Result> { + // Check workspace first + let output = Command::new("cargo") + .args(["locate-project", "--workspace", "--message-format=plain"]) + .current_dir(module_path) + .output()?; + + if output.status.success() { + let workspace_manifest = String::from_utf8(output.stdout)?.trim().to_string(); + if let Some(ws_root) = Path::new(&workspace_manifest).parent() { + let ws_target = ws_root.join("target/release"); + if ws_target.exists() { + return Ok(ws_target); + } + } + } + + // Fallback: local target (standalone crate) + let local = module_path.join("target/release"); + if local.exists() { + return Ok(local); + } + + Err(format!( + "Could not find target directory for {}", + module_path.display() + ).into()) +} + +fn get_package_name(cargo_toml_path: &Path) -> Result> { + let content = fs::read_to_string(cargo_toml_path)?; + let config: toml::Value = toml::from_str(&content)?; + let name = config["package"]["name"] + .as_str() + .ok_or("Missing package name in Cargo.toml")?; + Ok(name.to_string()) +} + +fn get_dynamic_lib_name(module_name: &str) -> String { + let sanitized = module_name.replace('-', "_"); + #[cfg(target_os = "windows")] + return format!("{}.dll", sanitized); + #[cfg(target_os = "macos")] + return format!("lib{}.dylib", sanitized); + #[cfg(target_os = "linux")] + return format!("lib{}.so", sanitized); +} diff --git a/oxforge/src/mdk/mod.rs b/oxforge/src/mdk/mod.rs new file mode 100644 index 0000000..308e103 --- /dev/null +++ b/oxforge/src/mdk/mod.rs @@ -0,0 +1,404 @@ +pub mod types; +pub mod recording; + +pub use types::*; +pub use recording::*; + +use serde::{Deserialize, Serialize}; +use std::any::Any; +use std::collections::HashMap; +use uuid::Uuid; + +pub use serde; +pub use toml; +pub use uuid; + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct ModuleConfig { + pub unique_name: String, + pub description: String, + #[serde(default)] + pub behavior: BehaviorConfig, + #[serde(default)] + pub ports: Vec, + #[serde(default)] + pub buses: Vec, + #[serde(default)] + pub parameters: HashMap, + #[serde(default)] + pub gui: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct BehaviorConfig { + pub chaining_mode: ChainingMode, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +pub enum ChainingMode { + #[default] + None, + Series, + Parallel, + SeriesParallel, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PortTemplate { + pub name: String, + pub role: PortRole, + pub data_type: DataType, + #[serde(default)] + pub port_type: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub enum PortRole { Main, Chain, Aux } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub enum DataType { Audio, Midi, Custom } + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct BusTemplate { + pub name: String, + pub access: BusAccess, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub enum BusAccess { Read, Write } + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ParameterDefinition { + pub label: String, + pub r#type: String, + pub default: toml::Value, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GuiElement { + pub r#type: String, + pub controls: String, +} + +#[derive(Debug, Clone, Copy)] +pub struct MidiEvent { + pub timing: u32, + pub channel: u8, + pub message: MidiMessage, +} +#[derive(Debug, Clone, Copy)] +pub enum MidiMessage { + NoteOn { key: u8, velocity: u8 }, + NoteOff { key: u8, velocity: u8 }, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TransportState { + Playing, + #[default] + Stopped, +} +#[derive(Debug, Clone, Copy, Default)] +pub struct MusicalTime { + pub sample_pos: u64, + pub beat_pos: f64, + pub tempo: f64, + pub time_signature_numerator: u8, + pub time_signature_denominator: u8, + pub state: TransportState, +} + +#[derive(Debug, Clone)] +pub enum ToGuiMessage { + Log(String), + UpdateParameterDisplay { param_key: String, value_str: String }, + VisualizationData { data: Vec }, +} + +type GuiCallback = std::sync::Arc; + +pub struct ToGuiQueue { + callback: Option, +} + +impl std::fmt::Debug for ToGuiQueue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ToGuiQueue") + .field("has_callback", &self.callback.is_some()) + .finish() + } +} + +impl Default for ToGuiQueue { + fn default() -> Self { + Self::noop() + } +} + +impl Clone for ToGuiQueue { + fn clone(&self) -> Self { + Self { + callback: self.callback.clone(), + } + } +} + +impl ToGuiQueue { + pub fn noop() -> Self { + Self { callback: None } + } + + pub fn with_callback(f: F) -> Self { + Self { + callback: Some(std::sync::Arc::new(f)), + } + } + + pub fn send(&self, message: ToGuiMessage) -> Result<(), &'static str> { + if let Some(ref cb) = self.callback { + cb(message); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Default)] +pub struct GlobalConfig { + pub instance_id: Uuid, + pub sample_rate: f32, + pub buffer_size: u32, +} + +pub struct MainAudioInput<'a> { pub buffer: &'a [f32] } +impl<'a> MainAudioInput<'a> { + pub fn iter(&self) -> impl Iterator { self.buffer.iter() } + pub fn buffer(&self) -> &'a [f32] { self.buffer } +} + +pub struct MainAudioOutput<'a> { pub buffer: &'a mut [f32] } +impl<'a> MainAudioOutput<'a> { + pub fn iter_mut(&mut self) -> impl Iterator { self.buffer.iter_mut() } + pub fn buffer_mut(&mut self) -> &mut [f32] { self.buffer } +} + +pub struct ChainInput<'a> { + pub data: &'a (dyn Any + Send), +} +impl<'a> ChainInput<'a> { + pub fn get(&self) -> Option<&T> { + self.data.downcast_ref::() + } +} + +pub struct ChainOutput<'a> { + pub data: &'a mut Box, +} +impl<'a> ChainOutput<'a> { + pub fn send(&mut self, data: T) { + *self.data = Box::new(data); + } +} + +// --- Lane/Bus view types for module port access --- + +pub struct LaneRef<'a> { + real: &'a [f32], + analytic: &'a [(f32, f32)], +} + +impl<'a> LaneRef<'a> { + pub fn new(real: &'a [f32], analytic: &'a [(f32, f32)]) -> Self { + Self { real, analytic } + } + + pub fn real(&self) -> &[f32] { + self.real + } + + pub fn analytic(&self) -> &[(f32, f32)] { + self.analytic + } +} + +pub struct LaneMut<'a> { + real: &'a mut [f32], +} + +impl<'a> LaneMut<'a> { + pub fn new(real: &'a mut [f32]) -> Self { + Self { real } + } + + pub fn real(&self) -> &[f32] { + self.real + } + + pub fn real_mut(&mut self) -> &mut [f32] { + self.real + } +} + +pub struct BusRef<'a> { + lanes: Vec>, +} + +impl<'a> BusRef<'a> { + pub fn new(lanes: Vec>) -> Self { + Self { lanes } + } + + pub fn lanes(&self) -> &[LaneRef<'a>] { + &self.lanes + } + + pub fn lane(&self, index: usize) -> Option<&LaneRef<'a>> { + self.lanes.get(index) + } + + pub fn channels(&self) -> usize { + self.lanes.len() + } +} + +pub struct BusMut<'a> { + lanes: Vec>, +} + +impl<'a> BusMut<'a> { + pub fn new(lanes: Vec>) -> Self { + Self { lanes } + } + + pub fn lanes_mut(&mut self) -> &mut [LaneMut<'a>] { + &mut self.lanes + } + + pub fn lane_mut(&mut self, index: usize) -> Option<&mut LaneMut<'a>> { + self.lanes.get_mut(index) + } + + pub fn channels(&self) -> usize { + self.lanes.len() + } +} + +// --- Port declaration types --- + +#[derive(Debug, Clone)] +pub enum PortContent { + Bus { channels: usize }, + Lane, + Custom { type_name: String }, + AllBuses, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PortDirection { + Input, + Output, +} + +#[derive(Debug, Clone)] +pub struct PortDeclaration { + pub name: String, + pub direction: PortDirection, + pub content: PortContent, +} + +// --- Module contract --- + +#[derive(Debug, Clone)] +pub struct ModuleContract { + pub realtime: bool, + pub min_buffer_samples: Option, +} + +impl Default for ModuleContract { + fn default() -> Self { + Self { realtime: true, min_buffer_samples: None } + } +} + +// --- Port view (runtime data passed to process) --- + +pub struct PortView<'a> { + buses_in: HashMap>, + lanes_in: HashMap>, + custom: HashMap, +} + +impl<'a> Default for PortView<'a> { + fn default() -> Self { + Self::new() + } +} + +impl<'a> PortView<'a> { + pub fn new() -> Self { + Self { + buses_in: HashMap::new(), + lanes_in: HashMap::new(), + custom: HashMap::new(), + } + } + + pub fn bus_in(&self, name: &str) -> Option<&BusRef<'a>> { + self.buses_in.get(name) + } + + pub fn lane_in(&self, name: &str) -> Option<&LaneRef<'a>> { + self.lanes_in.get(name) + } + + pub fn custom_in(&self, name: &str) -> Option<&T> { + self.custom.get(name).and_then(|d| d.downcast_ref::()) + } + + pub fn add_bus_in(&mut self, name: String, bus: BusRef<'a>) { + self.buses_in.insert(name, bus); + } + + pub fn add_lane_in(&mut self, name: String, lane: LaneRef<'a>) { + self.lanes_in.insert(name, lane); + } + + pub fn iter_buses_in(&self) -> impl Iterator)> { + self.buses_in.iter() + } +} + +// --- Ports struct --- + +pub struct Ports<'a> { + pub main_audio_in: Option>, + pub main_audio_out: Option>, + pub chain_in: Option>, + pub chain_out: Option>, + pub port: PortView<'a>, +} + +impl<'a> Default for Ports<'a> { + fn default() -> Self { + Self { + main_audio_in: None, + main_audio_out: None, + chain_in: None, + chain_out: None, + port: PortView::new(), + } + } +} + +pub struct ProcessContext { + pub time: MusicalTime, + pub params: HashMap, + pub to_gui: ToGuiQueue, +} + +pub trait OxideModule: Send + Sync { + fn new(config: &GlobalConfig) -> Self where Self: Sized; + fn process(&mut self, ports: Ports, context: &ProcessContext); + fn contract(&self) -> ModuleContract { ModuleContract::default() } + fn port_declarations(&self) -> Vec { Vec::new() } + fn receive_data(&mut self, _key: &str, _data: Box) {} +} diff --git a/oxforge/src/mdk/recording.rs b/oxforge/src/mdk/recording.rs new file mode 100644 index 0000000..17a4b80 --- /dev/null +++ b/oxforge/src/mdk/recording.rs @@ -0,0 +1,29 @@ +use std::path::PathBuf; +use uuid::Uuid; + +pub enum RecorderMessage { + Chunk { + bus_name: String, + real_l: Vec, + real_r: Vec, + imag_l: Vec, + imag_r: Vec, + }, + Finish { + project_path: PathBuf, + sample_rate: u32, + bit_depth: u16, + fft_size: u32, + start_sample: u64, + tempo: f32, + time_sig_num: u8, + }, +} + +pub struct PlaybackRegion { + pub bus_name: String, + pub region_id: Uuid, + pub start_sample: u64, + pub audio_l: Vec, + pub audio_r: Vec, +} diff --git a/oxforge/src/mdk/types.rs b/oxforge/src/mdk/types.rs new file mode 100644 index 0000000..9748b37 --- /dev/null +++ b/oxforge/src/mdk/types.rs @@ -0,0 +1,60 @@ +#[derive(Debug, Clone)] +pub struct AnalyticSignal { + pub left: Vec<(f32, f32)>, + pub right: Vec<(f32, f32)>, +} + +#[derive(Debug, Clone)] +pub struct VisualizationFrame { + pub left: Vec, + pub right: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub struct PhasePoint { + pub x: f32, + pub y: f32, + pub amplitude: f32, +} + +impl VisualizationFrame { + pub fn serialize(&self) -> Vec { + let mut buf = Vec::new(); + serialize_points(&self.left, &mut buf); + serialize_points(&self.right, &mut buf); + buf + } + + pub fn deserialize(data: &[u8]) -> Option { + let mut offset = 0; + let left = deserialize_points(data, &mut offset)?; + let right = deserialize_points(data, &mut offset)?; + Some(Self { left, right }) + } +} + +fn serialize_points(points: &[PhasePoint], buf: &mut Vec) { + buf.extend_from_slice(&(points.len() as u32).to_le_bytes()); + for p in points { + buf.extend_from_slice(&p.x.to_le_bytes()); + buf.extend_from_slice(&p.y.to_le_bytes()); + buf.extend_from_slice(&p.amplitude.to_le_bytes()); + } +} + +fn deserialize_points(data: &[u8], offset: &mut usize) -> Option> { + if *offset + 4 > data.len() { return None; } + let count = u32::from_le_bytes(data[*offset..*offset + 4].try_into().ok()?) as usize; + *offset += 4; + let needed = count * 12; + if *offset + needed > data.len() { return None; } + let mut points = Vec::with_capacity(count); + for _ in 0..count { + let x = f32::from_le_bytes(data[*offset..*offset + 4].try_into().ok()?); + let y = f32::from_le_bytes(data[*offset + 4..*offset + 8].try_into().ok()?); + let amplitude = f32::from_le_bytes(data[*offset + 8..*offset + 12].try_into().ok()?); + *offset += 12; + points.push(PhasePoint { x, y, amplitude }); + } + Some(points) +} diff --git a/oxide-modules/hilbert/Cargo.toml b/oxide-modules/hilbert/Cargo.toml new file mode 100644 index 0000000..0be55c3 --- /dev/null +++ b/oxide-modules/hilbert/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "oxide-hilbert" +version = "0.1.0" +edition = "2024" + +[lib] +name = "oxide_hilbert" +path = "src/lib.rs" + +[dependencies] +oxforge = { path = "../../oxforge" } +rustfft = "6" diff --git a/oxide-modules/hilbert/src/lib.rs b/oxide-modules/hilbert/src/lib.rs new file mode 100644 index 0000000..3bbc822 --- /dev/null +++ b/oxide-modules/hilbert/src/lib.rs @@ -0,0 +1,54 @@ +mod processor; + +use std::any::Any; +use oxforge::mdk::{ + GlobalConfig, ModuleContract, OxideModule, Ports, ProcessContext, +}; +use processor::HilbertProcessor; + +pub struct HilbertModule { + processor: HilbertProcessor, + fft_size: usize, +} + +impl OxideModule for HilbertModule { + fn new(_config: &GlobalConfig) -> Self { + let fft_size = 2048; + Self { + processor: HilbertProcessor::new(fft_size), + fft_size, + } + } + + fn process(&mut self, ports: Ports, _context: &ProcessContext) { + let Some(audio_in) = ports.main_audio_in else { return }; + let Some(mut audio_out) = ports.main_audio_out else { return }; + + let inp = audio_in.buffer(); + let out = audio_out.buffer_mut(); + let len = out.len().min(inp.len()); + out[..len].copy_from_slice(&inp[..len]); + + let analytic = self.processor.process_stereo_interleaved(inp); + + if let Some(mut chain_out) = ports.chain_out { + chain_out.send(analytic); + } + } + + fn contract(&self) -> ModuleContract { + ModuleContract { + realtime: true, + min_buffer_samples: Some(self.fft_size), + } + } + + fn receive_data(&mut self, key: &str, data: Box) { + if key == "set_fft_size" { + if let Ok(size) = data.downcast::() { + self.fft_size = *size; + self.processor.set_fft_size(*size); + } + } + } +} diff --git a/oxide-modules/hilbert/src/processor.rs b/oxide-modules/hilbert/src/processor.rs new file mode 100644 index 0000000..9593123 --- /dev/null +++ b/oxide-modules/hilbert/src/processor.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; + +use rustfft::num_complex::Complex; +use rustfft::{Fft, FftPlanner}; + +use oxforge::mdk::AnalyticSignal; + +pub struct HilbertProcessor { + fft_size: usize, + hop_size: usize, + history_l: Vec, + history_r: Vec, + forward: Arc>, + inverse: Arc>, + fft_buf: Vec>, + ifft_buf: Vec>, +} + +impl HilbertProcessor { + pub fn new(fft_size: usize) -> Self { + let mut planner = FftPlanner::new(); + let forward = planner.plan_fft_forward(fft_size); + let inverse = planner.plan_fft_inverse(fft_size); + + Self { + fft_size, + hop_size: 0, + history_l: vec![0.0; fft_size], + history_r: vec![0.0; fft_size], + forward, + inverse, + fft_buf: vec![Complex::new(0.0, 0.0); fft_size], + ifft_buf: vec![Complex::new(0.0, 0.0); fft_size], + } + } + + pub fn process_stereo_interleaved(&mut self, input: &[f32]) -> AnalyticSignal { + let frame_count = input.len() / 2; + if frame_count == 0 { + return AnalyticSignal { left: Vec::new(), right: Vec::new() }; + } + + if self.hop_size == 0 { + self.hop_size = frame_count; + } + + let mut left_hop = Vec::with_capacity(frame_count); + let mut right_hop = Vec::with_capacity(frame_count); + for i in 0..frame_count { + left_hop.push(input[i * 2]); + right_hop.push(input[i * 2 + 1]); + } + + let left = self.hilbert_channel(&left_hop, true); + let right = self.hilbert_channel(&right_hop, false); + + AnalyticSignal { left, right } + } + + pub fn set_fft_size(&mut self, new_size: usize) { + if new_size == self.fft_size { + return; + } + let mut planner = FftPlanner::new(); + self.fft_size = new_size; + self.forward = planner.plan_fft_forward(new_size); + self.inverse = planner.plan_fft_inverse(new_size); + self.history_l = vec![0.0; new_size]; + self.history_r = vec![0.0; new_size]; + self.fft_buf = vec![Complex::new(0.0, 0.0); new_size]; + self.ifft_buf = vec![Complex::new(0.0, 0.0); new_size]; + self.hop_size = 0; + } + + fn hilbert_channel(&mut self, hop: &[f32], is_left: bool) -> Vec<(f32, f32)> { + let history = if is_left { + &mut self.history_l + } else { + &mut self.history_r + }; + + let hop_size = hop.len(); + + history.copy_within(hop_size.., 0); + history[self.fft_size - hop_size..].copy_from_slice(hop); + + for (i, &s) in history.iter().enumerate() { + self.fft_buf[i] = Complex::new(s, 0.0); + } + self.forward.process(&mut self.fft_buf); + + let n = self.fft_size; + let nyquist = n / 2; + for i in 1..nyquist { + self.fft_buf[i] *= 2.0; + } + for i in (nyquist + 1)..n { + self.fft_buf[i] = Complex::new(0.0, 0.0); + } + + self.ifft_buf.copy_from_slice(&self.fft_buf); + self.inverse.process(&mut self.ifft_buf); + + let norm = 1.0 / n as f32; + let offset = n - hop_size; + let mut result = Vec::with_capacity(hop_size); + for i in 0..hop_size { + let c = self.ifft_buf[offset + i]; + result.push((c.re * norm, c.im * norm)); + } + result + } +} diff --git a/oxide-modules/input/Cargo.toml b/oxide-modules/input/Cargo.toml new file mode 100644 index 0000000..6cf02be --- /dev/null +++ b/oxide-modules/input/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "input_device" +version = "0.1.0" +edition = "2024" + +[lib] +# Build a C-style dynamic library that the host can load. +crate-type = ["cdylib"] + +[dependencies] +# This module depends on the MDK definitions from oxforge. +oxforge = { path = "../../oxforge" } \ No newline at end of file diff --git a/oxide-modules/input/module.toml b/oxide-modules/input/module.toml new file mode 100644 index 0000000..0af99e0 --- /dev/null +++ b/oxide-modules/input/module.toml @@ -0,0 +1,16 @@ +unique_name = "Input Device" +description = "Provides a gain-controlled audio stream from a hardware input." + +[behavior] +# This module is an independent unit. Instances do not communicate with each other. +chaining_mode = "Parallel" + +# This module requires a main audio port to process audio. +[[ports]] +name = "main" +role = "Main" +data_type = "Audio" + +# Define the parameters the host can control. +[parameters] +trim = { label = "Trim", type = "f32", default = 1.0 } \ No newline at end of file diff --git a/oxide-modules/input/src/lib.rs b/oxide-modules/input/src/lib.rs new file mode 100644 index 0000000..e69de29 diff --git a/oxide-modules/input_router/Cargo.toml b/oxide-modules/input_router/Cargo.toml new file mode 100644 index 0000000..bde28be --- /dev/null +++ b/oxide-modules/input_router/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "oxide-input-router" +version = "0.1.0" +edition = "2024" + +[lib] +name = "oxide_input_router" +path = "src/lib.rs" + +[dependencies] +oxforge = { path = "../../oxforge" } diff --git a/oxide-modules/input_router/src/lib.rs b/oxide-modules/input_router/src/lib.rs new file mode 100644 index 0000000..3c70f13 --- /dev/null +++ b/oxide-modules/input_router/src/lib.rs @@ -0,0 +1,45 @@ +use oxforge::mdk::{ + GlobalConfig, OxideModule, PortContent, PortDeclaration, PortDirection, Ports, ProcessContext, +}; + +pub struct InputRouterModule; + +impl OxideModule for InputRouterModule { + fn new(_config: &GlobalConfig) -> Self { Self } + + fn process(&mut self, ports: Ports, context: &ProcessContext) { + let Some(audio_in) = ports.main_audio_in else { return }; + let Some(mut audio_out) = ports.main_audio_out else { return }; + + let armed = context.params.get("armed").copied().unwrap_or(0.0) > 0.5; + + if armed { + if let Some(hw_bus) = ports.port.bus_in("hw_input") { + let out = audio_out.buffer_mut(); + let lanes = hw_bus.lanes(); + if lanes.len() >= 2 { + let left = lanes[0].real(); + let right = lanes[1].real(); + let frames = out.len() / 2; + for i in 0..frames { + out[i * 2] = if i < left.len() { left[i] } else { 0.0 }; + out[i * 2 + 1] = if i < right.len() { right[i] } else { 0.0 }; + } + } + } + } else { + let inp = audio_in.buffer(); + let out = audio_out.buffer_mut(); + let len = out.len().min(inp.len()); + out[..len].copy_from_slice(&inp[..len]); + } + } + + fn port_declarations(&self) -> Vec { + vec![PortDeclaration { + name: "hw_input".into(), + direction: PortDirection::Input, + content: PortContent::Bus { channels: 2 }, + }] + } +} diff --git a/oxide-modules/latency/.gitignore b/oxide-modules/latency/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/oxide-modules/latency/.gitignore @@ -0,0 +1 @@ +/target diff --git a/oxide-modules/latency/Cargo.toml b/oxide-modules/latency/Cargo.toml new file mode 100644 index 0000000..732b28e --- /dev/null +++ b/oxide-modules/latency/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "latency_checker" +version = "0.1.0" +edition = "2024" + +[lib] +# This is critical. It tells Rust to build a C-style dynamic library +# (.so, .dll, .dylib) that Audio Oxide can load at runtime. +crate-type = ["cdylib"] + +[dependencies] +oxforge = { path = "../../oxforge" } \ No newline at end of file diff --git a/oxide-modules/latency/module.toml b/oxide-modules/latency/module.toml new file mode 100644 index 0000000..6ae9dd8 --- /dev/null +++ b/oxide-modules/latency/module.toml @@ -0,0 +1,28 @@ +unique_name = "Latency Checker" +description = "Measures signal latency between two points in the chain." + +[behavior] +chaining_mode = "SeriesParallel" + +[[ports]] +name = "main" +role = "Main" +data_type = "Audio" + +[[ports]] +name = "latency_bus" +role = "Chain" +data_type = "Custom" +port_type = "Latency" + +# Declare that this module wants to read from the global config bus. +[[buses]] +name = "GlobalConfig" +access = "Read" + +[parameters] +latency_ms = { label = "Latency (ms)", type = "String", default = "N/A" } + +[[gui]] +type = "Label" +controls = "latency_ms" \ No newline at end of file diff --git a/oxide-modules/latency/src/lib.rs b/oxide-modules/latency/src/lib.rs new file mode 100644 index 0000000..56062b3 --- /dev/null +++ b/oxide-modules/latency/src/lib.rs @@ -0,0 +1,86 @@ +use oxforge::mdk::*; +use std::time::Instant; + +#[derive(Clone, Copy)] +pub struct HeartbeatPacket { + timestamp: Instant, +} + +pub struct LatencyModule {} + +/// The FFI-safe entry point for creating a module instance. +/// +/// This function is called by the host to create a new instance of the audio module. +/// It returns a pointer to a boxed `OxideModule` trait object. The double boxing +/// (`Box>`) is a standard Rust pattern to convert a "fat" trait +/// object pointer into a "thin" pointer that is safe to pass across an FFI boundary. +/// +/// The host is responsible for calling `destroy_module` with the returned pointer +/// when the module is no longer needed to prevent memory leaks. +#[unsafe(no_mangle)] +pub extern "C" fn create_module( + config: &GlobalConfig, +) -> *mut Box { + let module = LatencyModule::new(config); + let boxed_trait: Box = Box::new(module); + let boxed_box = Box::new(boxed_trait); + Box::into_raw(boxed_box) +} + +/// The FFI-safe entry point for destroying a module instance. +/// +/// # Safety +/// The caller must ensure that `module_ptr` is a valid pointer returned from +/// `create_module`. This function must only be called once for any given +/// module instance. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn destroy_module(module_ptr: *mut Box) { + if !module_ptr.is_null() { + // Reconstitute the Box from the raw pointer, which allows Rust's memory + // manager to properly drop the object and deallocate its memory. + // This must be in an unsafe block in the 2024 edition. + unsafe { + let _ = Box::from_raw(module_ptr); + } + } +} + +impl OxideModule for LatencyModule { + fn new(_config: &GlobalConfig) -> Self { + Self {} + } + + fn process(&mut self, mut ports: Ports, context: &ProcessContext) { + // Example of accessing a bus (though not used here yet): + // if let Some(bus_handle) = context.buses.get("GlobalConfig") { + // if let Ok(config) = bus_handle.read() { + // if let Some(global_config) = config.downcast_ref::() { + // // now we can use global_config.sample_rate, etc. + // } + // } + // } + + if let (Some(input), Some(mut output)) = (ports.main_audio_in.take(), ports.main_audio_out.take()) { + output.buffer_mut().copy_from_slice(input.buffer()); + + if let Some(mut chain_out) = ports.chain_out.take() { + let packet = HeartbeatPacket { + timestamp: Instant::now(), + }; + chain_out.send(packet); + } + + if let Some(chain_in) = ports.chain_in.take() { + if let Some(packet) = chain_in.get::() { + let latency = packet.timestamp.elapsed(); + let latency_ms = latency.as_secs_f64() * 1000.0; + + context.to_gui.send(ToGuiMessage::UpdateParameterDisplay { + param_key: "latency_ms".to_string(), + value_str: format!("{:.3} ms", latency_ms), + }).ok(); + } + } + } + } +} \ No newline at end of file diff --git a/oxide-modules/output/Cargo.toml b/oxide-modules/output/Cargo.toml new file mode 100644 index 0000000..e69de29 diff --git a/oxide-modules/output/src/lib.rs b/oxide-modules/output/src/lib.rs new file mode 100644 index 0000000..e69de29 diff --git a/oxide-modules/output_mixer/Cargo.toml b/oxide-modules/output_mixer/Cargo.toml new file mode 100644 index 0000000..867eb11 --- /dev/null +++ b/oxide-modules/output_mixer/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "oxide-output-mixer" +version = "0.1.0" +edition = "2024" + +[lib] +name = "oxide_output_mixer" +path = "src/lib.rs" + +[dependencies] +oxforge = { path = "../../oxforge" } diff --git a/oxide-modules/output_mixer/src/lib.rs b/oxide-modules/output_mixer/src/lib.rs new file mode 100644 index 0000000..c5c6465 --- /dev/null +++ b/oxide-modules/output_mixer/src/lib.rs @@ -0,0 +1,34 @@ +use oxforge::mdk::{ + GlobalConfig, OxideModule, PortContent, PortDeclaration, PortDirection, Ports, ProcessContext, +}; + +pub struct OutputMixerModule; + +impl OxideModule for OutputMixerModule { + fn new(_config: &GlobalConfig) -> Self { Self } + + fn process(&mut self, ports: Ports, _context: &ProcessContext) { + let Some(mut audio_out) = ports.main_audio_out else { return }; + let out = audio_out.buffer_mut(); + + for (_name, bus_ref) in ports.port.iter_buses_in() { + let lanes = bus_ref.lanes(); + if lanes.len() < 2 { continue; } + let left = lanes[0].real(); + let right = lanes[1].real(); + let frames = out.len() / 2; + for i in 0..frames { + if i < left.len() { out[i * 2] += left[i]; } + if i < right.len() { out[i * 2 + 1] += right[i]; } + } + } + } + + fn port_declarations(&self) -> Vec { + vec![PortDeclaration { + name: "all_tracks".into(), + direction: PortDirection::Input, + content: PortContent::AllBuses, + }] + } +} diff --git a/oxide-modules/passthrough/Cargo.toml b/oxide-modules/passthrough/Cargo.toml new file mode 100644 index 0000000..10a950c --- /dev/null +++ b/oxide-modules/passthrough/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "passthrough" +version = "0.1.0" +edition = "2024" + +[lib] +# This is critical. It tells Rust to build a C-style dynamic library +# (.so, .dll, .dylib) that Audio Oxide can load at runtime. +crate-type = ["cdylib"] + +[dependencies] +# This module depends on the MDK definitions from oxforge. +oxforge = { path = "../../oxforge" } \ No newline at end of file diff --git a/oxide-modules/passthrough/module.toml b/oxide-modules/passthrough/module.toml new file mode 100644 index 0000000..98cc378 --- /dev/null +++ b/oxide-modules/passthrough/module.toml @@ -0,0 +1,15 @@ +unique_name = "Passthrough" +description = "Passes audio and other data through without modification." + +[behavior] +# CORRECTED: Parallel mode indicates that instances of this module are +# independent and do not form special cross-instance communication chains. +# This is the standard mode for most typical audio effects. +chaining_mode = "Parallel" + +# Declare that this module has a main audio port for input and output. +# The host will see this and provide the necessary audio buffers. +[[ports]] +name = "main" +role = "Main" +data_type = "Audio" \ No newline at end of file diff --git a/oxide-modules/passthrough/src/lib.rs b/oxide-modules/passthrough/src/lib.rs new file mode 100644 index 0000000..b65cb6e --- /dev/null +++ b/oxide-modules/passthrough/src/lib.rs @@ -0,0 +1,32 @@ +use oxforge::mdk::*; + +pub struct PassthroughModule {} + +#[unsafe(no_mangle)] +pub extern "C" fn create_module( + config: &GlobalConfig, +) -> *mut Box { + let module = PassthroughModule::new(config); + let boxed_trait: Box = Box::new(module); + let boxed_box = Box::new(boxed_trait); + Box::into_raw(boxed_box) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn destroy_module(module_ptr: *mut Box) { + if !module_ptr.is_null() { + let _ = unsafe { Box::from_raw(module_ptr) }; + } +} + +impl OxideModule for PassthroughModule { + fn new(_config: &GlobalConfig) -> Self { + Self {} + } + + fn process(&mut self, mut ports: Ports, _context: &ProcessContext) { + if let (Some(input), Some(mut output)) = (ports.main_audio_in.take(), ports.main_audio_out.take()) { + output.buffer_mut().copy_from_slice(input.buffer()); + } + } +} diff --git a/oxide-modules/recorder/Cargo.toml b/oxide-modules/recorder/Cargo.toml new file mode 100644 index 0000000..1069bb3 --- /dev/null +++ b/oxide-modules/recorder/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "oxide-recorder" +version = "0.1.0" +edition = "2024" + +[lib] +name = "oxide_recorder" +path = "src/lib.rs" + +[dependencies] +oxforge = { path = "../../oxforge" } +crossbeam-channel = "0.5.12" diff --git a/oxide-modules/recorder/src/lib.rs b/oxide-modules/recorder/src/lib.rs new file mode 100644 index 0000000..4d2ea8c --- /dev/null +++ b/oxide-modules/recorder/src/lib.rs @@ -0,0 +1,81 @@ +use std::any::Any; +use crossbeam_channel::Sender; +use oxforge::mdk::{ + AnalyticSignal, GlobalConfig, OxideModule, Ports, ProcessContext, RecorderMessage, +}; + +pub struct RecorderModule { + tx: Option>, + bus_name: String, +} + +impl OxideModule for RecorderModule { + fn new(_config: &GlobalConfig) -> Self { + Self { + tx: None, + bus_name: String::new(), + } + } + + fn process(&mut self, ports: Ports, _context: &ProcessContext) { + let Some(audio_in) = ports.main_audio_in else { return }; + let Some(mut audio_out) = ports.main_audio_out else { return }; + + let inp = audio_in.buffer(); + let out = audio_out.buffer_mut(); + let len = out.len().min(inp.len()); + out[..len].copy_from_slice(&inp[..len]); + + let analytic: Option = ports.chain_in.as_ref() + .and_then(|ci| ci.get::().cloned()); + + if let (Some(signal), Some(mut chain_out)) = (&analytic, ports.chain_out) { + chain_out.send(signal.clone()); + } + + if let Some(ref tx) = self.tx { + let frames = inp.len() / 2; + let mut real_l = Vec::with_capacity(frames); + let mut real_r = Vec::with_capacity(frames); + for i in 0..frames { + real_l.push(inp[i * 2]); + real_r.push(inp[i * 2 + 1]); + } + + let (imag_l, imag_r) = if let Some(ref signal) = analytic { + let il: Vec = signal.left.iter().map(|&(_, im)| im).collect(); + let ir: Vec = signal.right.iter().map(|&(_, im)| im).collect(); + (il, ir) + } else { + (vec![0.0; frames], vec![0.0; frames]) + }; + + let _ = tx.send(RecorderMessage::Chunk { + bus_name: self.bus_name.clone(), + real_l, + real_r, + imag_l, + imag_r, + }); + } + } + + fn receive_data(&mut self, key: &str, data: Box) { + match key { + "start_recording" => { + if let Ok(tx) = data.downcast::>() { + self.tx = Some(*tx); + } + } + "stop_recording" => { + self.tx = None; + } + "set_bus_name" => { + if let Ok(name) = data.downcast::() { + self.bus_name = *name; + } + } + _ => {} + } + } +} diff --git a/oxide-modules/region_player/Cargo.toml b/oxide-modules/region_player/Cargo.toml new file mode 100644 index 0000000..9db6277 --- /dev/null +++ b/oxide-modules/region_player/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "oxide-region-player" +version = "0.1.0" +edition = "2024" + +[lib] +name = "oxide_region_player" +path = "src/lib.rs" + +[dependencies] +oxforge = { path = "../../oxforge" } +uuid = { version = "1.18.1", features = ["v4"] } diff --git a/oxide-modules/region_player/src/lib.rs b/oxide-modules/region_player/src/lib.rs new file mode 100644 index 0000000..132dcb8 --- /dev/null +++ b/oxide-modules/region_player/src/lib.rs @@ -0,0 +1,70 @@ +use std::any::Any; +use oxforge::mdk::{ + GlobalConfig, ModuleContract, OxideModule, PlaybackRegion, Ports, ProcessContext, + TransportState, +}; + +pub struct RegionPlayerModule { + regions: Vec, +} + +impl OxideModule for RegionPlayerModule { + fn new(_config: &GlobalConfig) -> Self { + Self { regions: Vec::new() } + } + + fn process(&mut self, ports: Ports, context: &ProcessContext) { + let Some(mut audio_out) = ports.main_audio_out else { return }; + let out = audio_out.buffer_mut(); + let frames = out.len() / 2; + + if context.time.state != TransportState::Playing { + return; + } + + let pos = context.time.sample_pos; + for region in &self.regions { + let region_end = region.start_sample + region.audio_l.len() as u64; + if pos + frames as u64 <= region.start_sample || pos >= region_end { + continue; + } + let offset = if pos >= region.start_sample { + (pos - region.start_sample) as usize + } else { + 0 + }; + let bus_offset = if pos < region.start_sample { + (region.start_sample - pos) as usize + } else { + 0 + }; + let avail = region.audio_l.len() - offset; + let count = avail.min(frames - bus_offset); + + for i in 0..count { + out[(bus_offset + i) * 2] += region.audio_l[offset + i]; + out[(bus_offset + i) * 2 + 1] += region.audio_r[offset + i]; + } + } + } + + fn contract(&self) -> ModuleContract { + ModuleContract { realtime: true, min_buffer_samples: None } + } + + fn receive_data(&mut self, key: &str, data: Box) { + match key { + "load_region" => { + if let Ok(region) = data.downcast::() { + self.regions.push(*region); + } + } + "unload_region" => { + if let Ok(id) = data.downcast::() { + self.regions.retain(|r| r.region_id != *id); + } + } + _ => {} + } + } +} diff --git a/oxide-modules/spiral_visualizer/Cargo.toml b/oxide-modules/spiral_visualizer/Cargo.toml new file mode 100644 index 0000000..259100c --- /dev/null +++ b/oxide-modules/spiral_visualizer/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "oxide-spiral-visualizer" +version = "0.1.0" +edition = "2024" + +[lib] +name = "oxide_spiral_visualizer" +path = "src/lib.rs" + +[dependencies] +oxforge = { path = "../../oxforge" } diff --git a/oxide-modules/spiral_visualizer/src/lib.rs b/oxide-modules/spiral_visualizer/src/lib.rs new file mode 100644 index 0000000..6d3291f --- /dev/null +++ b/oxide-modules/spiral_visualizer/src/lib.rs @@ -0,0 +1,115 @@ +use oxforge::mdk::{ + GlobalConfig, ModuleContract, OxideModule, Ports, ProcessContext, ToGuiMessage, + AnalyticSignal, PhasePoint, VisualizationFrame, +}; + +const DEFAULT_MAX_POINTS: usize = 4096; + +pub struct SpiralVisualizer { + buffer_l: Vec, + buffer_r: Vec, + frame_counter: u32, + send_interval: u32, + max_points: usize, +} + +impl OxideModule for SpiralVisualizer { + fn new(config: &GlobalConfig) -> Self { + let buffers_per_second = config.sample_rate / config.buffer_size as f32; + let interval = (buffers_per_second / 30.0).max(1.0) as u32; + + Self { + buffer_l: Vec::with_capacity(DEFAULT_MAX_POINTS), + buffer_r: Vec::with_capacity(DEFAULT_MAX_POINTS), + frame_counter: 0, + send_interval: interval.max(1), + max_points: DEFAULT_MAX_POINTS, + } + } + + fn process(&mut self, ports: Ports, context: &ProcessContext) { + if let Some(&size) = context.params.get("viz_buffer_size") { + let new_max = (size as usize).clamp(256, 16384); + if new_max != self.max_points { + self.max_points = new_max; + if self.buffer_l.len() > self.max_points { + let excess = self.buffer_l.len() - self.max_points; + self.buffer_l.drain(..excess); + } + if self.buffer_r.len() > self.max_points { + let excess = self.buffer_r.len() - self.max_points; + self.buffer_r.drain(..excess); + } + } + } + + let mut got_analytic = false; + if let Some(bus) = ports.port.bus_in("main") { + let lanes = bus.lanes(); + if !lanes.is_empty() { + for &(re, im) in lanes[0].analytic() { + let amp = (re * re + im * im).sqrt(); + self.buffer_l.push(PhasePoint { x: re, y: im, amplitude: amp }); + } + if lanes.len() > 1 { + for &(re, im) in lanes[1].analytic() { + let amp = (re * re + im * im).sqrt(); + self.buffer_r.push(PhasePoint { x: re, y: im, amplitude: amp }); + } + } + got_analytic = true; + } + } + + if !got_analytic { + if let Some(ref chain_in) = ports.chain_in { + if let Some(signal) = chain_in.get::() { + for &(re, im) in &signal.left { + let amp = (re * re + im * im).sqrt(); + self.buffer_l.push(PhasePoint { x: re, y: im, amplitude: amp }); + } + for &(re, im) in &signal.right { + let amp = (re * re + im * im).sqrt(); + self.buffer_r.push(PhasePoint { x: re, y: im, amplitude: amp }); + } + } + } + } + + if self.buffer_l.len() > self.max_points { + let excess = self.buffer_l.len() - self.max_points; + self.buffer_l.drain(..excess); + } + if self.buffer_r.len() > self.max_points { + let excess = self.buffer_r.len() - self.max_points; + self.buffer_r.drain(..excess); + } + + if let (Some(audio_in), Some(mut audio_out)) = + (ports.main_audio_in, ports.main_audio_out) + { + let inp = audio_in.buffer(); + let out = audio_out.buffer_mut(); + let len = out.len().min(inp.len()); + out[..len].copy_from_slice(&inp[..len]); + } + + self.frame_counter += 1; + if self.frame_counter >= self.send_interval { + self.frame_counter = 0; + + let frame = VisualizationFrame { + left: self.buffer_l.clone(), + right: self.buffer_r.clone(), + }; + + let _ = context.to_gui.send(ToGuiMessage::VisualizationData { + data: frame.serialize(), + }); + } + } + + fn contract(&self) -> ModuleContract { + ModuleContract { realtime: true, min_buffer_samples: None } + } +} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..08c3ada --- /dev/null +++ b/run.sh @@ -0,0 +1,9704 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2034 # Many variables are used by sourced scripts +# shellcheck disable=SC2155 # Declare and assign separately (acceptable in this codebase) +# shellcheck disable=SC2329 # Functions may be invoked indirectly or via dynamic dispatch +# shellcheck disable=SC2086 # Word splitting is intentional in some contexts +#=============================================================================== +# Loki Mode - Autonomous Runner +# Single script that handles prerequisites, setup, and autonomous execution +# +# Usage: +# ./autonomy/run.sh [OPTIONS] [PRD_PATH] +# ./autonomy/run.sh ./docs/requirements.md +# ./autonomy/run.sh # Interactive mode +# ./autonomy/run.sh --parallel # Parallel mode with git worktrees +# ./autonomy/run.sh --parallel ./prd.md # Parallel mode with PRD +# +# Environment Variables: +# LOKI_PROVIDER - AI provider: claude (default), codex, gemini +# LOKI_MAX_RETRIES - Max retry attempts (default: 50) +# LOKI_BASE_WAIT - Base wait time in seconds (default: 60) +# LOKI_MAX_WAIT - Max wait time in seconds (default: 3600) +# LOKI_SKIP_PREREQS - Skip prerequisite checks (default: false) +# LOKI_DASHBOARD - Enable web dashboard (default: true) +# LOKI_DASHBOARD_PORT - Dashboard port (default: 57374) +# LOKI_TLS_CERT - Path to PEM certificate (enables HTTPS for dashboard) +# LOKI_TLS_KEY - Path to PEM private key (enables HTTPS for dashboard) +# +# Resource Monitoring (prevents system overload): +# LOKI_RESOURCE_CHECK_INTERVAL - Check resources every N seconds (default: 300 = 5min) +# LOKI_RESOURCE_CPU_THRESHOLD - CPU % threshold to warn (default: 80) +# LOKI_RESOURCE_MEM_THRESHOLD - Memory % threshold to warn (default: 80) +# +# Budget / Cost Limits (opt-in): +# LOKI_BUDGET_LIMIT - Max USD spend before auto-pause (default: empty = unlimited) +# Example: "50.00" pauses session when estimated cost >= $50 +# +# Security & Autonomy Controls (Enterprise): +# LOKI_STAGED_AUTONOMY - Require approval before execution (default: false) +# LOKI_AUDIT_LOG - Enable audit logging (default: true) +# LOKI_AUDIT_DISABLED - Disable audit logging (default: false) +# LOKI_MAX_PARALLEL_AGENTS - Limit concurrent agent spawning (default: 10) +# LOKI_SANDBOX_MODE - Run in sandboxed container (default: false, requires Docker) +# LOKI_ALLOWED_PATHS - Comma-separated paths agents can modify (default: all) +# LOKI_BLOCKED_COMMANDS - Comma-separated blocked shell commands (default: rm -rf /) +# +# OIDC / SSO Authentication (optional, works alongside token auth): +# LOKI_OIDC_ISSUER - OIDC issuer URL (e.g., https://accounts.google.com) +# LOKI_OIDC_CLIENT_ID - OIDC client/application ID +# LOKI_OIDC_AUDIENCE - Expected JWT audience (default: same as client_id) +# +# SDLC Phase Controls (all enabled by default, set to 'false' to skip): +# LOKI_PHASE_UNIT_TESTS - Run unit tests (default: true) +# LOKI_PHASE_API_TESTS - Functional API testing (default: true) +# LOKI_PHASE_E2E_TESTS - E2E/UI testing with Playwright (default: true) +# LOKI_PHASE_SECURITY - Security scanning OWASP/auth (default: true) +# LOKI_PHASE_INTEGRATION - Integration tests SAML/OIDC/SSO (default: true) +# LOKI_PHASE_CODE_REVIEW - 3-reviewer parallel code review (default: true) +# LOKI_PHASE_WEB_RESEARCH - Competitor/feature gap research (default: true) +# LOKI_PHASE_PERFORMANCE - Load/performance testing (default: true) +# LOKI_PHASE_ACCESSIBILITY - WCAG compliance testing (default: true) +# LOKI_PHASE_REGRESSION - Regression testing (default: true) +# LOKI_PHASE_UAT - UAT simulation (default: true) +# +# Autonomous Loop Controls (Ralph Wiggum Mode): +# LOKI_COMPLETION_PROMISE - EXPLICIT stop condition text (default: none - runs forever) +# Example: "ALL TESTS PASSING 100%" +# Only stops when the AI provider outputs this EXACT text +# LOKI_MAX_ITERATIONS - Max loop iterations before exit (default: 1000) +# LOKI_PERPETUAL_MODE - Ignore ALL completion signals (default: false) +# Set to 'true' for truly infinite operation +# +# Completion Council (v5.25.0) - Multi-agent completion verification: +# LOKI_COUNCIL_ENABLED - Enable completion council (default: true) +# LOKI_COUNCIL_SIZE - Number of council members (default: 3) +# LOKI_COUNCIL_THRESHOLD - Votes needed for completion (default: 2) +# LOKI_COUNCIL_CHECK_INTERVAL - Check every N iterations (default: 5) +# LOKI_COUNCIL_MIN_ITERATIONS - Min iterations before council runs (default: 3) +# LOKI_COUNCIL_STAGNATION_LIMIT - Max iterations with no git changes (default: 5) +# +# Model Selection: +# LOKI_ALLOW_HAIKU - Enable Haiku model for fast tier (default: false) +# When false: Opus for dev/bugfix, Sonnet for tests/docs +# When true: Sonnet for dev, Haiku for tests/docs (original) +# Use --allow-haiku flag or set to 'true' +# +# 2026 Research Enhancements: +# LOKI_PROMPT_REPETITION - Enable prompt repetition for Haiku agents (default: true) +# arXiv 2512.14982v1: Improves accuracy 4-5x on structured tasks +# LOKI_CONFIDENCE_ROUTING - Enable confidence-based routing (default: true) +# HN Production: 4-tier routing (auto-approve, direct, supervisor, escalate) +# LOKI_AUTONOMY_MODE - Autonomy level (default: perpetual) +# Options: perpetual, checkpoint, supervised +# Tim Dettmers: "Shorter bursts of autonomy with feedback loops" +# +# Parallel Workflows (Git Worktrees): +# LOKI_PARALLEL_MODE - Enable git worktree-based parallelism (default: false) +# Use --parallel flag or set to 'true' +# LOKI_MAX_WORKTREES - Maximum parallel worktrees (default: 5) +# LOKI_MAX_PARALLEL_SESSIONS - Maximum concurrent AI sessions (default: 3) +# LOKI_PARALLEL_TESTING - Run testing stream in parallel (default: true) +# LOKI_PARALLEL_DOCS - Run documentation stream in parallel (default: true) +# LOKI_PARALLEL_BLOG - Run blog stream if site has blog (default: false) +# LOKI_AUTO_MERGE - Auto-merge completed features (default: true) +# +# Complexity Tiers (Auto-Claude pattern): +# LOKI_COMPLEXITY - Force complexity tier (default: auto) +# Options: auto, simple, standard, complex +# Simple (3 phases): 1-2 files, single service, UI fixes, text changes +# Standard (6 phases): 3-10 files, 1-2 services, features, bug fixes +# Complex (8 phases): 10+ files, multiple services, external integrations +# +# GitHub Integration (v4.1.0): +# LOKI_GITHUB_IMPORT - Import open issues as tasks (default: false) +# LOKI_GITHUB_PR - Create PR when feature complete (default: false) +# LOKI_GITHUB_SYNC - Sync status back to issues (default: false) +# LOKI_GITHUB_REPO - Override repo detection (default: from git remote) +# LOKI_GITHUB_LABELS - Filter by labels (comma-separated) +# LOKI_GITHUB_MILESTONE - Filter by milestone +# LOKI_GITHUB_ASSIGNEE - Filter by assignee +# LOKI_GITHUB_LIMIT - Max issues to import (default: 100) +# LOKI_GITHUB_PR_LABEL - Label for PRs (default: none, avoids error if label missing) +# +# Desktop Notifications (v4.1.0): +# LOKI_NOTIFICATIONS - Enable desktop notifications (default: true) +# LOKI_NOTIFICATION_SOUND - Play sound with notifications (default: true) +# +# Human Intervention (Auto-Claude pattern): +# PAUSE file: touch .loki/PAUSE - pauses after current session +# HUMAN_INPUT.md: echo "instructions" > .loki/HUMAN_INPUT.md +# STOP file: touch .loki/STOP - stops immediately +# Ctrl+C (once): Pauses execution, shows options +# Ctrl+C (twice): Exits immediately +# +# Security (Enterprise): +# LOKI_PROMPT_INJECTION - Enable HUMAN_INPUT.md processing (default: false) +# Set to "true" only in trusted environments +# +# Branch Protection (agent isolation): +# LOKI_BRANCH_PROTECTION - Create feature branch for agent changes (default: false) +# Agent works on loki/session-- branch +# Creates PR on session end if gh CLI is available +# +# Process Supervision (opt-in): +# LOKI_WATCHDOG - Enable process health monitoring (default: false) +# LOKI_WATCHDOG_INTERVAL - Check interval in seconds (default: 30) +#=============================================================================== +# +# Compatibility: bash 3.2+ (macOS default), bash 4+ (Linux), WSL +# Parallel mode (--parallel) requires bash 4.0+ for associative arrays +#=============================================================================== + +set -uo pipefail + +# Compatibility check: Ensure we're running in bash (not sh, dash, zsh) +if [ -z "${BASH_VERSION:-}" ]; then + echo "[ERROR] This script requires bash. Please run with: bash $0" >&2 + exit 1 +fi + +# Extract major version for feature checks +BASH_VERSION_MAJOR="${BASH_VERSION%%.*}" +BASH_VERSION_MINOR="${BASH_VERSION#*.}" +BASH_VERSION_MINOR="${BASH_VERSION_MINOR%%.*}" + +# Warn if bash version is very old (< 3.2) +if [ "$BASH_VERSION_MAJOR" -lt 3 ] || { [ "$BASH_VERSION_MAJOR" -eq 3 ] && [ "$BASH_VERSION_MINOR" -lt 2 ]; }; then + echo "[WARN] Bash version $BASH_VERSION is old. Recommend bash 3.2+ for full compatibility." >&2 + echo "[WARN] Some features may not work correctly." >&2 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +#=============================================================================== +# Self-Copy Protection +# Bash reads scripts incrementally, so editing a running script corrupts execution. +# Solution: Copy ourselves to /tmp and run from there. The original can be safely edited. +#=============================================================================== +if [[ -z "${LOKI_RUNNING_FROM_TEMP:-}" ]] && [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + TEMP_SCRIPT=$(mktemp /tmp/loki-run-XXXXXX.sh) + cp "${BASH_SOURCE[0]}" "$TEMP_SCRIPT" + chmod 700 "$TEMP_SCRIPT" + export LOKI_RUNNING_FROM_TEMP=1 + export LOKI_ORIGINAL_SCRIPT_DIR="$SCRIPT_DIR" + export LOKI_ORIGINAL_PROJECT_DIR="$PROJECT_DIR" + exec "$TEMP_SCRIPT" "$@" +fi + +# Restore original paths when running from temp +SCRIPT_DIR="${LOKI_ORIGINAL_SCRIPT_DIR:-$SCRIPT_DIR}" +PROJECT_DIR="${LOKI_ORIGINAL_PROJECT_DIR:-$PROJECT_DIR}" + +# Clean up temp script on exit (only when running from temp copy) +if [[ "${LOKI_RUNNING_FROM_TEMP:-}" == "1" ]]; then + trap 'rm -f "${BASH_SOURCE[0]}" 2>/dev/null' EXIT +fi + +#=============================================================================== +# Configuration File Support (v4.1.0) +# Loads settings from config file, environment variables take precedence +#=============================================================================== +load_config_file() { + local config_file="" + + # Search for config file in order of priority + # Security: Reject symlinks to prevent path traversal attacks + # 1. Project-local config + if [ -f ".loki/config.yaml" ] && [ ! -L ".loki/config.yaml" ]; then + config_file=".loki/config.yaml" + elif [ -f ".loki/config.yml" ] && [ ! -L ".loki/config.yml" ]; then + config_file=".loki/config.yml" + # 2. User-global config (symlinks allowed in home dir - user controls it) + elif [ -f "${HOME}/.config/loki-mode/config.yaml" ]; then + config_file="${HOME}/.config/loki-mode/config.yaml" + elif [ -f "${HOME}/.config/loki-mode/config.yml" ]; then + config_file="${HOME}/.config/loki-mode/config.yml" + fi + + # If no config file found, return silently + if [ -z "$config_file" ]; then + return 0 + fi + + # Check for yq (YAML parser) + if ! command -v yq &> /dev/null; then + # Fallback: parse simple YAML with sed/grep + parse_simple_yaml "$config_file" + return 0 + fi + + # Use yq for proper YAML parsing + parse_yaml_with_yq "$config_file" +} + +# Fallback YAML parser for simple key: value format +parse_simple_yaml() { + local file="$1" + + # Parse core settings + set_from_yaml "$file" "core.max_retries" "LOKI_MAX_RETRIES" + set_from_yaml "$file" "core.base_wait" "LOKI_BASE_WAIT" + set_from_yaml "$file" "core.max_wait" "LOKI_MAX_WAIT" + set_from_yaml "$file" "core.skip_prereqs" "LOKI_SKIP_PREREQS" + + # Dashboard + set_from_yaml "$file" "dashboard.enabled" "LOKI_DASHBOARD" + set_from_yaml "$file" "dashboard.port" "LOKI_DASHBOARD_PORT" + + # Resources + set_from_yaml "$file" "resources.check_interval" "LOKI_RESOURCE_CHECK_INTERVAL" + set_from_yaml "$file" "resources.cpu_threshold" "LOKI_RESOURCE_CPU_THRESHOLD" + set_from_yaml "$file" "resources.mem_threshold" "LOKI_RESOURCE_MEM_THRESHOLD" + + # Security + set_from_yaml "$file" "security.staged_autonomy" "LOKI_STAGED_AUTONOMY" + set_from_yaml "$file" "security.audit_log" "LOKI_AUDIT_LOG" + set_from_yaml "$file" "security.max_parallel_agents" "LOKI_MAX_PARALLEL_AGENTS" + set_from_yaml "$file" "security.sandbox_mode" "LOKI_SANDBOX_MODE" + set_from_yaml "$file" "security.allowed_paths" "LOKI_ALLOWED_PATHS" + set_from_yaml "$file" "security.blocked_commands" "LOKI_BLOCKED_COMMANDS" + + # Phases + set_from_yaml "$file" "phases.unit_tests" "LOKI_PHASE_UNIT_TESTS" + set_from_yaml "$file" "phases.api_tests" "LOKI_PHASE_API_TESTS" + set_from_yaml "$file" "phases.e2e_tests" "LOKI_PHASE_E2E_TESTS" + set_from_yaml "$file" "phases.security" "LOKI_PHASE_SECURITY" + set_from_yaml "$file" "phases.integration" "LOKI_PHASE_INTEGRATION" + set_from_yaml "$file" "phases.code_review" "LOKI_PHASE_CODE_REVIEW" + set_from_yaml "$file" "phases.web_research" "LOKI_PHASE_WEB_RESEARCH" + set_from_yaml "$file" "phases.performance" "LOKI_PHASE_PERFORMANCE" + set_from_yaml "$file" "phases.accessibility" "LOKI_PHASE_ACCESSIBILITY" + set_from_yaml "$file" "phases.regression" "LOKI_PHASE_REGRESSION" + set_from_yaml "$file" "phases.uat" "LOKI_PHASE_UAT" + + # Completion + set_from_yaml "$file" "completion.promise" "LOKI_COMPLETION_PROMISE" + set_from_yaml "$file" "completion.max_iterations" "LOKI_MAX_ITERATIONS" + set_from_yaml "$file" "completion.perpetual_mode" "LOKI_PERPETUAL_MODE" + set_from_yaml "$file" "completion.council.enabled" "LOKI_COUNCIL_ENABLED" + set_from_yaml "$file" "completion.council.size" "LOKI_COUNCIL_SIZE" + set_from_yaml "$file" "completion.council.threshold" "LOKI_COUNCIL_THRESHOLD" + set_from_yaml "$file" "completion.council.check_interval" "LOKI_COUNCIL_CHECK_INTERVAL" + set_from_yaml "$file" "completion.council.min_iterations" "LOKI_COUNCIL_MIN_ITERATIONS" + set_from_yaml "$file" "completion.council.stagnation_limit" "LOKI_COUNCIL_STAGNATION_LIMIT" + + # Model + set_from_yaml "$file" "model.prompt_repetition" "LOKI_PROMPT_REPETITION" + set_from_yaml "$file" "model.confidence_routing" "LOKI_CONFIDENCE_ROUTING" + set_from_yaml "$file" "model.autonomy_mode" "LOKI_AUTONOMY_MODE" + set_from_yaml "$file" "model.planning" "LOKI_MODEL_PLANNING" + set_from_yaml "$file" "model.development" "LOKI_MODEL_DEVELOPMENT" + set_from_yaml "$file" "model.fast" "LOKI_MODEL_FAST" + set_from_yaml "$file" "model.compaction_interval" "LOKI_COMPACTION_INTERVAL" + + # Parallel + set_from_yaml "$file" "parallel.enabled" "LOKI_PARALLEL_MODE" + set_from_yaml "$file" "parallel.max_worktrees" "LOKI_MAX_WORKTREES" + set_from_yaml "$file" "parallel.max_sessions" "LOKI_MAX_PARALLEL_SESSIONS" + set_from_yaml "$file" "parallel.testing" "LOKI_PARALLEL_TESTING" + set_from_yaml "$file" "parallel.docs" "LOKI_PARALLEL_DOCS" + set_from_yaml "$file" "parallel.blog" "LOKI_PARALLEL_BLOG" + set_from_yaml "$file" "parallel.auto_merge" "LOKI_AUTO_MERGE" + + # Complexity + set_from_yaml "$file" "complexity.tier" "LOKI_COMPLEXITY" + + # GitHub + set_from_yaml "$file" "github.import" "LOKI_GITHUB_IMPORT" + set_from_yaml "$file" "github.pr" "LOKI_GITHUB_PR" + set_from_yaml "$file" "github.sync" "LOKI_GITHUB_SYNC" + set_from_yaml "$file" "github.repo" "LOKI_GITHUB_REPO" + set_from_yaml "$file" "github.labels" "LOKI_GITHUB_LABELS" + set_from_yaml "$file" "github.milestone" "LOKI_GITHUB_MILESTONE" + set_from_yaml "$file" "github.assignee" "LOKI_GITHUB_ASSIGNEE" + set_from_yaml "$file" "github.limit" "LOKI_GITHUB_LIMIT" + set_from_yaml "$file" "github.pr_label" "LOKI_GITHUB_PR_LABEL" + + # Notifications + set_from_yaml "$file" "notifications.enabled" "LOKI_NOTIFICATIONS" + set_from_yaml "$file" "notifications.sound" "LOKI_NOTIFICATION_SOUND" +} + +# Validate YAML value to prevent injection attacks +validate_yaml_value() { + local value="$1" + local max_length="${2:-1000}" + + # Reject empty values + if [ -z "$value" ]; then + return 1 + fi + + # Reject values with dangerous shell metacharacters + # Allow alphanumeric, spaces, dots, dashes, underscores, slashes, colons, commas, @ + if [[ "$value" =~ [\$\`\|\;\&\>\<\(\)\{\}\[\]\\] ]]; then + return 1 + fi + + # Reject values that are too long (DoS protection) + if [ "${#value}" -gt "$max_length" ]; then + return 1 + fi + + # Reject values with newlines (could corrupt variables) + if [[ "$value" == *$'\n'* ]]; then + return 1 + fi + + return 0 +} + +# Escape regex metacharacters for safe grep usage +escape_regex() { + local input="$1" + # Escape: . * ? + [ ] ^ $ { } | ( ) \ + printf '%s' "$input" | sed 's/[.[\*?+^${}|()\\]/\\&/g' +} + +# Helper: Extract value from YAML and set env var if not already set +set_from_yaml() { + local file="$1" + local yaml_path="$2" + local env_var="$3" + + # Skip if env var is already set + if [ -n "${!env_var:-}" ]; then + return 0 + fi + + # Extract value using grep and sed (handles simple YAML) + # Convert yaml path like "core.max_retries" to search pattern + local value="" + local key="${yaml_path##*.}" # Get last part of path + + # Escape regex metacharacters in key for safe grep + local escaped_key + escaped_key=$(escape_regex "$key") + + # Simple grep for the key (works for flat or indented YAML) + # Use read to avoid xargs command execution risks + value=$(grep -E "^\s*${escaped_key}:" "$file" 2>/dev/null | head -1 | sed -E 's/.*:\s*//' | sed 's/#.*//' | sed 's/^["\x27]//;s/["\x27]$//' | tr -d '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + + # Validate value before export (security check) + if [ -n "$value" ] && [ "$value" != "null" ] && validate_yaml_value "$value"; then + export "$env_var=$value" + fi +} + +# Parse YAML using yq (proper parser) +parse_yaml_with_yq() { + local file="$1" + local mappings=( + "core.max_retries:LOKI_MAX_RETRIES" + "core.base_wait:LOKI_BASE_WAIT" + "core.max_wait:LOKI_MAX_WAIT" + "core.skip_prereqs:LOKI_SKIP_PREREQS" + "dashboard.enabled:LOKI_DASHBOARD" + "dashboard.port:LOKI_DASHBOARD_PORT" + "resources.check_interval:LOKI_RESOURCE_CHECK_INTERVAL" + "resources.cpu_threshold:LOKI_RESOURCE_CPU_THRESHOLD" + "resources.mem_threshold:LOKI_RESOURCE_MEM_THRESHOLD" + "security.staged_autonomy:LOKI_STAGED_AUTONOMY" + "security.audit_log:LOKI_AUDIT_LOG" + "security.max_parallel_agents:LOKI_MAX_PARALLEL_AGENTS" + "security.sandbox_mode:LOKI_SANDBOX_MODE" + "security.allowed_paths:LOKI_ALLOWED_PATHS" + "security.blocked_commands:LOKI_BLOCKED_COMMANDS" + "phases.unit_tests:LOKI_PHASE_UNIT_TESTS" + "phases.api_tests:LOKI_PHASE_API_TESTS" + "phases.e2e_tests:LOKI_PHASE_E2E_TESTS" + "phases.security:LOKI_PHASE_SECURITY" + "phases.integration:LOKI_PHASE_INTEGRATION" + "phases.code_review:LOKI_PHASE_CODE_REVIEW" + "phases.web_research:LOKI_PHASE_WEB_RESEARCH" + "phases.performance:LOKI_PHASE_PERFORMANCE" + "phases.accessibility:LOKI_PHASE_ACCESSIBILITY" + "phases.regression:LOKI_PHASE_REGRESSION" + "phases.uat:LOKI_PHASE_UAT" + "completion.promise:LOKI_COMPLETION_PROMISE" + "completion.max_iterations:LOKI_MAX_ITERATIONS" + "completion.perpetual_mode:LOKI_PERPETUAL_MODE" + "completion.council.enabled:LOKI_COUNCIL_ENABLED" + "completion.council.size:LOKI_COUNCIL_SIZE" + "completion.council.threshold:LOKI_COUNCIL_THRESHOLD" + "completion.council.check_interval:LOKI_COUNCIL_CHECK_INTERVAL" + "completion.council.min_iterations:LOKI_COUNCIL_MIN_ITERATIONS" + "completion.council.stagnation_limit:LOKI_COUNCIL_STAGNATION_LIMIT" + "model.prompt_repetition:LOKI_PROMPT_REPETITION" + "model.confidence_routing:LOKI_CONFIDENCE_ROUTING" + "model.autonomy_mode:LOKI_AUTONOMY_MODE" + "model.compaction_interval:LOKI_COMPACTION_INTERVAL" + "parallel.enabled:LOKI_PARALLEL_MODE" + "parallel.max_worktrees:LOKI_MAX_WORKTREES" + "parallel.max_sessions:LOKI_MAX_PARALLEL_SESSIONS" + "parallel.testing:LOKI_PARALLEL_TESTING" + "parallel.docs:LOKI_PARALLEL_DOCS" + "parallel.blog:LOKI_PARALLEL_BLOG" + "parallel.auto_merge:LOKI_AUTO_MERGE" + "complexity.tier:LOKI_COMPLEXITY" + "github.import:LOKI_GITHUB_IMPORT" + "github.pr:LOKI_GITHUB_PR" + "github.sync:LOKI_GITHUB_SYNC" + "github.repo:LOKI_GITHUB_REPO" + "github.labels:LOKI_GITHUB_LABELS" + "github.milestone:LOKI_GITHUB_MILESTONE" + "github.assignee:LOKI_GITHUB_ASSIGNEE" + "github.limit:LOKI_GITHUB_LIMIT" + "github.pr_label:LOKI_GITHUB_PR_LABEL" + "notifications.enabled:LOKI_NOTIFICATIONS" + "notifications.sound:LOKI_NOTIFICATION_SOUND" + ) + + for mapping in "${mappings[@]}"; do + local yaml_path="${mapping%%:*}" + local env_var="${mapping##*:}" + + # Skip if env var is already set + if [ -n "${!env_var:-}" ]; then + continue + fi + + # Extract value using yq + local value + value=$(yq eval ".$yaml_path // \"\"" "$file" 2>/dev/null) + + # Set env var if value found and not empty/null + # Also validate for security (prevent injection) + if [ -n "$value" ] && [ "$value" != "null" ] && [ "$value" != "" ] && validate_yaml_value "$value"; then + export "$env_var=$value" + fi + done +} + +# Load config file before setting defaults +load_config_file + +# Load JSON settings from loki config set (v6.0.0) +_load_json_settings() { + local settings_file="${TARGET_DIR:-.}/.loki/config/settings.json" + [ -f "$settings_file" ] || return 0 + eval "$(_LOKI_SETTINGS_FILE="$settings_file" python3 -c " +import json, sys, os, shlex + +def get_nested(d, key): + \"\"\"Resolve dotted keys through nested dicts (model.planning -> data['model']['planning'])\"\"\" + parts = key.split('.') + cur = d + for p in parts: + if isinstance(cur, dict): + cur = cur.get(p) + else: + return None + return cur + +try: + with open(os.environ['_LOKI_SETTINGS_FILE']) as f: + data = json.load(f) +except Exception: + sys.exit(0) +mapping = { + 'maxTier': 'LOKI_MAX_TIER', + 'model.planning': 'LOKI_MODEL_PLANNING', + 'model.development': 'LOKI_MODEL_DEVELOPMENT', + 'model.fast': 'LOKI_MODEL_FAST', + 'notify.slack': 'LOKI_SLACK_WEBHOOK', + 'notify.discord': 'LOKI_DISCORD_WEBHOOK', +} +for key, env_var in mapping.items(): + # Try nested dict lookup first, then flat key, then underscore variant + val = get_nested(data, key) or data.get(key) or data.get(key.replace('.', '_')) + if val and isinstance(val, str): + safe_val = shlex.quote(val) + print(f'[ -z \"\${{{env_var}:-}}\" ] && export {env_var}={safe_val}') +" 2>/dev/null)" 2>/dev/null || true +} +_LOKI_SETTINGS_FILE="${TARGET_DIR:-.}/.loki/config/settings.json" _load_json_settings + +# Configuration +MAX_RETRIES=${LOKI_MAX_RETRIES:-50} +BASE_WAIT=${LOKI_BASE_WAIT:-60} +MAX_WAIT=${LOKI_MAX_WAIT:-3600} +SKIP_PREREQS=${LOKI_SKIP_PREREQS:-false} +ENABLE_DASHBOARD=${LOKI_DASHBOARD:-true} +DASHBOARD_PORT=${LOKI_DASHBOARD_PORT:-57374} +RESOURCE_CHECK_INTERVAL=${LOKI_RESOURCE_CHECK_INTERVAL:-300} # Check every 5 minutes +RESOURCE_CPU_THRESHOLD=${LOKI_RESOURCE_CPU_THRESHOLD:-80} # CPU % threshold +RESOURCE_MEM_THRESHOLD=${LOKI_RESOURCE_MEM_THRESHOLD:-80} # Memory % threshold + +# Budget / Cost Limit (opt-in, empty = unlimited) +BUDGET_LIMIT=${LOKI_BUDGET_LIMIT:-""} # USD amount, e.g., "50.00" + +# Background Mode +BACKGROUND_MODE=${LOKI_BACKGROUND:-false} # Run in background + +# Security & Autonomy Controls +STAGED_AUTONOMY=${LOKI_STAGED_AUTONOMY:-false} # Require plan approval +AUDIT_LOG_ENABLED=${LOKI_AUDIT_LOG:-true} # Enable audit logging (on by default) +MAX_PARALLEL_AGENTS=${LOKI_MAX_PARALLEL_AGENTS:-10} # Limit concurrent agents +SANDBOX_MODE=${LOKI_SANDBOX_MODE:-false} # Docker sandbox mode +ALLOWED_PATHS=${LOKI_ALLOWED_PATHS:-""} # Empty = all paths allowed +BLOCKED_COMMANDS=${LOKI_BLOCKED_COMMANDS:-"rm -rf /,dd if=,mkfs,:(){ :|:& };:"} + +# Process Supervision (opt-in) +WATCHDOG_ENABLED=${LOKI_WATCHDOG:-"false"} # Enable process health monitoring +WATCHDOG_INTERVAL=${LOKI_WATCHDOG_INTERVAL:-30} # Check interval in seconds +LAST_WATCHDOG_CHECK=0 + +STATUS_MONITOR_PID="" +DASHBOARD_PID="" +DASHBOARD_LAST_ALIVE=0 +_DASHBOARD_RESTARTING=false +RESOURCE_MONITOR_PID="" + +# SDLC Phase Controls (all enabled by default) +PHASE_UNIT_TESTS=${LOKI_PHASE_UNIT_TESTS:-true} +PHASE_API_TESTS=${LOKI_PHASE_API_TESTS:-true} +PHASE_E2E_TESTS=${LOKI_PHASE_E2E_TESTS:-true} +PHASE_SECURITY=${LOKI_PHASE_SECURITY:-true} +PHASE_INTEGRATION=${LOKI_PHASE_INTEGRATION:-true} +PHASE_CODE_REVIEW=${LOKI_PHASE_CODE_REVIEW:-true} +PHASE_WEB_RESEARCH=${LOKI_PHASE_WEB_RESEARCH:-true} +PHASE_PERFORMANCE=${LOKI_PHASE_PERFORMANCE:-true} +PHASE_ACCESSIBILITY=${LOKI_PHASE_ACCESSIBILITY:-true} +PHASE_REGRESSION=${LOKI_PHASE_REGRESSION:-true} +PHASE_UAT=${LOKI_PHASE_UAT:-true} + +# Autonomous Loop Controls (Ralph Wiggum Mode) +# Default: No auto-completion - runs until max iterations or explicit promise +COMPLETION_PROMISE=${LOKI_COMPLETION_PROMISE:-""} +MAX_ITERATIONS=${LOKI_MAX_ITERATIONS:-1000} +ITERATION_COUNT=0 +# Perpetual mode: never stop unless max iterations (ignores all completion signals) +PERPETUAL_MODE=${LOKI_PERPETUAL_MODE:-false} + +# Enterprise background service PIDs (OTEL bridge, audit subscriber, integration sync) +ENTERPRISE_PIDS=() + +# Completion Council (v5.25.0) - Multi-agent completion verification +# Source completion council module +COUNCIL_SCRIPT="$SCRIPT_DIR/completion-council.sh" +if [ -f "$COUNCIL_SCRIPT" ]; then + # shellcheck source=completion-council.sh + source "$COUNCIL_SCRIPT" +fi + +# PRD Checklist module (v5.44.0) +if [ -f "${SCRIPT_DIR}/prd-checklist.sh" ]; then + # shellcheck source=prd-checklist.sh + source "${SCRIPT_DIR}/prd-checklist.sh" +fi + +# App Runner module (v5.45.0) +if [ -f "${SCRIPT_DIR}/app-runner.sh" ]; then + # shellcheck source=app-runner.sh + source "${SCRIPT_DIR}/app-runner.sh" +fi + +# Playwright Smoke Test module (v5.46.0) +if [ -f "${SCRIPT_DIR}/playwright-verify.sh" ]; then + # shellcheck source=playwright-verify.sh + source "${SCRIPT_DIR}/playwright-verify.sh" +fi + +# Anonymous usage telemetry (opt-out: LOKI_TELEMETRY_DISABLED=true or DO_NOT_TRACK=1) +TELEMETRY_SCRIPT="$SCRIPT_DIR/telemetry.sh" +if [ -f "$TELEMETRY_SCRIPT" ]; then + # shellcheck source=telemetry.sh + source "$TELEMETRY_SCRIPT" +fi + +# 2026 Research Enhancements (minimal additions) +PROMPT_REPETITION=${LOKI_PROMPT_REPETITION:-true} +CONFIDENCE_ROUTING=${LOKI_CONFIDENCE_ROUTING:-true} +AUTONOMY_MODE=${LOKI_AUTONOMY_MODE:-perpetual} # perpetual|checkpoint|supervised + +# Proactive Context Management (OpenCode/Sisyphus pattern, validated by Opus) +COMPACTION_INTERVAL=${LOKI_COMPACTION_INTERVAL:-25} # Suggest compaction every N iterations + +# Parallel Workflows (Git Worktrees) +PARALLEL_MODE=${LOKI_PARALLEL_MODE:-false} +MAX_WORKTREES=${LOKI_MAX_WORKTREES:-5} +MAX_PARALLEL_SESSIONS=${LOKI_MAX_PARALLEL_SESSIONS:-3} +PARALLEL_TESTING=${LOKI_PARALLEL_TESTING:-true} +PARALLEL_DOCS=${LOKI_PARALLEL_DOCS:-true} + +# Gate Escalation Ladder (v6.10.0) +GATE_CLEAR_LIMIT=${LOKI_GATE_CLEAR_LIMIT:-3} +GATE_ESCALATE_LIMIT=${LOKI_GATE_ESCALATE_LIMIT:-5} +GATE_PAUSE_LIMIT=${LOKI_GATE_PAUSE_LIMIT:-10} +TARGET_DIR="${LOKI_TARGET_DIR:-$(pwd)}" +PARALLEL_BLOG=${LOKI_PARALLEL_BLOG:-false} +AUTO_MERGE=${LOKI_AUTO_MERGE:-true} + +# Complexity Tiers (Auto-Claude pattern) +# auto = detect from PRD/codebase, simple = 3 phases, standard = 6 phases, complex = 8 phases +COMPLEXITY_TIER=${LOKI_COMPLEXITY:-auto} +DETECTED_COMPLEXITY="" + +# Multi-Provider Support (v5.0.0) +# Provider: claude (default), codex, gemini +LOKI_PROVIDER=${LOKI_PROVIDER:-claude} + +# Source provider configuration +PROVIDERS_DIR="$PROJECT_DIR/providers" +if [ -f "$PROVIDERS_DIR/loader.sh" ]; then + # shellcheck source=/dev/null + source "$PROVIDERS_DIR/loader.sh" + + # Validate provider + if ! validate_provider "$LOKI_PROVIDER"; then + echo "ERROR: Unknown provider: $LOKI_PROVIDER" >&2 + echo "Supported providers: ${SUPPORTED_PROVIDERS[*]}" >&2 + exit 1 + fi + + # Load provider config + if ! load_provider "$LOKI_PROVIDER"; then + echo "ERROR: Failed to load provider config: $LOKI_PROVIDER" >&2 + exit 1 + fi + + # Save provider for future runs (if .loki dir exists or will be created) + if [ -d ".loki/state" ] || mkdir -p ".loki/state" 2>/dev/null; then + echo "$LOKI_PROVIDER" > ".loki/state/provider" + fi +else + # Fallback: Claude-only mode (backwards compatibility) + PROVIDER_NAME="claude" + PROVIDER_CLI="claude" + PROVIDER_AUTONOMOUS_FLAG="--dangerously-skip-permissions" + PROVIDER_PROMPT_FLAG="-p" + PROVIDER_DEGRADED=false + PROVIDER_DISPLAY_NAME="Claude Code" + PROVIDER_HAS_PARALLEL=true + PROVIDER_HAS_SUBAGENTS=true + PROVIDER_HAS_TASK_TOOL=true + PROVIDER_HAS_MCP=true + PROVIDER_PROMPT_POSITIONAL=false +fi + +# Track worktree PIDs for cleanup (requires bash 4+ for associative arrays) +# BASH_VERSION_MAJOR is defined at script startup +if [ "$BASH_VERSION_MAJOR" -ge 4 ] 2>/dev/null; then + declare -A WORKTREE_PIDS=() + declare -A WORKTREE_PATHS=() +else + # Fallback: parallel mode will check and warn + # shellcheck disable=SC2178 + WORKTREE_PIDS="" + # shellcheck disable=SC2178 + WORKTREE_PATHS="" +fi +# Track background install PIDs for cleanup (indexed array, works on all bash versions) +WORKTREE_INSTALL_PIDS=() + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +#=============================================================================== +# Logging Functions +#=============================================================================== + +log_header() { + echo "" + echo -e "${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║${NC} ${BOLD}$1${NC}" + echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}" +} + +log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_warning() { log_warn "$@"; } # Alias for backwards compatibility +log_error() { echo -e "${RED}[ERROR]${NC} $*"; } +log_step() { echo -e "${CYAN}[STEP]${NC} $*"; } +log_debug() { [[ "${LOKI_DEBUG:-}" == "true" ]] && echo -e "${CYAN}[DEBUG]${NC} $*" || true; } + +#=============================================================================== +# Process Registry (PID Supervisor) +# Central registry of all spawned child processes for reliable cleanup +#=============================================================================== + +PID_REGISTRY_DIR="" + +# Initialize the PID registry directory +init_pid_registry() { + PID_REGISTRY_DIR="${TARGET_DIR:-.}/.loki/pids" + mkdir -p "$PID_REGISTRY_DIR" +} + +# Parse a field from a JSON registry entry (python3 with shell fallback) +# Usage: _parse_json_field +_parse_json_field() { + local file="$1" field="$2" + if command -v python3 >/dev/null 2>&1; then + python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2],''))" "$file" "$field" 2>/dev/null + else + # Shell fallback: extract value for simple flat JSON + sed 's/.*"'"$field"'":\s*//' "$file" 2>/dev/null | sed 's/[",}].*//' | head -1 + fi +} + +# Register a spawned process in the central registry +# Usage: register_pid