From 725b18dc72c63b703ccc4426ef6aca677eecf61b Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 30 Mar 2026 18:34:56 -0700 Subject: [PATCH] baseline: recover workspace from iCloud damage --- .gitignore | 10 + Cargo.toml | 26 + au-o2-gui/.gitignore | 1 + au-o2-gui/Cargo.toml | 55 + au-o2-gui/assets/Info.plist | 28 + au-o2-gui/assets/icon.icns | Bin 0 -> 225313 bytes au-o2-gui/assets/icon.svg | 24 + au-o2-gui/assets/icons/add.svg | 4 + au-o2-gui/assets/icons/automation.svg | 5 + au-o2-gui/assets/icons/close.svg | 4 + au-o2-gui/assets/icons/copy.svg | 4 + au-o2-gui/assets/icons/count-in.svg | 8 + au-o2-gui/assets/icons/cut.svg | 7 + au-o2-gui/assets/icons/cycle.svg | 6 + au-o2-gui/assets/icons/eq.svg | 3 + au-o2-gui/assets/icons/fast-forward.svg | 4 + au-o2-gui/assets/icons/folder.svg | 3 + au-o2-gui/assets/icons/freeze.svg | 7 + au-o2-gui/assets/icons/input-monitor.svg | 5 + au-o2-gui/assets/icons/insert.svg | 7 + au-o2-gui/assets/icons/io.svg | 5 + au-o2-gui/assets/icons/lock.svg | 4 + au-o2-gui/assets/icons/metronome.svg | 5 + au-o2-gui/assets/icons/mute.svg | 5 + au-o2-gui/assets/icons/pan.svg | 6 + au-o2-gui/assets/icons/paste.svg | 4 + au-o2-gui/assets/icons/pause.svg | 4 + au-o2-gui/assets/icons/play.svg | 3 + au-o2-gui/assets/icons/punch.svg | 8 + au-o2-gui/assets/icons/record-arm.svg | 4 + au-o2-gui/assets/icons/record.svg | 3 + au-o2-gui/assets/icons/redo.svg | 4 + au-o2-gui/assets/icons/remove.svg | 3 + au-o2-gui/assets/icons/rewind.svg | 4 + au-o2-gui/assets/icons/rtz.svg | 4 + au-o2-gui/assets/icons/save.svg | 5 + au-o2-gui/assets/icons/search.svg | 4 + au-o2-gui/assets/icons/send.svg | 4 + au-o2-gui/assets/icons/settings.svg | 4 + au-o2-gui/assets/icons/solo.svg | 5 + au-o2-gui/assets/icons/stop.svg | 3 + au-o2-gui/assets/icons/tool-eraser.svg | 4 + au-o2-gui/assets/icons/tool-glue.svg | 7 + au-o2-gui/assets/icons/tool-pencil.svg | 3 + au-o2-gui/assets/icons/tool-pointer.svg | 4 + au-o2-gui/assets/icons/tool-scissors.svg | 7 + au-o2-gui/assets/icons/tool-zoom.svg | 4 + au-o2-gui/assets/icons/track-audio.svg | 3 + au-o2-gui/assets/icons/track-aux.svg | 5 + au-o2-gui/assets/icons/track-bus.svg | 7 + au-o2-gui/assets/icons/track-midi.svg | 7 + au-o2-gui/assets/icons/undo.svg | 4 + au-o2-gui/assets/icons/view-clip-launcher.svg | 7 + au-o2-gui/assets/icons/view-editor.svg | 7 + au-o2-gui/assets/icons/view-inspector.svg | 5 + au-o2-gui/assets/icons/view-library.svg | 4 + au-o2-gui/assets/icons/view-mixer.svg | 8 + au-o2-gui/assets/icons/view-notepad.svg | 6 + au-o2-gui/assets/icons/view-step-seq.svg | 11 + au-o2-gui/assets/icons/view-toolbar.svg | 3 + au-o2-gui/assets/icons/view-visualizer.svg | 5 + au-o2-gui/assets/logo-placeholder 2.svg | 26 + au-o2-gui/assets/logo-placeholder.svg | 49 + au-o2-gui/src/automation.rs | 140 + au-o2-gui/src/behaviors/mod.rs | 49 + au-o2-gui/src/clipboard.rs | 18 + au-o2-gui/src/codec/error.rs | 28 + au-o2-gui/src/codec/mod.rs | 4 + au-o2-gui/src/codec/xtc.rs | 239 + au-o2-gui/src/config.rs | 179 + au-o2-gui/src/debug.rs | 70 + au-o2-gui/src/editor/automation.rs | 150 + au-o2-gui/src/editor/clip_launcher.rs | 78 + au-o2-gui/src/editor/clipboard.rs | 133 + au-o2-gui/src/editor/edit_actions.rs | 77 + au-o2-gui/src/editor/engine_tick.rs | 215 + au-o2-gui/src/editor/export.rs | 144 + au-o2-gui/src/editor/freeze.rs | 96 + au-o2-gui/src/editor/groups.rs | 88 + au-o2-gui/src/editor/helpers.rs | 20 + au-o2-gui/src/editor/init.rs | 258 + au-o2-gui/src/editor/layout.rs | 162 + au-o2-gui/src/editor/markers.rs | 36 + au-o2-gui/src/editor/midi.rs | 292 + au-o2-gui/src/editor/mod.rs | 675 ++ au-o2-gui/src/editor/module_gui.rs | 29 + au-o2-gui/src/editor/modules.rs | 82 + au-o2-gui/src/editor/redo.rs | 338 + au-o2-gui/src/editor/regions.rs | 241 + au-o2-gui/src/editor/sends.rs | 30 + au-o2-gui/src/editor/session.rs | 69 + au-o2-gui/src/editor/session_player.rs | 86 + au-o2-gui/src/editor/spatial.rs | 61 + au-o2-gui/src/editor/stems.rs | 129 + au-o2-gui/src/editor/takes.rs | 67 + au-o2-gui/src/editor/tempo_detect.rs | 112 + au-o2-gui/src/editor/timeline_events.rs | 217 + au-o2-gui/src/editor/tracks.rs | 176 + au-o2-gui/src/editor/transport.rs | 133 + au-o2-gui/src/editor/undo.rs | 315 + au-o2-gui/src/editor/view.rs | 371 + au-o2-gui/src/engine/ara.rs | 93 + au-o2-gui/src/engine/atmos.rs | 318 + au-o2-gui/src/engine/bus.rs | 192 + au-o2-gui/src/engine/contract.rs | 111 + au-o2-gui/src/engine/cycle/commands.rs | 410 + au-o2-gui/src/engine/cycle/metronome.rs | 56 + au-o2-gui/src/engine/cycle/mod.rs | 208 + au-o2-gui/src/engine/cycle/modules.rs | 153 + au-o2-gui/src/engine/cycle/process.rs | 296 + au-o2-gui/src/engine/cycle/recording.rs | 69 + au-o2-gui/src/engine/device.rs | 143 + au-o2-gui/src/engine/graph.rs | 156 + au-o2-gui/src/engine/host.rs | 172 + au-o2-gui/src/engine/io.rs | 289 + au-o2-gui/src/engine/lane.rs | 127 + au-o2-gui/src/engine/mod.rs | 241 + au-o2-gui/src/engine/onset.rs | 235 + au-o2-gui/src/engine/param.rs | 42 + au-o2-gui/src/engine/recorder.rs | 269 + au-o2-gui/src/engine/resample.rs | 135 + au-o2-gui/src/engine/schedule.rs | 360 + au-o2-gui/src/engine/session_player.rs | 415 + au-o2-gui/src/engine/spatial.rs | 154 + au-o2-gui/src/engine/stems.rs | 252 + au-o2-gui/src/export.rs | 268 + au-o2-gui/src/first_run.rs | 70 + au-o2-gui/src/gui/editor/control_bar.rs | 163 + au-o2-gui/src/gui/editor/editor_pane.rs | 45 + au-o2-gui/src/gui/editor/inspector.rs | 166 + au-o2-gui/src/gui/editor/menu_bar.rs | 395 + au-o2-gui/src/gui/editor/mixer.rs | 180 + au-o2-gui/src/gui/editor/mod.rs | 9 + au-o2-gui/src/gui/editor/new_track_wizard.rs | 80 + au-o2-gui/src/gui/editor/timeline.rs | 672 ++ au-o2-gui/src/gui/editor/toolbar.rs | 42 + au-o2-gui/src/gui/editor/track_header.rs | 135 + au-o2-gui/src/gui/editor/visualizer/mod.rs | 6 + .../gui/editor/visualizer/shaders/spiral.wgsl | 28 + au-o2-gui/src/gui/editor/visualizer/spiral.rs | 420 + au-o2-gui/src/gui/first_run_wizard.rs | 20 + au-o2-gui/src/gui/icon_button.rs | 436 + au-o2-gui/src/gui/icons.rs | 249 + au-o2-gui/src/gui/mod.rs | 11 + au-o2-gui/src/gui/native_menu.rs | 163 + au-o2-gui/src/gui/new_project.rs | 150 + au-o2-gui/src/gui/project_viewer.rs | 159 + au-o2-gui/src/gui/settings.rs | 414 + au-o2-gui/src/gui/splash.rs | 80 + au-o2-gui/src/gui/styles.rs | 30 + au-o2-gui/src/gui/time_utility.rs | 365 + au-o2-gui/src/history.rs | 109 + au-o2-gui/src/main.rs | 27 + au-o2-gui/src/module_gui_manager.rs | 227 + au-o2-gui/src/modules/mod.rs | 3 + au-o2-gui/src/modules/registry.rs | 68 + au-o2-gui/src/region.rs | 108 + au-o2-gui/src/routing.rs | 266 + au-o2-gui/src/timing.rs | 224 + au-o2-gui/src/track.rs | 217 + au-o2-gui/src/triggers.rs | 69 + au-o2-gui/src/waveform.rs | 126 + build.sh | 67 + debug.sh | 22 + icons.sh | 59 + oxforge/.gitignore | 1 + oxforge/Cargo.toml | 21 + oxforge/src/lib.rs | 2 + oxforge/src/main.rs | 160 + oxforge/src/mdk/mod.rs | 404 + oxforge/src/mdk/recording.rs | 29 + oxforge/src/mdk/types.rs | 60 + oxide-modules/hilbert/Cargo.toml | 12 + oxide-modules/hilbert/src/lib.rs | 54 + oxide-modules/hilbert/src/processor.rs | 113 + oxide-modules/input/Cargo.toml | 12 + oxide-modules/input/module.toml | 16 + oxide-modules/input/src/lib.rs | 0 oxide-modules/input_router/Cargo.toml | 11 + oxide-modules/input_router/src/lib.rs | 45 + oxide-modules/latency/.gitignore | 1 + oxide-modules/latency/Cargo.toml | 12 + oxide-modules/latency/module.toml | 28 + oxide-modules/latency/src/lib.rs | 86 + oxide-modules/output/Cargo.toml | 0 oxide-modules/output/src/lib.rs | 0 oxide-modules/output_mixer/Cargo.toml | 11 + oxide-modules/output_mixer/src/lib.rs | 34 + oxide-modules/passthrough/Cargo.toml | 13 + oxide-modules/passthrough/module.toml | 15 + oxide-modules/passthrough/src/lib.rs | 32 + oxide-modules/recorder/Cargo.toml | 12 + oxide-modules/recorder/src/lib.rs | 81 + oxide-modules/region_player/Cargo.toml | 12 + oxide-modules/region_player/src/lib.rs | 70 + oxide-modules/spiral_visualizer/Cargo.toml | 11 + oxide-modules/spiral_visualizer/src/lib.rs | 115 + run.sh | 9704 +++++++++++++++++ 198 files changed, 28516 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 au-o2-gui/.gitignore create mode 100644 au-o2-gui/Cargo.toml create mode 100644 au-o2-gui/assets/Info.plist create mode 100644 au-o2-gui/assets/icon.icns create mode 100644 au-o2-gui/assets/icon.svg create mode 100644 au-o2-gui/assets/icons/add.svg create mode 100644 au-o2-gui/assets/icons/automation.svg create mode 100644 au-o2-gui/assets/icons/close.svg create mode 100644 au-o2-gui/assets/icons/copy.svg create mode 100644 au-o2-gui/assets/icons/count-in.svg create mode 100644 au-o2-gui/assets/icons/cut.svg create mode 100644 au-o2-gui/assets/icons/cycle.svg create mode 100644 au-o2-gui/assets/icons/eq.svg create mode 100644 au-o2-gui/assets/icons/fast-forward.svg create mode 100644 au-o2-gui/assets/icons/folder.svg create mode 100644 au-o2-gui/assets/icons/freeze.svg create mode 100644 au-o2-gui/assets/icons/input-monitor.svg create mode 100644 au-o2-gui/assets/icons/insert.svg create mode 100644 au-o2-gui/assets/icons/io.svg create mode 100644 au-o2-gui/assets/icons/lock.svg create mode 100644 au-o2-gui/assets/icons/metronome.svg create mode 100644 au-o2-gui/assets/icons/mute.svg create mode 100644 au-o2-gui/assets/icons/pan.svg create mode 100644 au-o2-gui/assets/icons/paste.svg create mode 100644 au-o2-gui/assets/icons/pause.svg create mode 100644 au-o2-gui/assets/icons/play.svg create mode 100644 au-o2-gui/assets/icons/punch.svg create mode 100644 au-o2-gui/assets/icons/record-arm.svg create mode 100644 au-o2-gui/assets/icons/record.svg create mode 100644 au-o2-gui/assets/icons/redo.svg create mode 100644 au-o2-gui/assets/icons/remove.svg create mode 100644 au-o2-gui/assets/icons/rewind.svg create mode 100644 au-o2-gui/assets/icons/rtz.svg create mode 100644 au-o2-gui/assets/icons/save.svg create mode 100644 au-o2-gui/assets/icons/search.svg create mode 100644 au-o2-gui/assets/icons/send.svg create mode 100644 au-o2-gui/assets/icons/settings.svg create mode 100644 au-o2-gui/assets/icons/solo.svg create mode 100644 au-o2-gui/assets/icons/stop.svg create mode 100644 au-o2-gui/assets/icons/tool-eraser.svg create mode 100644 au-o2-gui/assets/icons/tool-glue.svg create mode 100644 au-o2-gui/assets/icons/tool-pencil.svg create mode 100644 au-o2-gui/assets/icons/tool-pointer.svg create mode 100644 au-o2-gui/assets/icons/tool-scissors.svg create mode 100644 au-o2-gui/assets/icons/tool-zoom.svg create mode 100644 au-o2-gui/assets/icons/track-audio.svg create mode 100644 au-o2-gui/assets/icons/track-aux.svg create mode 100644 au-o2-gui/assets/icons/track-bus.svg create mode 100644 au-o2-gui/assets/icons/track-midi.svg create mode 100644 au-o2-gui/assets/icons/undo.svg create mode 100644 au-o2-gui/assets/icons/view-clip-launcher.svg create mode 100644 au-o2-gui/assets/icons/view-editor.svg create mode 100644 au-o2-gui/assets/icons/view-inspector.svg create mode 100644 au-o2-gui/assets/icons/view-library.svg create mode 100644 au-o2-gui/assets/icons/view-mixer.svg create mode 100644 au-o2-gui/assets/icons/view-notepad.svg create mode 100644 au-o2-gui/assets/icons/view-step-seq.svg create mode 100644 au-o2-gui/assets/icons/view-toolbar.svg create mode 100644 au-o2-gui/assets/icons/view-visualizer.svg create mode 100644 au-o2-gui/assets/logo-placeholder 2.svg create mode 100644 au-o2-gui/assets/logo-placeholder.svg create mode 100644 au-o2-gui/src/automation.rs create mode 100644 au-o2-gui/src/behaviors/mod.rs create mode 100644 au-o2-gui/src/clipboard.rs create mode 100644 au-o2-gui/src/codec/error.rs create mode 100644 au-o2-gui/src/codec/mod.rs create mode 100644 au-o2-gui/src/codec/xtc.rs create mode 100644 au-o2-gui/src/config.rs create mode 100644 au-o2-gui/src/debug.rs create mode 100644 au-o2-gui/src/editor/automation.rs create mode 100644 au-o2-gui/src/editor/clip_launcher.rs create mode 100644 au-o2-gui/src/editor/clipboard.rs create mode 100644 au-o2-gui/src/editor/edit_actions.rs create mode 100644 au-o2-gui/src/editor/engine_tick.rs create mode 100644 au-o2-gui/src/editor/export.rs create mode 100644 au-o2-gui/src/editor/freeze.rs create mode 100644 au-o2-gui/src/editor/groups.rs create mode 100644 au-o2-gui/src/editor/helpers.rs create mode 100644 au-o2-gui/src/editor/init.rs create mode 100644 au-o2-gui/src/editor/layout.rs create mode 100644 au-o2-gui/src/editor/markers.rs create mode 100644 au-o2-gui/src/editor/midi.rs create mode 100644 au-o2-gui/src/editor/mod.rs create mode 100644 au-o2-gui/src/editor/module_gui.rs create mode 100644 au-o2-gui/src/editor/modules.rs create mode 100644 au-o2-gui/src/editor/redo.rs create mode 100644 au-o2-gui/src/editor/regions.rs create mode 100644 au-o2-gui/src/editor/sends.rs create mode 100644 au-o2-gui/src/editor/session.rs create mode 100644 au-o2-gui/src/editor/session_player.rs create mode 100644 au-o2-gui/src/editor/spatial.rs create mode 100644 au-o2-gui/src/editor/stems.rs create mode 100644 au-o2-gui/src/editor/takes.rs create mode 100644 au-o2-gui/src/editor/tempo_detect.rs create mode 100644 au-o2-gui/src/editor/timeline_events.rs create mode 100644 au-o2-gui/src/editor/tracks.rs create mode 100644 au-o2-gui/src/editor/transport.rs create mode 100644 au-o2-gui/src/editor/undo.rs create mode 100644 au-o2-gui/src/editor/view.rs create mode 100644 au-o2-gui/src/engine/ara.rs create mode 100644 au-o2-gui/src/engine/atmos.rs create mode 100644 au-o2-gui/src/engine/bus.rs create mode 100644 au-o2-gui/src/engine/contract.rs create mode 100644 au-o2-gui/src/engine/cycle/commands.rs create mode 100644 au-o2-gui/src/engine/cycle/metronome.rs create mode 100644 au-o2-gui/src/engine/cycle/mod.rs create mode 100644 au-o2-gui/src/engine/cycle/modules.rs create mode 100644 au-o2-gui/src/engine/cycle/process.rs create mode 100644 au-o2-gui/src/engine/cycle/recording.rs create mode 100644 au-o2-gui/src/engine/device.rs create mode 100644 au-o2-gui/src/engine/graph.rs create mode 100644 au-o2-gui/src/engine/host.rs create mode 100644 au-o2-gui/src/engine/io.rs create mode 100644 au-o2-gui/src/engine/lane.rs create mode 100644 au-o2-gui/src/engine/mod.rs create mode 100644 au-o2-gui/src/engine/onset.rs create mode 100644 au-o2-gui/src/engine/param.rs create mode 100644 au-o2-gui/src/engine/recorder.rs create mode 100644 au-o2-gui/src/engine/resample.rs create mode 100644 au-o2-gui/src/engine/schedule.rs create mode 100644 au-o2-gui/src/engine/session_player.rs create mode 100644 au-o2-gui/src/engine/spatial.rs create mode 100644 au-o2-gui/src/engine/stems.rs create mode 100644 au-o2-gui/src/export.rs create mode 100644 au-o2-gui/src/first_run.rs create mode 100644 au-o2-gui/src/gui/editor/control_bar.rs create mode 100644 au-o2-gui/src/gui/editor/editor_pane.rs create mode 100644 au-o2-gui/src/gui/editor/inspector.rs create mode 100644 au-o2-gui/src/gui/editor/menu_bar.rs create mode 100644 au-o2-gui/src/gui/editor/mixer.rs create mode 100644 au-o2-gui/src/gui/editor/mod.rs create mode 100644 au-o2-gui/src/gui/editor/new_track_wizard.rs create mode 100644 au-o2-gui/src/gui/editor/timeline.rs create mode 100644 au-o2-gui/src/gui/editor/toolbar.rs create mode 100644 au-o2-gui/src/gui/editor/track_header.rs create mode 100644 au-o2-gui/src/gui/editor/visualizer/mod.rs create mode 100644 au-o2-gui/src/gui/editor/visualizer/shaders/spiral.wgsl create mode 100644 au-o2-gui/src/gui/editor/visualizer/spiral.rs create mode 100644 au-o2-gui/src/gui/first_run_wizard.rs create mode 100644 au-o2-gui/src/gui/icon_button.rs create mode 100644 au-o2-gui/src/gui/icons.rs create mode 100644 au-o2-gui/src/gui/mod.rs create mode 100644 au-o2-gui/src/gui/native_menu.rs create mode 100644 au-o2-gui/src/gui/new_project.rs create mode 100644 au-o2-gui/src/gui/project_viewer.rs create mode 100644 au-o2-gui/src/gui/settings.rs create mode 100644 au-o2-gui/src/gui/splash.rs create mode 100644 au-o2-gui/src/gui/styles.rs create mode 100644 au-o2-gui/src/gui/time_utility.rs create mode 100644 au-o2-gui/src/history.rs create mode 100644 au-o2-gui/src/main.rs create mode 100644 au-o2-gui/src/module_gui_manager.rs create mode 100644 au-o2-gui/src/modules/mod.rs create mode 100644 au-o2-gui/src/modules/registry.rs create mode 100644 au-o2-gui/src/region.rs create mode 100644 au-o2-gui/src/routing.rs create mode 100644 au-o2-gui/src/timing.rs create mode 100644 au-o2-gui/src/track.rs create mode 100644 au-o2-gui/src/triggers.rs create mode 100644 au-o2-gui/src/waveform.rs create mode 100755 build.sh create mode 100755 debug.sh create mode 100755 icons.sh create mode 100644 oxforge/.gitignore create mode 100644 oxforge/Cargo.toml create mode 100644 oxforge/src/lib.rs create mode 100644 oxforge/src/main.rs create mode 100644 oxforge/src/mdk/mod.rs create mode 100644 oxforge/src/mdk/recording.rs create mode 100644 oxforge/src/mdk/types.rs create mode 100644 oxide-modules/hilbert/Cargo.toml create mode 100644 oxide-modules/hilbert/src/lib.rs create mode 100644 oxide-modules/hilbert/src/processor.rs create mode 100644 oxide-modules/input/Cargo.toml create mode 100644 oxide-modules/input/module.toml create mode 100644 oxide-modules/input/src/lib.rs create mode 100644 oxide-modules/input_router/Cargo.toml create mode 100644 oxide-modules/input_router/src/lib.rs create mode 100644 oxide-modules/latency/.gitignore create mode 100644 oxide-modules/latency/Cargo.toml create mode 100644 oxide-modules/latency/module.toml create mode 100644 oxide-modules/latency/src/lib.rs create mode 100644 oxide-modules/output/Cargo.toml create mode 100644 oxide-modules/output/src/lib.rs create mode 100644 oxide-modules/output_mixer/Cargo.toml create mode 100644 oxide-modules/output_mixer/src/lib.rs create mode 100644 oxide-modules/passthrough/Cargo.toml create mode 100644 oxide-modules/passthrough/module.toml create mode 100644 oxide-modules/passthrough/src/lib.rs create mode 100644 oxide-modules/recorder/Cargo.toml create mode 100644 oxide-modules/recorder/src/lib.rs create mode 100644 oxide-modules/region_player/Cargo.toml create mode 100644 oxide-modules/region_player/src/lib.rs create mode 100644 oxide-modules/spiral_visualizer/Cargo.toml create mode 100644 oxide-modules/spiral_visualizer/src/lib.rs create mode 100755 run.sh 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 0000000000000000000000000000000000000000..49606c82bc75964c82c512cf3bb46d0f6a888bb4 GIT binary patch literal 225313 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

^BSuz*8)5|I)ci}tqM{gm+VC^@#Mu^klVmDa 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 G=zjoNKonpA literal 0 HcmV?d00001 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

--|
$project_name|g" \ + -e "s|
--|
$project_path|g" \ + "$skill_dashboard" > .loki/dashboard/index.html + log_info "Dashboard copied from skill installation" + log_info "Project: $project_name ($project_path)" + return + fi + + # Fallback: Generate basic dashboard if external file not found + cat > .loki/dashboard/index.html << 'DASHBOARD_HTML' + + + + + + Loki Mode Dashboard + + + +
+

LOKI MODE

+
Autonomous Multi-Agent Startup System
+
Loading...
+
+
+
-
Active Agents
+
-
Pending
+
-
In Progress
+
-
Completed
+
-
Failed
+
+
Active Agents
+
+
Task Queue
+
+

Pending 0

+

In Progress 0

+

Completed 0

+

Failed 0

+
+
Last updated: -
+
Powered by ${PROVIDER_DISPLAY_NAME:-Claude}
+ + + + +DASHBOARD_HTML +} + +update_agents_state() { + # Aggregate agent information from .agent/sub-agents/*.json into .loki/state/agents.json + local agents_dir=".agent/sub-agents" + local output_file=".loki/state/agents.json" + + # Initialize empty array if no agents directory + if [ ! -d "$agents_dir" ]; then + echo "[]" > "$output_file" + return + fi + + # Find all agent JSON files and aggregate them + local agents_json="[" + local first=true + + for agent_file in "$agents_dir"/*.json; do + # Skip if no JSON files exist + [ -e "$agent_file" ] || continue + + # Read agent JSON + local agent_data=$(cat "$agent_file" 2>/dev/null) + if [ -n "$agent_data" ]; then + # Add comma separator for all but first entry + if [ "$first" = true ]; then + first=false + else + agents_json="${agents_json}," + fi + agents_json="${agents_json}${agent_data}" + fi + done + + agents_json="${agents_json}]" + + # Write aggregated data (atomic via temp file + mv) + local tmp_file="${output_file}.tmp.$$" + echo "$agents_json" > "$tmp_file" + mv -f "$tmp_file" "$output_file" 2>/dev/null || rm -f "$tmp_file" +} + +#=============================================================================== +# Resource Monitoring +#=============================================================================== + +check_system_resources() { + # Check CPU and memory usage and write status to .loki/state/resources.json + local output_file=".loki/state/resources.json" + + # Get CPU usage (average across all cores) + local cpu_usage=0 + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS: get CPU idle from top header, calculate usage = 100 - idle + local idle=$(top -l 2 -n 0 | grep "CPU usage" | tail -1 | awk -F'[:,]' '{for(i=1;i<=NF;i++) if($i ~ /idle/) print $(i)}' | awk '{print int($1)}') + cpu_usage=$((100 - ${idle:-0})) + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + # Linux: use top or mpstat + cpu_usage=$(top -bn2 | grep "Cpu(s)" | tail -1 | sed "s/.*, *\([0-9.]*\)%* id.*/\1/" | awk '{print int(100 - $1)}') + else + cpu_usage=0 + fi + + # Get memory usage + local mem_usage=0 + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS: use vm_stat + local page_size=$(pagesize) + local vm_stat=$(vm_stat) + local pages_free=$(echo "$vm_stat" | awk '/Pages free/ {print $3}' | tr -d '.') + local pages_active=$(echo "$vm_stat" | awk '/Pages active/ {print $3}' | tr -d '.') + local pages_inactive=$(echo "$vm_stat" | awk '/Pages inactive/ {print $3}' | tr -d '.') + local pages_speculative=$(echo "$vm_stat" | awk '/Pages speculative/ {print $3}' | tr -d '.') + local pages_wired=$(echo "$vm_stat" | awk '/Pages wired down/ {print $4}' | tr -d '.') + + local total_pages=$((pages_free + pages_active + pages_inactive + pages_speculative + pages_wired)) + local used_pages=$((pages_active + pages_wired)) + mem_usage=$((used_pages * 100 / total_pages)) + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + # Linux: use free + mem_usage=$(free | grep Mem | awk '{print int($3/$2 * 100)}') + else + mem_usage=0 + fi + + # Determine status + local cpu_status="ok" + local mem_status="ok" + local overall_status="ok" + local warning_message="" + + if [ "$cpu_usage" -ge "$RESOURCE_CPU_THRESHOLD" ]; then + cpu_status="high" + overall_status="warning" + warning_message="CPU usage is ${cpu_usage}% (threshold: ${RESOURCE_CPU_THRESHOLD}%). Consider reducing parallel agent count or pausing non-critical tasks." + fi + + if [ "$mem_usage" -ge "$RESOURCE_MEM_THRESHOLD" ]; then + mem_status="high" + overall_status="warning" + if [ -n "$warning_message" ]; then + warning_message="${warning_message} Memory usage is ${mem_usage}% (threshold: ${RESOURCE_MEM_THRESHOLD}%)." + else + warning_message="Memory usage is ${mem_usage}% (threshold: ${RESOURCE_MEM_THRESHOLD}%). Consider reducing parallel agent count or cleaning up resources." + fi + fi + + # Write JSON status + cat > "$output_file" << EOF +{ + "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "cpu": { + "usage_percent": $cpu_usage, + "threshold_percent": $RESOURCE_CPU_THRESHOLD, + "status": "$cpu_status" + }, + "memory": { + "usage_percent": $mem_usage, + "threshold_percent": $RESOURCE_MEM_THRESHOLD, + "status": "$mem_status" + }, + "overall_status": "$overall_status", + "warning_message": "$warning_message" +} +EOF + + # Log warning if resources are high + if [ "$overall_status" = "warning" ]; then + log_warn "RESOURCE WARNING: $warning_message" + fi +} + +start_resource_monitor() { + log_step "Starting resource monitor (checks every ${RESOURCE_CHECK_INTERVAL}s)..." + + # Initial check + check_system_resources + + # Background monitoring loop + ( + while true; do + sleep "$RESOURCE_CHECK_INTERVAL" + check_system_resources + done + ) & + RESOURCE_MONITOR_PID=$! + register_pid "$RESOURCE_MONITOR_PID" "resource-monitor" + + log_info "Resource monitor started (CPU threshold: ${RESOURCE_CPU_THRESHOLD}%, Memory threshold: ${RESOURCE_MEM_THRESHOLD}%)" + log_info "Check status: ${CYAN}cat .loki/state/resources.json${NC}" +} + +stop_resource_monitor() { + if [ -n "$RESOURCE_MONITOR_PID" ]; then + kill "$RESOURCE_MONITOR_PID" 2>/dev/null || true + wait "$RESOURCE_MONITOR_PID" 2>/dev/null || true + unregister_pid "$RESOURCE_MONITOR_PID" + fi +} + +#=============================================================================== +# Audit Logging (Enterprise Security) +#=============================================================================== + +audit_log() { + # Log security-relevant events for enterprise compliance + local event_type="$1" + local event_data="$2" + local audit_file=".loki/logs/audit-$(date +%Y%m%d).jsonl" + + if [ "$AUDIT_LOG_ENABLED" != "true" ]; then + return + fi + + mkdir -p .loki/logs + + local log_entry + if command -v jq >/dev/null 2>&1; then + log_entry=$(jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg evt "$event_type" --arg data "$event_data" --arg user "$(whoami)" --argjson pid "$$" '{timestamp:$ts,event:$evt,data:$data,user:$user,pid:$pid}') + else + local safe_type safe_data + safe_type=$(printf '%s' "$event_type" | sed 's/["\\]/\\&/g; s/\n/\\n/g') + safe_data=$(printf '%s' "$event_data" | sed 's/["\\]/\\&/g; s/\n/\\n/g') + log_entry="{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"$safe_type\",\"data\":\"$safe_data\",\"user\":\"$(whoami)\",\"pid\":$$}" + fi + echo "$log_entry" >> "$audit_file" +} + +#=============================================================================== +# Branch Protection for Agent Changes +#=============================================================================== + +setup_agent_branch() { + # Create an isolated feature branch for agent changes. + # This prevents agents from committing directly to the main branch. + # Controlled by LOKI_BRANCH_PROTECTION env var (default: false). + local branch_protection="${LOKI_BRANCH_PROTECTION:-false}" + + if [ "$branch_protection" != "true" ]; then + log_info "Branch protection disabled (LOKI_BRANCH_PROTECTION=${branch_protection})" + return 0 + fi + + # Ensure we are inside a git repository + if ! git rev-parse --is-inside-work-tree &>/dev/null; then + log_warn "Not a git repository - skipping branch protection" + return 0 + fi + + local timestamp + timestamp=$(date +%s) + local branch_name="loki/session-${timestamp}-$$" + + log_info "Branch protection enabled - creating agent branch: $branch_name" + + # Create and checkout the feature branch + if ! git checkout -b "$branch_name" 2>/dev/null; then + log_error "Failed to create agent branch: $branch_name" + return 1 + fi + + # Store the branch name for later use (PR creation, cleanup) + mkdir -p .loki/state + echo "$branch_name" > .loki/state/agent-branch.txt + + log_info "Agent branch created: $branch_name" + audit_log "BRANCH_PROTECTION" "branch=$branch_name" + echo "$branch_name" +} + +create_session_pr() { + # Push the agent branch and create a PR if gh CLI is available. + # Called during session cleanup to submit agent changes for review. + local branch_file=".loki/state/agent-branch.txt" + + if [ ! -f "$branch_file" ]; then + # No agent branch was created (branch protection was off) + return 0 + fi + + local branch_name + branch_name=$(cat "$branch_file" 2>/dev/null) + + if [ -z "$branch_name" ]; then + return 0 + fi + + log_info "Pushing agent branch: $branch_name" + + # Check if there are any commits on this branch beyond the base + local commit_count + commit_count=$(git rev-list --count HEAD ^"$(git merge-base HEAD main 2>/dev/null || echo HEAD)" 2>/dev/null || echo "0") + + if [ "$commit_count" = "0" ]; then + log_info "No commits on agent branch - skipping PR creation" + return 0 + fi + + # Push the branch + if ! git push -u origin "$branch_name" 2>/dev/null; then + log_warn "Failed to push agent branch: $branch_name" + return 1 + fi + + # Create PR if gh CLI is available + if command -v gh &>/dev/null; then + local pr_url + pr_url=$(gh pr create \ + --title "Loki Mode: Agent session changes ($branch_name)" \ + --body "Automated changes from Loki Mode agent session. + +Branch: \`$branch_name\` +Session PID: $$ +Created: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --head "$branch_name" 2>/dev/null) || true + + if [ -n "$pr_url" ]; then + log_info "PR created: $pr_url" + audit_log "PR_CREATED" "branch=$branch_name,url=$pr_url" + else + log_warn "Failed to create PR - branch pushed to: $branch_name" + fi + else + log_info "gh CLI not available - branch pushed to: $branch_name" + log_info "Create a PR manually for branch: $branch_name" + fi +} + +#=============================================================================== +# Agent Action Auditing +#=============================================================================== + +audit_agent_action() { + # Record agent actions to a JSONL audit trail. + # Fire-and-forget: errors are silently ignored to avoid blocking execution. + # Args: action_type, description, [details] + local action_type="${1:-unknown}" + local description="${2:-}" + local details="${3:-}" + local audit_file=".loki/logs/agent-audit.jsonl" + + ( + mkdir -p .loki/logs 2>/dev/null + + # Requires python3 for JSON formatting; skip silently if unavailable + command -v python3 &>/dev/null || exit 0 + + local timestamp + timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) + local iter="${ITERATION_COUNT:-0}" + local pid="$$" + + python3 -c " +import json, sys +entry = { + 'timestamp': sys.argv[1], + 'action': sys.argv[2], + 'description': sys.argv[3], + 'details': sys.argv[4], + 'iteration': int(sys.argv[5]), + 'pid': int(sys.argv[6]) +} +print(json.dumps(entry)) +" "$timestamp" "$action_type" "$description" "$details" "$iter" "$pid" >> "$audit_file" 2>/dev/null + ) & +} + +check_staged_autonomy() { + # In staged autonomy mode, write plan and wait for approval + local plan_file="$1" + + if [ "$STAGED_AUTONOMY" != "true" ]; then + return 0 + fi + + log_info "STAGED AUTONOMY: Waiting for plan approval..." + log_info "Review plan at: $plan_file" + log_info "Create .loki/signals/PLAN_APPROVED to continue" + + audit_log "STAGED_AUTONOMY_WAIT" "plan=$plan_file" + + # Wait for approval signal + while [ ! -f ".loki/signals/PLAN_APPROVED" ]; do + sleep 5 + done + + rm -f ".loki/signals/PLAN_APPROVED" + audit_log "STAGED_AUTONOMY_APPROVED" "plan=$plan_file" + log_info "Plan approved, continuing execution..." +} + +check_command_allowed() { + # Check if a command string contains any blocked patterns from BLOCKED_COMMANDS. + # + # SECURITY NOTE: This function is intentionally NOT called by run.sh because + # run.sh does not directly execute arbitrary shell commands from user or agent + # input. Command execution is handled by the AI CLI's own permission model: + # - Claude Code: --dangerously-skip-permissions (with its own allowlist) + # - Codex CLI: --full-auto or exec --dangerously-bypass-approvals-and-sandbox + # - Gemini CLI: --approval-mode=yolo + # + # HUMAN_INPUT.md content is injected as a text prompt to the AI agent (not + # executed as a shell command), and is already guarded by: + # - LOKI_PROMPT_INJECTION=false by default (disabled unless explicitly enabled) + # - Symlink rejection (prevents path traversal attacks) + # - 1MB file size limit + # + # This function is retained as a utility for external callers (sandbox.sh, + # custom hooks, or user scripts) that may need to validate commands against + # the BLOCKED_COMMANDS list before execution. + local command="$1" + + IFS=',' read -ra BLOCKED_ARRAY <<< "$BLOCKED_COMMANDS" + for blocked in "${BLOCKED_ARRAY[@]}"; do + if [[ "$command" == *"$blocked"* ]]; then + audit_log "BLOCKED_COMMAND" "command=$command,pattern=$blocked" + log_error "SECURITY: Blocked dangerous command: $command" + return 1 + fi + done + + return 0 +} + +#=============================================================================== +# Cross-Project Learnings Database +#=============================================================================== + +init_learnings_db() { + # Initialize the cross-project learnings database + local learnings_dir="${HOME}/.loki/learnings" + mkdir -p "$learnings_dir" + + # Create database files if they don't exist + if [ ! -f "$learnings_dir/patterns.jsonl" ]; then + echo '{"version":"1.0","created":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' > "$learnings_dir/patterns.jsonl" + fi + + if [ ! -f "$learnings_dir/mistakes.jsonl" ]; then + echo '{"version":"1.0","created":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' > "$learnings_dir/mistakes.jsonl" + fi + + if [ ! -f "$learnings_dir/successes.jsonl" ]; then + echo '{"version":"1.0","created":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' > "$learnings_dir/successes.jsonl" + fi + + log_info "Learnings database initialized at: $learnings_dir" +} + +save_learning() { + # Save a learning to the cross-project database + local learning_type="$1" # pattern, mistake, success + local category="$2" + local description="$3" + local project="${4:-$(basename "$(pwd)")}" + + local learnings_dir="${HOME}/.loki/learnings" + local target_file="$learnings_dir/${learning_type}s.jsonl" + + if [ ! -d "$learnings_dir" ]; then + init_learnings_db + fi + + local learning_entry + if command -v jq >/dev/null 2>&1; then + learning_entry=$(jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg proj "$project" --arg cat "$category" --arg desc "$description" '{timestamp:$ts,project:$proj,category:$cat,description:$desc}') + else + local safe_proj safe_cat safe_desc + safe_proj=$(printf '%s' "$project" | sed 's/["\\]/\\&/g; s/\n/\\n/g') + safe_cat=$(printf '%s' "$category" | sed 's/["\\]/\\&/g; s/\n/\\n/g') + safe_desc=$(printf '%s' "$description" | sed 's/["\\]/\\&/g; s/\n/\\n/g') + learning_entry="{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":\"$safe_proj\",\"category\":\"$safe_cat\",\"description\":\"$safe_desc\"}" + fi + echo "$learning_entry" >> "$target_file" + log_info "Saved $learning_type: $category" +} + +get_relevant_learnings() { + # Get learnings relevant to the current context + local context="$1" + local learnings_dir="${HOME}/.loki/learnings" + local output_file=".loki/state/relevant-learnings.json" + + if [ ! -d "$learnings_dir" ]; then + echo '{"patterns":[],"mistakes":[],"successes":[]}' > "$output_file" + return + fi + + # Simple grep-based relevance (can be enhanced with embeddings) + # Pass context via environment variable to avoid quote escaping issues + export LOKI_CONTEXT="$context" + python3 << 'LEARNINGS_SCRIPT' +import json +import os + +learnings_dir = os.path.expanduser("~/.loki/learnings") +context = os.environ.get("LOKI_CONTEXT", "").lower() + +def load_jsonl(filepath): + entries = [] + try: + with open(filepath, 'r') as f: + for line in f: + try: + entry = json.loads(line) + if 'description' in entry: + entries.append(entry) + except: + continue + except: + pass + return entries + +def filter_relevant(entries, context, limit=5): + scored = [] + for e in entries: + desc = e.get('description', '').lower() + cat = e.get('category', '').lower() + score = sum(1 for word in context.split() if word in desc or word in cat) + if score > 0: + scored.append((score, e)) + scored.sort(reverse=True, key=lambda x: x[0]) + return [e for _, e in scored[:limit]] + +patterns = load_jsonl(f"{learnings_dir}/patterns.jsonl") +mistakes = load_jsonl(f"{learnings_dir}/mistakes.jsonl") +successes = load_jsonl(f"{learnings_dir}/successes.jsonl") + +result = { + "patterns": filter_relevant(patterns, context), + "mistakes": filter_relevant(mistakes, context), + "successes": filter_relevant(successes, context) +} + +with open(".loki/state/relevant-learnings.json", 'w') as f: + json.dump(result, f, indent=2) +LEARNINGS_SCRIPT + + log_info "Loaded relevant learnings to: $output_file" +} + +extract_learnings_from_session() { + # Extract learnings from completed session + local continuity_file=".loki/CONTINUITY.md" + + if [ ! -f "$continuity_file" ]; then + return + fi + + log_info "Extracting learnings from session..." + + # Parse CONTINUITY.md for all learning types + python3 << 'EXTRACT_SCRIPT' +import re +import json +import os +import hashlib +from datetime import datetime, timezone + +continuity_file = ".loki/CONTINUITY.md" +learnings_dir = os.path.expanduser("~/.loki/learnings") +os.makedirs(learnings_dir, exist_ok=True) + +if not os.path.exists(continuity_file): + exit(0) + +with open(continuity_file, 'r') as f: + content = f.read() + +project = os.path.basename(os.getcwd()) +timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + +def normalize_for_hash(text): + """Normalize text for consistent hashing (case-insensitive, trimmed)""" + return text.strip().lower() + +def get_existing_hashes(filepath): + """Get hashes of existing entries to avoid duplicates""" + hashes = set() + if os.path.exists(filepath): + with open(filepath, 'r') as f: + for line in f: + try: + entry = json.loads(line) + if 'description' in entry: + normalized = normalize_for_hash(entry['description']) + h = hashlib.md5(normalized.encode()).hexdigest() + hashes.add(h) + except: + continue + return hashes + +def save_entries(filepath, entries, category): + """Save entries avoiding duplicates (case-insensitive)""" + existing = get_existing_hashes(filepath) + saved = 0 + with open(filepath, 'a') as f: + for desc in entries: + # Normalize for deduplication + normalized = normalize_for_hash(desc) + h = hashlib.md5(normalized.encode()).hexdigest() + if h not in existing: + entry = { + "timestamp": timestamp, + "project": project, + "category": category, + "description": desc.strip() + } + f.write(json.dumps(entry) + "\n") + existing.add(h) + saved += 1 + return saved + +def extract_bullets(text): + """Extract bullet points from text""" + return [b.strip() for b in re.findall(r'[-*]\s+(.+)', text) if b.strip()] + +def extract_numbered_items(text): + """Extract numbered list items""" + return [b.strip() for b in re.findall(r'\d+\.\s+(.+)', text) if b.strip()] + +# === Extract Mistakes & Learnings === +mistakes = [] + +# From ## Mistakes & Learnings section +mistakes_match = re.search(r'## Mistakes & Learnings\n(.*?)(?=\n## |\Z)', content, re.DOTALL) +if mistakes_match: + mistakes.extend(extract_bullets(mistakes_match.group(1))) + +# From ## Challenges Encountered section +challenges_match = re.search(r'## Challenges Encountered\n(.*?)(?=\n## |\Z)', content, re.DOTALL) +if challenges_match: + mistakes.extend(extract_bullets(challenges_match.group(1))) + +if mistakes: + saved = save_entries(f"{learnings_dir}/mistakes.jsonl", mistakes, "session") + if saved > 0: + print(f"Extracted {saved} new mistakes") + +# === Extract Patterns (learnings, insights, approaches) === +patterns = [] + +# From **Learnings:** sections (most valuable source!) +for match in re.finditer(r'\*\*Learnings:\*\*\n(.*?)(?=\n\*\*|\n###|\n##|\Z)', content, re.DOTALL): + patterns.extend(extract_bullets(match.group(1))) + +# From ## Architecture Decisions section +arch_match = re.search(r'## Architecture Decisions\n(.*?)(?=\n## |\Z)', content, re.DOTALL) +if arch_match: + patterns.extend(extract_bullets(arch_match.group(1))) + +# From ## Patterns Used, ## Solutions Applied sections (if they exist) +for pattern_regex in [ + r'## Patterns Used\n(.*?)(?=\n## |\Z)', + r'## Solutions Applied\n(.*?)(?=\n## |\Z)', + r'## Key Approaches\n(.*?)(?=\n## |\Z)', +]: + match = re.search(pattern_regex, content, re.DOTALL) + if match: + patterns.extend(extract_bullets(match.group(1))) + +# Also extract inline mentions +patterns.extend(re.findall(r'(?:Pattern|Solution|Approach|Fix Applied):\s*(.+)', content)) + +if patterns: + saved = save_entries(f"{learnings_dir}/patterns.jsonl", patterns, "session") + if saved > 0: + print(f"Extracted {saved} new patterns") + +# === Extract Successes (completed tasks) === +successes = [] + +# From **Completed:** sections (numbered lists) +for match in re.finditer(r'\*\*Completed:\*\*\n(.*?)(?=\n\*\*|\n###|\n##|\Z)', content, re.DOTALL): + successes.extend(extract_numbered_items(match.group(1))) + successes.extend(extract_bullets(match.group(1))) + +# From ## Completed Tasks, ## Achievements sections (if they exist) +for pattern_regex in [ + r'## Completed Tasks\n(.*?)(?=\n## |\Z)', + r'## Achievements\n(.*?)(?=\n## |\Z)', + r'## Done\n(.*?)(?=\n## |\Z)', +]: + match = re.search(pattern_regex, content, re.DOTALL) + if match: + successes.extend(extract_bullets(match.group(1))) + +# Extract [x] completed checkboxes +successes.extend(re.findall(r'\[x\]\s+(.+)', content, re.IGNORECASE)) + +# From ## Session Summary sections (key accomplishments) +for match in re.finditer(r'## Session \d+ Summary.*?\n(.*?)(?=\n## |\Z)', content, re.DOTALL): + successes.extend(extract_bullets(match.group(1))) + +if successes: + saved = save_entries(f"{learnings_dir}/successes.jsonl", successes, "session") + if saved > 0: + print(f"Extracted {saved} new successes") + +print("Learning extraction complete") +EXTRACT_SCRIPT +} + +# ============================================================================ +# Session Continuity - Automatic CONTINUITY.md Management +# Creates/updates .loki/CONTINUITY.md with structured working memory +# so agents can cheaply load session context (<500 tokens / ~2KB) +# ============================================================================ + +update_continuity() { + local continuity_file=".loki/CONTINUITY.md" + local iteration="${ITERATION_COUNT:-0}" + local provider="${PROVIDER_NAME:-claude}" + local phase="" + + # Read current phase from orchestrator state + if [ -f ".loki/state/orchestrator.json" ]; then + phase=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('currentPhase', 'BOOTSTRAP'))" 2>/dev/null || echo "BOOTSTRAP") + else + phase="BOOTSTRAP" + fi + + # Calculate elapsed time from orchestrator startedAt + local elapsed="0m" + if [ -f ".loki/state/orchestrator.json" ]; then + local started_at + started_at=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('startedAt', ''))" 2>/dev/null || echo "") + if [ -n "$started_at" ]; then + local elapsed_secs + export _CONT_STARTED_AT="$started_at" + elapsed_secs=$(python3 << 'ELAPSED_CALC' +import os +from datetime import datetime, timezone +try: + sa = os.environ["_CONT_STARTED_AT"] + start = datetime.fromisoformat(sa.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + print(int((now - start).total_seconds())) +except Exception: + print(0) +ELAPSED_CALC +) + elapsed_secs="${elapsed_secs:-0}" + unset _CONT_STARTED_AT + elapsed=$(format_duration "$elapsed_secs") + fi + fi + + # Get RARV phase name + local rarv_phase="" + if [ "$iteration" -gt 0 ]; then + rarv_phase=$(get_rarv_phase_name "$iteration") + fi + + # Use python3 with env vars (no shell interpolation into Python code) + export _CONT_FILE="$continuity_file" + export _CONT_ITERATION="$iteration" + export _CONT_PHASE="$phase" + export _CONT_PROVIDER="$provider" + export _CONT_ELAPSED="$elapsed" + export _CONT_RARV="$rarv_phase" + + python3 << 'CONTINUITY_SCRIPT' +import json +import os +from datetime import datetime, timezone + +cont_file = os.environ["_CONT_FILE"] +iteration = os.environ["_CONT_ITERATION"] +phase = os.environ["_CONT_PHASE"] +provider = os.environ["_CONT_PROVIDER"] +elapsed = os.environ["_CONT_ELAPSED"] +rarv = os.environ.get("_CONT_RARV", "") +timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + +sections = [] +sections.append(f"# Session Continuity\n\nUpdated: {timestamp}\n") + +# Current State +state_lines = [f"- Iteration: {iteration}"] +if phase: + state_lines.append(f"- Phase: {phase}") +if rarv: + state_lines.append(f"- RARV Step: {rarv}") +state_lines.append(f"- Provider: {provider}") +state_lines.append(f"- Elapsed: {elapsed}") +sections.append("## Current State\n\n" + "\n".join(state_lines) + "\n") + +# Last Completed Task - from last git commit +last_task_lines = [] +try: + import subprocess + result = subprocess.run( + ["git", "log", "-1", "--pretty=format:%s", "--no-merges"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0 and result.stdout.strip(): + last_task_lines.append(f"- Last commit: {result.stdout.strip()[:120]}") + files_result = subprocess.run( + ["git", "diff", "--name-only", "HEAD~1", "HEAD"], + capture_output=True, text=True, timeout=5 + ) + if files_result.returncode == 0 and files_result.stdout.strip(): + changed = files_result.stdout.strip().split("\n")[:5] + last_task_lines.append(f"- Files changed: {', '.join(changed)}") + if len(files_result.stdout.strip().split("\n")) > 5: + last_task_lines.append(f" (+{len(files_result.stdout.strip().split(chr(10))) - 5} more)") +except Exception: + pass +if not last_task_lines: + last_task_lines.append("- No commits yet") +sections.append("## Last Completed Task\n\n" + "\n".join(last_task_lines) + "\n") + +# Active Blockers +blocker_lines = [] +blocked_file = ".loki/queue/blocked.json" +if os.path.exists(blocked_file): + try: + with open(blocked_file) as f: + blocked = json.load(f) + if isinstance(blocked, dict): + blocked = blocked.get("tasks", []) + for b in blocked[:3]: + title = b.get("title", b.get("id", "unknown")) + reason = b.get("reason", b.get("description", "")) + line = f"- {title}" + if reason: + line += f": {reason[:80]}" + blocker_lines.append(line) + except Exception: + pass +if not blocker_lines: + blocker_lines.append("- None") +sections.append("## Active Blockers\n\n" + "\n".join(blocker_lines) + "\n") + +# Next Up - top 3 from pending queue +next_lines = [] +pending_file = ".loki/queue/pending.json" +if os.path.exists(pending_file): + try: + with open(pending_file) as f: + pending = json.load(f) + if isinstance(pending, dict): + pending = pending.get("tasks", []) + for t in pending[:3]: + title = t.get("title", t.get("id", "unknown")) + next_lines.append(f"- {title}") + except Exception: + pass +if not next_lines: + next_lines.append("- No pending tasks") +sections.append("## Next Up\n\n" + "\n".join(next_lines) + "\n") + +# Key Decisions - from memory timeline (last 5) +decision_lines = [] +timeline_file = ".loki/memory/timeline.json" +if os.path.exists(timeline_file): + try: + with open(timeline_file) as f: + timeline = json.load(f) + decisions = [] + if isinstance(timeline, list): + for entry in timeline: + if entry.get("type") == "key_decision" or "decision" in entry.get("type", ""): + decisions.append(entry) + elif "key_decisions" in entry: + for d in entry["key_decisions"]: + decisions.append(d if isinstance(d, dict) else {"description": str(d)}) + elif isinstance(timeline, dict) and "key_decisions" in timeline: + decisions = timeline["key_decisions"] + for d in decisions[-5:]: + desc = d.get("description", d.get("title", d.get("summary", str(d)))) + if isinstance(desc, str): + decision_lines.append(f"- {desc[:100]}") + except Exception: + pass +if not decision_lines: + decision_lines.append("- None recorded yet") +sections.append("## Key Decisions This Session\n\n" + "\n".join(decision_lines) + "\n") + +# Write the file (overwrite each time to keep it fresh) +os.makedirs(os.path.dirname(cont_file) if os.path.dirname(cont_file) else ".", exist_ok=True) +with open(cont_file, "w") as f: + f.write("\n".join(sections)) +CONTINUITY_SCRIPT + + # Clean up exported env vars + unset _CONT_FILE _CONT_ITERATION _CONT_PHASE _CONT_PROVIDER _CONT_ELAPSED _CONT_RARV + + log_info "Updated session continuity: $continuity_file" +} + +# ============================================================================ +# Knowledge Compounding - Structured Solutions (v5.30.0) +# Inspired by Compound Engineering Plugin's docs/solutions/ with YAML frontmatter +# ============================================================================ + +compound_session_to_solutions() { + # Compound JSONL learnings into structured solution markdown files + local learnings_dir="${HOME}/.loki/learnings" + local solutions_dir="${HOME}/.loki/solutions" + + if [ ! -d "$learnings_dir" ]; then + return + fi + + log_info "Compounding learnings into structured solutions..." + + python3 << 'COMPOUND_SCRIPT' +import json +import os +import re +import hashlib +from datetime import datetime, timezone +from collections import defaultdict + +learnings_dir = os.path.expanduser("~/.loki/learnings") +solutions_dir = os.path.expanduser("~/.loki/solutions") + +# Fixed categories +CATEGORIES = ["security", "performance", "architecture", "testing", "debugging", "deployment", "general"] + +# Category keyword mapping +CATEGORY_KEYWORDS = { + "security": ["auth", "login", "password", "token", "injection", "xss", "csrf", "cors", "secret", "encrypt", "permission", "role", "session", "cookie", "oauth", "jwt"], + "performance": ["cache", "query", "n+1", "memory", "leak", "slow", "timeout", "pool", "index", "optimize", "bundle", "lazy", "render", "batch"], + "architecture": ["pattern", "solid", "coupling", "abstraction", "module", "interface", "design", "refactor", "structure", "layer", "separation", "dependency"], + "testing": ["test", "mock", "fixture", "coverage", "assert", "spec", "e2e", "playwright", "jest", "flaky", "snapshot"], + "debugging": ["debug", "error", "trace", "log", "stack", "crash", "exception", "breakpoint", "inspect", "diagnose"], + "deployment": ["deploy", "docker", "ci", "cd", "pipeline", "kubernetes", "k8s", "nginx", "ssl", "domain", "env", "config", "build"], +} + +def load_jsonl(filepath): + entries = [] + if not os.path.exists(filepath): + return entries + with open(filepath, 'r') as f: + for line in f: + try: + entry = json.loads(line) + if 'description' in entry: + entries.append(entry) + except: + continue + return entries + +def classify_category(description): + desc_lower = description.lower() + scores = {} + for cat, keywords in CATEGORY_KEYWORDS.items(): + scores[cat] = sum(1 for kw in keywords if kw in desc_lower) + best = max(scores, key=scores.get) + return best if scores[best] > 0 else "general" + +def slugify(text): + slug = re.sub(r'[^a-z0-9]+', '-', text.lower().strip()) + return slug.strip('-')[:80] + +def solution_exists(solutions_dir, title_slug): + for cat in CATEGORIES: + cat_dir = os.path.join(solutions_dir, cat) + if os.path.exists(cat_dir): + if os.path.exists(os.path.join(cat_dir, f"{title_slug}.md")): + return True + return False + +# Load all learnings +patterns = load_jsonl(os.path.join(learnings_dir, "patterns.jsonl")) +mistakes = load_jsonl(os.path.join(learnings_dir, "mistakes.jsonl")) +successes = load_jsonl(os.path.join(learnings_dir, "successes.jsonl")) + +# Group by category +grouped = defaultdict(list) +for entry in patterns + mistakes + successes: + cat = classify_category(entry.get('description', '')) + grouped[cat].append(entry) + +created = 0 +now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + +for category, entries in grouped.items(): + if len(entries) < 2: + continue # Need at least 2 related entries to compound + + # Create category directory + cat_dir = os.path.join(solutions_dir, category) + os.makedirs(cat_dir, exist_ok=True) + + # Group similar entries (simple: by shared keywords) + # Take the most descriptive entry as the title + best_entry = max(entries, key=lambda e: len(e.get('description', ''))) + title = best_entry['description'][:120] + slug = slugify(title) + + if solution_exists(solutions_dir, slug): + continue # Already compounded + + # Extract tags from all entries + all_words = ' '.join(e.get('description', '') for e in entries).lower() + tags = [] + for kw_list in CATEGORY_KEYWORDS.values(): + for kw in kw_list: + if kw in all_words and kw not in tags: + tags.append(kw) + tags = tags[:8] # Limit to 8 tags + + # Build symptoms from mistake entries + symptoms = [] + for e in entries: + desc = e.get('description', '') + if any(w in desc.lower() for w in ['error', 'fail', 'bug', 'crash', 'issue', 'problem']): + symptoms.append(desc[:200]) + symptoms = symptoms[:4] + if not symptoms: + symptoms = [entries[0].get('description', '')[:200]] + + # Build solution content from pattern/success entries + solution_lines = [] + for e in entries: + desc = e.get('description', '') + if not any(w in desc.lower() for w in ['error', 'fail', 'bug', 'crash']): + solution_lines.append(f"- {desc}") + if not solution_lines: + solution_lines = [f"- {entries[0].get('description', '')}"] + + project = best_entry.get('project', os.path.basename(os.getcwd())) + + # Write solution file + filepath = os.path.join(cat_dir, f"{slug}.md") + with open(filepath, 'w') as f: + f.write(f"---\n") + f.write(f'title: "{title}"\n') + f.write(f"category: {category}\n") + f.write(f"tags: [{', '.join(tags)}]\n") + f.write(f"symptoms:\n") + for s in symptoms: + f.write(f' - "{s}"\n') + f.write(f'root_cause: "Identified from {len(entries)} related learnings across sessions"\n') + f.write(f'prevention: "See solution details below"\n') + f.write(f"confidence: {min(0.5 + 0.1 * len(entries), 0.95):.2f}\n") + f.write(f'source_project: "{project}"\n') + f.write(f'created: "{now}"\n') + f.write(f"applied_count: 0\n") + f.write(f"---\n\n") + f.write(f"## Solution\n\n") + f.write('\n'.join(solution_lines) + '\n\n') + f.write(f"## Context\n\n") + f.write(f"Compounded from {len(entries)} learnings ") + f.write(f"({len([e for e in entries if e in patterns])} patterns, ") + f.write(f"{len([e for e in entries if e in mistakes])} mistakes, ") + f.write(f"{len([e for e in entries if e in successes])} successes) ") + f.write(f"from project: {project}\n") + + created += 1 + +if created > 0: + print(f"Compounded {created} new solution files to {solutions_dir}") +else: + print("No new solutions to compound (need 2+ related learnings per category)") +COMPOUND_SCRIPT +} + + +# ============================================================================ +# Hard Quality Gate: Static Analysis (v6.7.0) +# Detects project type and runs appropriate linter on changed files +# Results stored in .loki/quality/static-analysis.json +# ============================================================================ + +enforce_static_analysis() { + local loki_dir="${TARGET_DIR:-.}/.loki" + local quality_dir="$loki_dir/quality" + mkdir -p "$quality_dir" "$loki_dir/signals" + + local changed_files + changed_files=$(git -C "${TARGET_DIR:-.}" diff --name-only HEAD~1 2>/dev/null || \ + git -C "${TARGET_DIR:-.}" diff --name-only --cached 2>/dev/null || echo "") + if [ -z "$changed_files" ]; then + log_info "Static analysis: no changed files to check" + touch "$quality_dir/static-analysis.pass" + return 0 + fi + + local findings=0 + local total_checked=0 + local details="" + + # JavaScript/TypeScript + local js_files + js_files=$(echo "$changed_files" | grep -E '\.(js|ts|jsx|tsx)$' || true) + if [ -n "$js_files" ]; then + local abs_files="" + for f in $js_files; do + [ -f "${TARGET_DIR:-.}/$f" ] && abs_files="$abs_files ${TARGET_DIR:-.}/$f" + done + if [ -n "$abs_files" ]; then + total_checked=$((total_checked + $(echo "$abs_files" | wc -w))) + if [ -f "${TARGET_DIR:-.}/.eslintrc.js" ] || [ -f "${TARGET_DIR:-.}/.eslintrc.json" ] || \ + [ -f "${TARGET_DIR:-.}/eslint.config.js" ] || [ -f "${TARGET_DIR:-.}/eslint.config.mjs" ]; then + local eslint_out + # shellcheck disable=SC2086 + eslint_out=$(cd "${TARGET_DIR:-.}" && npx eslint $js_files 2>&1) || { + findings=$((findings + 1)) + details="${details}ESLint: $(echo "$eslint_out" | tail -3 | tr '\n' ' '). " + } + else + for f in $abs_files; do + node --check "$f" 2>&1 || { + findings=$((findings + 1)) + details="${details}Syntax error: $f. " + } + done + fi + fi + fi + + # Python + local py_files + py_files=$(echo "$changed_files" | grep -E '\.py$' || true) + if [ -n "$py_files" ]; then + for f in $py_files; do + [ -f "${TARGET_DIR:-.}/$f" ] || continue + total_checked=$((total_checked + 1)) + python3 -m py_compile "${TARGET_DIR:-.}/$f" 2>&1 || { + findings=$((findings + 1)) + details="${details}py_compile failed: $f. " + } + done + if command -v ruff &>/dev/null; then + local ruff_files="" + for f in $py_files; do + [ -f "${TARGET_DIR:-.}/$f" ] && ruff_files="$ruff_files ${TARGET_DIR:-.}/$f" + done + if [ -n "$ruff_files" ]; then + # shellcheck disable=SC2086 + ruff check $ruff_files 2>&1 || { + findings=$((findings + 1)) + details="${details}Ruff check found issues. " + } + fi + fi + fi + + # Shell scripts + local sh_files + sh_files=$(echo "$changed_files" | grep -E '\.sh$' || true) + if [ -n "$sh_files" ]; then + for f in $sh_files; do + [ -f "${TARGET_DIR:-.}/$f" ] || continue + total_checked=$((total_checked + 1)) + bash -n "${TARGET_DIR:-.}/$f" 2>&1 || { + findings=$((findings + 1)) + details="${details}Syntax error: $f. " + } + done + if command -v shellcheck &>/dev/null; then + for f in $sh_files; do + [ -f "${TARGET_DIR:-.}/$f" ] || continue + shellcheck "${TARGET_DIR:-.}/$f" 2>&1 || { + findings=$((findings + 1)) + details="${details}shellcheck: $f. " + } + done + fi + fi + + # Go + if [ -f "${TARGET_DIR:-.}/go.mod" ]; then + local go_files + go_files=$(echo "$changed_files" | grep -E '\.go$' || true) + if [ -n "$go_files" ] && command -v go &>/dev/null; then + total_checked=$((total_checked + $(echo "$go_files" | wc -w))) + (cd "${TARGET_DIR:-.}" && go vet ./... 2>&1) || { + findings=$((findings + 1)) + details="${details}go vet found issues. " + } + fi + fi + + # Rust + if [ -f "${TARGET_DIR:-.}/Cargo.toml" ] && command -v cargo &>/dev/null; then + total_checked=$((total_checked + 1)) + (cd "${TARGET_DIR:-.}" && cargo check 2>&1) || { + findings=$((findings + 1)) + details="${details}cargo check failed. " + } + fi + + # Write results + cat > "$quality_dir/static-analysis.json" << SAFEOF +{"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","files_checked":$total_checked,"findings":$findings,"summary":"$details","pass":$([ $findings -eq 0 ] && echo "true" || echo "false")} +SAFEOF + + if [ "$findings" -gt 0 ]; then + rm -f "$quality_dir/static-analysis.pass" + echo "static_analysis" > "$loki_dir/signals/STATIC_ANALYSIS_FAILED" 2>/dev/null || true + log_warn "Static analysis: $findings issue(s) in $total_checked files" + return 1 + else + touch "$quality_dir/static-analysis.pass" + rm -f "$loki_dir/signals/STATIC_ANALYSIS_FAILED" 2>/dev/null || true + log_info "Static analysis: $total_checked files checked, all clean" + return 0 + fi +} + +#=============================================================================== +# Gate Failure Tracking (v6.10.0) +#=============================================================================== + +track_gate_failure() { + local gate_name="$1" + local gate_file="${TARGET_DIR:-.}/.loki/quality/gate-failure-count.json" + mkdir -p "$(dirname "$gate_file")" + + _GATE_FILE="$gate_file" _GATE_NAME="$gate_name" python3 -c " +import json, os +gate_file = os.environ['_GATE_FILE'] +gate_name = os.environ['_GATE_NAME'] +try: + with open(gate_file) as f: + counts = json.load(f) +except (json.JSONDecodeError, FileNotFoundError, OSError): + counts = {} +counts[gate_name] = counts.get(gate_name, 0) + 1 +with open(gate_file, 'w') as f: + json.dump(counts, f, indent=2) +print(counts[gate_name]) +" 2>/dev/null || echo "1" +} + +clear_gate_failure() { + local gate_name="$1" + local gate_file="${TARGET_DIR:-.}/.loki/quality/gate-failure-count.json" + [ -f "$gate_file" ] || return 0 + + _GATE_FILE="$gate_file" _GATE_NAME="$gate_name" python3 -c " +import json, os +gate_file = os.environ['_GATE_FILE'] +gate_name = os.environ['_GATE_NAME'] +try: + with open(gate_file) as f: + counts = json.load(f) +except (json.JSONDecodeError, FileNotFoundError, OSError): + counts = {} +counts[gate_name] = 0 +with open(gate_file, 'w') as f: + json.dump(counts, f, indent=2) +" 2>/dev/null || true +} + +get_gate_failure_count() { + local gate_name="$1" + local gate_file="${TARGET_DIR:-.}/.loki/quality/gate-failure-count.json" + [ -f "$gate_file" ] || { echo "0"; return; } + + _GATE_FILE="$gate_file" _GATE_NAME="$gate_name" python3 -c " +import json, os +gate_file = os.environ['_GATE_FILE'] +gate_name = os.environ['_GATE_NAME'] +try: + with open(gate_file) as f: + counts = json.load(f) + print(counts.get(gate_name, 0)) +except (json.JSONDecodeError, FileNotFoundError, OSError): + print(0) +" 2>/dev/null || echo "0" +} + +# ============================================================================ +# Hard Quality Gate: Test Coverage (v6.7.0) +# Detects test runner and runs tests with coverage reporting +# Results stored in .loki/quality/test-results.json +# ============================================================================ + +enforce_test_coverage() { + local loki_dir="${TARGET_DIR:-.}/.loki" + local quality_dir="$loki_dir/quality" + mkdir -p "$quality_dir" "$loki_dir/signals" + + local min_coverage="${LOKI_MIN_COVERAGE:-80}" + local test_passed=true + local coverage_pct=0 + local test_runner="none" + local details="" + + # JavaScript/TypeScript + if [ -f "${TARGET_DIR:-.}/package.json" ]; then + if grep -q '"vitest"' "${TARGET_DIR:-.}/package.json" 2>/dev/null; then + test_runner="vitest" + local output + output=$(cd "${TARGET_DIR:-.}" && npx vitest run --reporter=json 2>&1) || test_passed=false + details="vitest: $(echo "$output" | tail -3 | tr '\n' ' ')" + elif grep -q '"jest"' "${TARGET_DIR:-.}/package.json" 2>/dev/null; then + test_runner="jest" + local output + output=$(cd "${TARGET_DIR:-.}" && npx jest --passWithNoTests --forceExit 2>&1) || test_passed=false + details="jest: $(echo "$output" | tail -3 | tr '\n' ' ')" + elif grep -q '"mocha"' "${TARGET_DIR:-.}/package.json" 2>/dev/null; then + test_runner="mocha" + local output + output=$(cd "${TARGET_DIR:-.}" && npx mocha 2>&1) || test_passed=false + details="mocha: $(echo "$output" | tail -3 | tr '\n' ' ')" + fi + fi + + # Monorepo: scan workspace packages for test runners (v6.10.0) + if [ "$test_runner" = "none" ] && [ -f "${TARGET_DIR:-.}/package.json" ]; then + local is_monorepo=false + # Detect monorepo indicators + if [ -f "${TARGET_DIR:-.}/pnpm-workspace.yaml" ] || \ + [ -f "${TARGET_DIR:-.}/turbo.json" ] || \ + [ -f "${TARGET_DIR:-.}/lerna.json" ] || \ + grep -q '"workspaces"' "${TARGET_DIR:-.}/package.json" 2>/dev/null; then + is_monorepo=true + fi + + if [ "$is_monorepo" = "true" ]; then + # Allow env override + if [ -n "${LOKI_MONOREPO_TEST_CMD:-}" ]; then + test_runner="monorepo-custom" + local output + output=$(cd "${TARGET_DIR:-.}" && eval "$LOKI_MONOREPO_TEST_CMD" 2>&1) || test_passed=false + details="monorepo-custom: $(echo "$output" | tail -3 | tr '\n' ' ')" + else + # Scan workspace packages for test runners + local workspace_runner="" + for pkg_json in "${TARGET_DIR:-.}"/packages/*/package.json \ + "${TARGET_DIR:-.}"/apps/*/package.json \ + "${TARGET_DIR:-.}"/services/*/package.json; do + [ -f "$pkg_json" ] || continue + if grep -q '"vitest"' "$pkg_json" 2>/dev/null; then + workspace_runner="vitest" + break + elif grep -q '"jest"' "$pkg_json" 2>/dev/null; then + workspace_runner="jest" + break + fi + done + + if [ -n "$workspace_runner" ]; then + test_runner="monorepo-$workspace_runner" + local output + if [ -f "${TARGET_DIR:-.}/turbo.json" ] && command -v turbo &>/dev/null; then + output=$(cd "${TARGET_DIR:-.}" && npx turbo test 2>&1) || test_passed=false + details="turbo test ($workspace_runner): $(echo "$output" | tail -3 | tr '\n' ' ')" + elif [ -f "${TARGET_DIR:-.}/pnpm-workspace.yaml" ] && command -v pnpm &>/dev/null; then + output=$(cd "${TARGET_DIR:-.}" && pnpm test --recursive 2>&1) || test_passed=false + details="pnpm test --recursive ($workspace_runner): $(echo "$output" | tail -3 | tr '\n' ' ')" + else + output=$(cd "${TARGET_DIR:-.}" && npm test 2>&1) || test_passed=false + details="npm test ($workspace_runner): $(echo "$output" | tail -3 | tr '\n' ' ')" + fi + fi + fi + fi + fi + + # Python + if [ "$test_runner" = "none" ]; then + if [ -f "${TARGET_DIR:-.}/setup.py" ] || [ -f "${TARGET_DIR:-.}/pyproject.toml" ] || \ + [ -d "${TARGET_DIR:-.}/tests" ]; then + if command -v pytest &>/dev/null; then + test_runner="pytest" + local output + output=$(cd "${TARGET_DIR:-.}" && pytest --tb=short 2>&1) || test_passed=false + details="pytest: $(echo "$output" | tail -5 | tr '\n' ' ')" + fi + fi + fi + + # Go + if [ "$test_runner" = "none" ] && [ -f "${TARGET_DIR:-.}/go.mod" ] && command -v go &>/dev/null; then + test_runner="go-test" + local output + output=$(cd "${TARGET_DIR:-.}" && go test ./... 2>&1) || test_passed=false + details="go test: $(echo "$output" | tail -3 | tr '\n' ' ')" + fi + + # Rust + if [ "$test_runner" = "none" ] && [ -f "${TARGET_DIR:-.}/Cargo.toml" ] && command -v cargo &>/dev/null; then + test_runner="cargo-test" + local output + output=$(cd "${TARGET_DIR:-.}" && cargo test 2>&1) || test_passed=false + details="cargo test: $(echo "$output" | tail -3 | tr '\n' ' ')" + fi + + if [ "$test_runner" = "none" ]; then + log_info "Test coverage: no test runner detected, skipping" + touch "$quality_dir/unit-tests.pass" + cat > "$quality_dir/test-results.json" << TREOF +{"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"none","pass":true,"summary":"No test runner detected"} +TREOF + return 0 + fi + + # Sanitize details for JSON + details=$(echo "$details" | tr '"' "'" | tr '\n' ' ' | head -c 500) + + cat > "$quality_dir/test-results.json" << TREOF +{"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"$test_runner","pass":$test_passed,"min_coverage":$min_coverage,"summary":"$details"} +TREOF + + if [ "$test_passed" = "true" ]; then + touch "$quality_dir/unit-tests.pass" + rm -f "$loki_dir/signals/TESTS_FAILED" 2>/dev/null || true + log_info "Test coverage gate: $test_runner passed" + return 0 + else + rm -f "$quality_dir/unit-tests.pass" + echo "tests_failed" > "$loki_dir/signals/TESTS_FAILED" 2>/dev/null || true + log_warn "Test coverage gate: $test_runner FAILED" + return 1 + fi +} + +# ============================================================================ +# 3-Reviewer Parallel Code Review (v5.35.0) +# Specialist pool from skills/quality-gates.md with blind review +# architecture-strategist always included, 2 more selected by keyword scoring +# ============================================================================ + +run_code_review() { + local loki_dir="${TARGET_DIR:-.}/.loki" + local review_dir="$loki_dir/quality/reviews" + local review_id + review_id="review-$(date -u +%Y%m%dT%H%M%SZ)-${ITERATION_COUNT:-0}" + mkdir -p "$review_dir/$review_id" + + # Get diff from last commit (staged changes) + local diff_content + diff_content=$(git -C "${TARGET_DIR:-.}" diff HEAD~1 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --cached 2>/dev/null || echo "") + if [ -z "$diff_content" ]; then + log_info "Code review: No diff to review, skipping" + return 0 + fi + + local changed_files + changed_files=$(git -C "${TARGET_DIR:-.}" diff --name-only HEAD~1 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --name-only --cached 2>/dev/null || echo "") + + log_header "CODE REVIEW: $review_id" + log_info "Selecting 3 specialist reviewers from pool..." + + # Write diff/files to temp files for python to read (avoid env var size limits) + local diff_file="$review_dir/$review_id/diff.txt" + local files_file="$review_dir/$review_id/files.txt" + echo "$diff_content" > "$diff_file" + echo "$changed_files" > "$files_file" + + # Select specialists via keyword scoring (python3 reads files, not env vars) + # Loads from agents/types.json when available, falls back to hardcoded pool (v6.7.0) + export LOKI_REVIEW_DIFF_FILE="$diff_file" + export LOKI_REVIEW_FILES_FILE="$files_file" + export LOKI_AGENTS_TYPES_FILE="${PROJECT_DIR}/agents/types.json" + local selected_specialists + selected_specialists=$(python3 << 'SPECIALIST_SELECT' +import os +import json + +# Hardcoded specialists (always available as fallback) +SPECIALISTS = { + "security-sentinel": { + "keywords": ["auth", "login", "password", "token", "api", "sql", "query", "cookie", "cors", "csrf"], + "focus": "OWASP Top 10, injection, auth, secrets, input validation", + "checks": "injection (SQL, XSS, command, template), auth bypass, secrets in code, missing input validation, OWASP Top 10, insecure defaults", + "priority": 0 + }, + "test-coverage-auditor": { + "keywords": ["test", "spec", "coverage", "assert", "mock", "fixture", "expect", "describe"], + "focus": "Missing tests, edge cases, error paths, boundary conditions", + "checks": "missing test cases, uncovered error paths, boundary conditions, mock correctness, test isolation, flaky test patterns", + "priority": 1 + }, + "performance-oracle": { + "keywords": ["database", "query", "cache", "render", "loop", "fetch", "load", "index", "join", "pool"], + "focus": "N+1 queries, memory leaks, caching, bundle size, lazy loading", + "checks": "N+1 queries, unbounded loops, memory leaks, missing caching, excessive re-renders, large bundle imports, missing pagination", + "priority": 2 + }, + "dependency-analyst": { + "keywords": ["package", "import", "require", "dependency", "npm", "pip", "yarn", "lock"], + "focus": "Outdated packages, CVEs, bloat, unused deps, license issues", + "checks": "outdated dependencies, known CVEs, unnecessary imports, dependency bloat, license compatibility, unused packages", + "priority": 3 + } +} + +# Load additional specialists from agents/types.json (v6.7.0) +types_file = os.environ.get("LOKI_AGENTS_TYPES_FILE", "") +if types_file and os.path.exists(types_file): + try: + with open(types_file) as f: + agent_types = json.load(f) + FOCUS_KEYWORDS = { + "ops-security": ["auth", "security", "vuln", "cve", "injection", "xss", "csrf", "encrypt", "secret", "permission"], + "eng-qa": ["test", "spec", "coverage", "assert", "mock", "fixture", "expect", "describe", "e2e", "unit"], + "eng-perf": ["perf", "cache", "query", "slow", "memory", "leak", "optimize", "bundle", "load", "latency"], + "eng-database": ["database", "sql", "query", "migration", "index", "join", "schema", "postgres", "mongo"], + "eng-frontend": ["react", "vue", "css", "html", "component", "render", "dom", "accessibility", "responsive"], + "eng-backend": ["api", "endpoint", "middleware", "route", "controller", "service", "auth", "validation"], + "eng-infra": ["docker", "k8s", "kubernetes", "deploy", "ci", "cd", "pipeline", "terraform", "helm"], + "review-code": ["refactor", "pattern", "solid", "coupling", "abstraction", "class", "function", "module"], + "review-security": ["auth", "login", "password", "token", "secret", "inject", "xss", "cors", "permission", "encrypt"], + "review-business": ["logic", "workflow", "business", "rule", "validation", "price", "payment", "order"], + } + for agent in agent_types: + agent_type = agent.get("type", "") + if agent_type in FOCUS_KEYWORDS and agent_type not in SPECIALISTS: + SPECIALISTS[agent_type] = { + "keywords": FOCUS_KEYWORDS[agent_type], + "focus": agent.get("capabilities", ""), + "checks": "Review from " + agent.get("name", agent_type) + " perspective: " + ", ".join(agent.get("focus", [])), + "priority": len(SPECIALISTS), + "persona": agent.get("persona", "") + } + except Exception: + pass # Fall back to hardcoded specialists + +diff_path = os.environ.get("LOKI_REVIEW_DIFF_FILE", "") +files_path = os.environ.get("LOKI_REVIEW_FILES_FILE", "") + +diff_text = "" +files_text = "" +if diff_path and os.path.exists(diff_path): + with open(diff_path, "r") as f: + diff_text = f.read().lower() +if files_path and os.path.exists(files_path): + with open(files_path, "r") as f: + files_text = f.read().lower() + +search_text = diff_text + " " + files_text + +# Score each specialist by keyword matches +scores = {} +for name, spec in SPECIALISTS.items(): + score = sum(1 for kw in spec["keywords"] if kw in search_text) + scores[name] = score + +# Sort by score descending, then by priority ascending (tie-breaker) +ranked = sorted(scores.keys(), key=lambda n: (-scores[n], SPECIALISTS[n]["priority"])) + +# If no keywords matched at all, use defaults +if all(s == 0 for s in scores.values()): + selected = ["security-sentinel", "test-coverage-auditor"] +else: + selected = ranked[:2] + +# Output JSON: architecture-strategist always first, then the 2 selected +result = { + "reviewers": [ + { + "name": "architecture-strategist", + "focus": "SOLID, coupling, cohesion, patterns, abstraction, dependency direction", + "checks": "SOLID violations, excessive coupling, wrong patterns, missing abstractions, dependency direction issues, god classes/functions" + } + ] + [ + { + "name": name, + "focus": SPECIALISTS[name]["focus"], + "checks": SPECIALISTS[name]["checks"] + } + for name in selected + ], + "scores": {n: scores[n] for n in scores}, + "pool_size": len(SPECIALISTS) +} +print(json.dumps(result)) +SPECIALIST_SELECT + ) + unset LOKI_REVIEW_DIFF_FILE LOKI_REVIEW_FILES_FILE LOKI_AGENTS_TYPES_FILE + + if [ -z "$selected_specialists" ]; then + log_error "Code review: Specialist selection failed" + return 1 + fi + + # Save selection metadata + echo "$selected_specialists" > "$review_dir/$review_id/selection.json" + + # Extract reviewer names for logging + local reviewer_names + reviewer_names=$(echo "$selected_specialists" | python3 -c "import sys,json; d=json.load(sys.stdin); print(', '.join(r['name'] for r in d['reviewers']))") + log_info "Selected reviewers: $reviewer_names" + + emit_event_json "code_review_start" \ + "review_id=$review_id" \ + "reviewers=$reviewer_names" \ + "iteration=$ITERATION_COUNT" + + # Dispatch 3 parallel blind reviews using provider-specific invocation + local pids=() + local reviewer_count + reviewer_count=$(echo "$selected_specialists" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['reviewers']))") + + for i in $(seq 0 $((reviewer_count - 1))); do + local reviewer_name reviewer_focus reviewer_checks + reviewer_name=$(echo "$selected_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['name'])") + reviewer_focus=$(echo "$selected_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['focus'])") + reviewer_checks=$(echo "$selected_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['checks'])") + + local review_output="$review_dir/$review_id/${reviewer_name}.txt" + + # Build prompt via python to avoid shell quoting issues with diff content + local review_prompt_file="$review_dir/$review_id/${reviewer_name}-prompt.txt" + export LOKI_REVIEW_PROMPT_NAME="$reviewer_name" + export LOKI_REVIEW_PROMPT_FOCUS="$reviewer_focus" + export LOKI_REVIEW_PROMPT_CHECKS="$reviewer_checks" + export LOKI_REVIEW_PROMPT_DIFF_FILE="$diff_file" + export LOKI_REVIEW_PROMPT_FILES_FILE="$files_file" + export LOKI_REVIEW_PROMPT_OUT="$review_prompt_file" + python3 << 'BUILD_PROMPT' +import os + +name = os.environ["LOKI_REVIEW_PROMPT_NAME"] +focus = os.environ["LOKI_REVIEW_PROMPT_FOCUS"] +checks = os.environ["LOKI_REVIEW_PROMPT_CHECKS"] + +with open(os.environ["LOKI_REVIEW_PROMPT_FILES_FILE"], "r") as f: + files = f.read().strip() +with open(os.environ["LOKI_REVIEW_PROMPT_DIFF_FILE"], "r") as f: + diff = f.read().strip() + +prompt = f"""You are {name}. Your SOLE focus is: {focus}. + +Review ONLY for: {checks}. + +Files changed: +{files} + +Diff: +{diff} + +Output format (STRICT - follow exactly): +VERDICT: PASS or FAIL +FINDINGS: +- [severity] description (file:line) +Severity levels: Critical, High, Medium, Low + +If no issues found, output: +VERDICT: PASS +FINDINGS: +- None""" + +with open(os.environ["LOKI_REVIEW_PROMPT_OUT"], "w") as f: + f.write(prompt) +BUILD_PROMPT + unset LOKI_REVIEW_PROMPT_NAME LOKI_REVIEW_PROMPT_FOCUS LOKI_REVIEW_PROMPT_CHECKS + unset LOKI_REVIEW_PROMPT_DIFF_FILE LOKI_REVIEW_PROMPT_FILES_FILE LOKI_REVIEW_PROMPT_OUT + + log_step "Dispatching reviewer: $reviewer_name" + + # Launch blind review in background (provider-specific) + ( + local prompt_text + prompt_text=$(cat "$review_prompt_file") + case "${PROVIDER_NAME:-claude}" in + claude) + claude --dangerously-skip-permissions -p "$prompt_text" \ + --output-format text > "$review_output" 2>/dev/null + ;; + codex) + codex exec --full-auto "$prompt_text" \ + > "$review_output" 2>/dev/null + ;; + gemini) + invoke_gemini_capture "$prompt_text" \ + > "$review_output" 2>/dev/null + ;; + cline) + invoke_cline_capture "$prompt_text" \ + > "$review_output" 2>/dev/null + ;; + aider) + invoke_aider_capture "$prompt_text" \ + > "$review_output" 2>/dev/null + ;; + *) + echo "VERDICT: PASS" > "$review_output" + echo "FINDINGS:" >> "$review_output" + echo "- [Low] Unknown provider, review skipped" >> "$review_output" + ;; + esac + ) & + pids+=($!) + register_pid "$!" "code-reviewer" "name=$reviewer_name" + done + + # Wait for all reviewers to complete + log_info "Waiting for $reviewer_count reviewers to complete (blind review)..." + for pid in "${pids[@]}"; do + wait "$pid" || true + unregister_pid "$pid" + done + + log_info "All reviewers complete. Aggregating verdicts..." + + # Aggregate verdicts: check for FAIL + Critical/High severity + local has_blocking=false + local pass_count=0 + local fail_count=0 + local verdicts_summary="" + + for i in $(seq 0 $((reviewer_count - 1))); do + local reviewer_name + reviewer_name=$(echo "$selected_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['name'])") + local review_output="$review_dir/$review_id/${reviewer_name}.txt" + + if [ ! -f "$review_output" ] || [ ! -s "$review_output" ]; then + log_warn "Reviewer $reviewer_name produced no output" + verdicts_summary="${verdicts_summary}${reviewer_name}:NO_OUTPUT " + continue + fi + + # Extract verdict + local verdict + verdict=$(grep -i "^VERDICT:" "$review_output" | head -1 | sed 's/^VERDICT:[[:space:]]*//' | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]') + + if [ "$verdict" = "FAIL" ]; then + ((fail_count++)) + # Check for Critical/High severity findings + if grep -qiE "\[(Critical|High)\]" "$review_output"; then + has_blocking=true + log_error "BLOCKING: $reviewer_name found Critical/High severity issues" + else + log_warn "FAIL: $reviewer_name found Medium/Low issues (non-blocking)" + fi + else + ((pass_count++)) + log_info "PASS: $reviewer_name" + fi + verdicts_summary="${verdicts_summary}${reviewer_name}:${verdict:-UNKNOWN} " + done + + # Save aggregate results via python3 + env vars (no shell interpolation in JSON) + export LOKI_REVIEW_AGG_FILE="$review_dir/$review_id/aggregate.json" + export LOKI_REVIEW_AGG_ID="$review_id" + export LOKI_REVIEW_AGG_ITER="$ITERATION_COUNT" + export LOKI_REVIEW_AGG_PASS="$pass_count" + export LOKI_REVIEW_AGG_FAIL="$fail_count" + export LOKI_REVIEW_AGG_BLOCKING="$has_blocking" + export LOKI_REVIEW_AGG_VERDICTS="$verdicts_summary" + python3 << 'AGG_SCRIPT' +import json, os +result = { + "review_id": os.environ["LOKI_REVIEW_AGG_ID"], + "iteration": int(os.environ["LOKI_REVIEW_AGG_ITER"]), + "pass_count": int(os.environ["LOKI_REVIEW_AGG_PASS"]), + "fail_count": int(os.environ["LOKI_REVIEW_AGG_FAIL"]), + "has_blocking": os.environ["LOKI_REVIEW_AGG_BLOCKING"] == "true", + "verdicts": os.environ["LOKI_REVIEW_AGG_VERDICTS"].strip() +} +with open(os.environ["LOKI_REVIEW_AGG_FILE"], "w") as f: + json.dump(result, f, indent=2) +AGG_SCRIPT + unset LOKI_REVIEW_AGG_FILE LOKI_REVIEW_AGG_ID LOKI_REVIEW_AGG_ITER + unset LOKI_REVIEW_AGG_PASS LOKI_REVIEW_AGG_FAIL LOKI_REVIEW_AGG_BLOCKING LOKI_REVIEW_AGG_VERDICTS + + emit_event_json "code_review_complete" \ + "review_id=$review_id" \ + "pass_count=$pass_count" \ + "fail_count=$fail_count" \ + "has_blocking=$has_blocking" \ + "iteration=$ITERATION_COUNT" + + # Anti-sycophancy check: unanimous PASS is suspicious + if [ "$pass_count" -eq "$reviewer_count" ] && [ "$fail_count" -eq 0 ]; then + log_warn "ANTI-SYCOPHANCY: All $reviewer_count reviewers passed unanimously" + log_warn "Devil's advocate note: Unanimous approval may indicate insufficient scrutiny" + log_warn "Consider manual review of $review_dir/$review_id/" + echo "UNANIMOUS_PASS: All reviewers approved - potential sycophancy risk" \ + >> "$review_dir/$review_id/anti-sycophancy.txt" + fi + + # Blocking decision + if [ "$has_blocking" = "true" ]; then + log_error "CODE REVIEW BLOCKED: Critical/High findings detected" + log_error "Review details: $review_dir/$review_id/" + return 1 + fi + + log_info "Code review passed ($pass_count/$reviewer_count PASS, $fail_count FAIL - no blocking issues)" + return 0 +} + +#=============================================================================== +# Adversarial Testing (v6.0.0) - For Standard+ complexity tiers +# Spawns an adversarial agent that tries to break the implementation. +# Only runs when complexity >= standard (6+ agents). +#=============================================================================== + +run_adversarial_testing() { + local loki_dir="${TARGET_DIR:-.}/.loki" + local adversarial_dir="$loki_dir/quality/adversarial" + local test_id + test_id="adversarial-$(date -u +%Y%m%dT%H%M%SZ)-${ITERATION_COUNT:-0}" + mkdir -p "$adversarial_dir/$test_id" + + # Only run for Standard+ complexity + local complexity="${LOKI_COMPLEXITY:-auto}" + if [ "$complexity" = "simple" ]; then + log_debug "Adversarial testing skipped: simple complexity tier" + return 0 + fi + + # Check if adversarial testing is disabled + if [ "${LOKI_ADVERSARIAL_TESTING:-true}" = "false" ]; then + log_debug "Adversarial testing disabled via LOKI_ADVERSARIAL_TESTING=false" + return 0 + fi + + log_header "ADVERSARIAL TESTING: $test_id" + + # Get diff for adversarial analysis + local diff_content + diff_content=$(git -C "${TARGET_DIR:-.}" diff HEAD~1 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --cached 2>/dev/null || echo "") + if [ -z "$diff_content" ]; then + log_info "Adversarial testing: No diff to test, skipping" + return 0 + fi + + local changed_files + changed_files=$(git -C "${TARGET_DIR:-.}" diff --name-only HEAD~1 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --name-only --cached 2>/dev/null || echo "") + + # Write analysis files + local diff_file="$adversarial_dir/$test_id/diff.txt" + local files_file="$adversarial_dir/$test_id/files.txt" + echo "$diff_content" > "$diff_file" + echo "$changed_files" > "$files_file" + + # Build adversarial prompt + local adversarial_prompt="You are an ADVERSARIAL TESTER. Your goal is to BREAK the implementation. + +CHANGED FILES: +$(cat "$files_file") + +DIFF: +$(head -500 "$diff_file") + +YOUR MISSION: +1. Find edge cases that will cause crashes or incorrect behavior +2. Identify inputs that bypass validation +3. Find race conditions or concurrency issues +4. Discover security vulnerabilities (injection, auth bypass, SSRF) +5. Find resource exhaustion vectors (unbounded loops, memory leaks) +6. Identify error handling gaps (missing try/catch, unchecked returns) + +OUTPUT FORMAT (STRICT): +ATTACK_VECTORS: +- [severity] [category] description | reproduction steps + Severity: Critical, High, Medium, Low + Category: crash, security, correctness, performance, resource + +SUGGESTED_TESTS: +- Test description that would catch this issue + +OVERALL_RISK: HIGH or MEDIUM or LOW" + + local result_file="$adversarial_dir/$test_id/result.txt" + + # Run adversarial agent + log_info "Spawning adversarial agent..." + case "${PROVIDER_NAME:-claude}" in + claude) + if command -v claude &>/dev/null; then + claude --dangerously-skip-permissions -p "$adversarial_prompt" \ + --output-format text > "$result_file" 2>/dev/null || true + fi + ;; + codex) + if command -v codex &>/dev/null; then + codex exec --full-auto "$adversarial_prompt" \ + > "$result_file" 2>/dev/null || true + fi + ;; + gemini) + if command -v gemini &>/dev/null; then + invoke_gemini_capture "$adversarial_prompt" \ + > "$result_file" 2>/dev/null || true + fi + ;; + cline) + if command -v cline &>/dev/null; then + invoke_cline_capture "$adversarial_prompt" \ + > "$result_file" 2>/dev/null || true + fi + ;; + aider) + if command -v aider &>/dev/null; then + invoke_aider_capture "$adversarial_prompt" \ + > "$result_file" 2>/dev/null || true + fi + ;; + *) + echo "ATTACK_VECTORS: None (unknown provider)" > "$result_file" + echo "OVERALL_RISK: LOW" >> "$result_file" + ;; + esac + + if [ ! -s "$result_file" ]; then + log_warn "Adversarial agent produced no output" + return 0 + fi + + # Parse risk level + local risk_level + risk_level=$(grep -i "OVERALL_RISK:" "$result_file" | head -1 | sed 's/.*OVERALL_RISK:[[:space:]]*//' | awk '{print toupper($1)}') + + # Count critical/high attack vectors + local critical_count high_count + critical_count=$(grep -ci "\[critical\]" "$result_file" 2>/dev/null || echo "0") + high_count=$(grep -ci "\[high\]" "$result_file" 2>/dev/null || echo "0") + + log_info "Adversarial testing complete: risk=$risk_level, critical=$critical_count, high=$high_count" + + emit_event_json "adversarial_test_complete" \ + "test_id=$test_id" \ + "risk_level=${risk_level:-UNKNOWN}" \ + "critical_count=$critical_count" \ + "high_count=$high_count" \ + "iteration=$ITERATION_COUNT" + + # Block on critical findings + if [ "$critical_count" -gt 0 ]; then + log_error "ADVERSARIAL TEST BLOCKED: $critical_count critical attack vectors found" + log_error "Details: $adversarial_dir/$test_id/result.txt" + return 1 + fi + + return 0 +} + +load_solutions_context() { + # Load relevant structured solutions for the current task context + local context="$1" + local solutions_dir="${HOME}/.loki/solutions" + local output_file=".loki/state/relevant-solutions.json" + + if [ ! -d "$solutions_dir" ]; then + echo '{"solutions":[]}' > "$output_file" 2>/dev/null || true + return + fi + + export LOKI_SOL_CONTEXT="$context" + python3 << 'SOLUTIONS_SCRIPT' +import json +import os +import re + +solutions_dir = os.path.expanduser("~/.loki/solutions") +context = os.environ.get("LOKI_SOL_CONTEXT", "").lower() +context_words = set(context.split()) + +results = [] + +for category in os.listdir(solutions_dir): + cat_dir = os.path.join(solutions_dir, category) + if not os.path.isdir(cat_dir): + continue + for filename in os.listdir(cat_dir): + if not filename.endswith('.md'): + continue + filepath = os.path.join(cat_dir, filename) + try: + with open(filepath, 'r') as f: + content = f.read() + except: + continue + + # Parse YAML frontmatter + fm_match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + if not fm_match: + continue + + fm = fm_match.group(1) + title = re.search(r'title:\s*"([^"]*)"', fm) + tags_match = re.search(r'tags:\s*\[([^\]]*)\]', fm) + root_cause = re.search(r'root_cause:\s*"([^"]*)"', fm) + prevention = re.search(r'prevention:\s*"([^"]*)"', fm) + symptoms = re.findall(r'^\s*-\s*"([^"]*)"', fm, re.MULTILINE) + + title_str = title.group(1) if title else filename.replace('.md', '') + tags = [t.strip() for t in tags_match.group(1).split(',')] if tags_match else [] + + # Score by matching + score = 0 + for tag in tags: + if tag.lower() in context: + score += 2 + for symptom in symptoms: + for word in symptom.lower().split(): + if word in context_words and len(word) > 3: + score += 3 + if category in context: + score += 1 + + if score > 0: + results.append({ + "score": score, + "category": category, + "title": title_str, + "root_cause": root_cause.group(1) if root_cause else "", + "prevention": prevention.group(1) if prevention else "", + "file": filepath + }) + +# Sort by score, take top 3 +results.sort(key=lambda x: x["score"], reverse=True) +top = results[:3] + +output = {"solutions": top} +os.makedirs(".loki/state", exist_ok=True) +with open(".loki/state/relevant-solutions.json", 'w') as f: + json.dump(output, f, indent=2) + +if top: + print(f"Loaded {len(top)} relevant solutions from cross-project knowledge base") +SOLUTIONS_SCRIPT +} + +# ============================================================================ +# Checkpoint/Snapshot System (v5.34.0) +# Git-based checkpoints after task completion with state snapshots +# Inspired by Cursor Self-Driving Codebases + Entire.io provenance tracking +# ============================================================================ + +create_checkpoint() { + # Create a git checkpoint after task completion + # Args: $1 = task description, $2 = task_id (optional) + local task_desc="${1:-task completed}" + local task_id="${2:-unknown}" + local checkpoint_dir=".loki/state/checkpoints" + local iteration="${ITERATION_COUNT:-0}" + + mkdir -p "$checkpoint_dir" + + # Only checkpoint if there are uncommitted changes + if git diff --quiet 2>/dev/null && git diff --cached --quiet 2>/dev/null; then + log_info "No uncommitted changes to checkpoint" + return 0 + fi + + # Capture git state + local git_sha + git_sha=$(git rev-parse HEAD 2>/dev/null || echo "no-git") + local git_branch + git_branch=$(git branch --show-current 2>/dev/null || echo "unknown") + + # Snapshot .loki state files + local timestamp + timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + local checkpoint_id="cp-${iteration}-$(date +%s)" + local cp_dir="${checkpoint_dir}/${checkpoint_id}" + + mkdir -p "$cp_dir" + + # Copy critical state files (lightweight -- not full .loki/) + for f in state/orchestrator.json queue/pending.json queue/completed.json queue/in-progress.json queue/current-task.json; do + if [ -f ".loki/$f" ]; then + local target_dir="$cp_dir/$(dirname "$f")" + mkdir -p "$target_dir" + cp ".loki/$f" "$cp_dir/$f" 2>/dev/null || true + fi + done + + # Write checkpoint metadata (use python3 json.dumps for safe serialization) + local phase_val + phase_val=$(cat .loki/state/orchestrator.json 2>/dev/null | python3 -c 'import sys,json; print(json.load(sys.stdin).get("currentPhase","unknown"))' 2>/dev/null || echo 'unknown') + + local index_file="${checkpoint_dir}/index.jsonl" + _CP_ID="$checkpoint_id" _CP_TS="$timestamp" _CP_ITER="$iteration" \ + _CP_TASK_ID="$task_id" _CP_DESC="${task_desc:0:200}" _CP_SHA="$git_sha" \ + _CP_BRANCH="$git_branch" _CP_PROVIDER="${PROVIDER_NAME:-claude}" \ + _CP_PHASE="$phase_val" _CP_DIR="$cp_dir" _CP_INDEX="$index_file" \ + python3 << 'CPEOF' +import json, os +metadata = { + "id": os.environ["_CP_ID"], + "timestamp": os.environ["_CP_TS"], + "iteration": int(os.environ["_CP_ITER"]), + "task_id": os.environ["_CP_TASK_ID"], + "task_description": os.environ["_CP_DESC"], + "git_sha": os.environ["_CP_SHA"], + "git_branch": os.environ["_CP_BRANCH"], + "provider": os.environ["_CP_PROVIDER"], + "phase": os.environ["_CP_PHASE"], +} +with open(os.path.join(os.environ["_CP_DIR"], "metadata.json"), "w") as f: + json.dump(metadata, f, indent=2) +with open(os.environ["_CP_INDEX"], "a") as f: + index_entry = {"id": metadata["id"], "ts": metadata["timestamp"], + "iter": metadata["iteration"], "task": metadata["task_description"], + "sha": metadata["git_sha"]} + f.write(json.dumps(index_entry) + "\n") +CPEOF + + # Retention: keep last 50 checkpoints, prune older + # Sort by epoch suffix (field after last hyphen) for correct chronological order + local cp_count + cp_count=$(find "$checkpoint_dir" -maxdepth 1 -type d -name "cp-*" 2>/dev/null | wc -l | tr -d ' ') + if [ "$cp_count" -gt 50 ]; then + local to_remove=$((cp_count - 50)) + find "$checkpoint_dir" -maxdepth 1 -type d -name "cp-*" 2>/dev/null \ + | sort -t'-' -k3 -n \ + | head -n "$to_remove" | while read -r old_cp; do + rm -rf "$old_cp" 2>/dev/null || true + done + # Rebuild index atomically from remaining checkpoints (sorted by epoch) + local tmp_index="${index_file}.tmp.$$" + for remaining in $(find "$checkpoint_dir" -maxdepth 2 -name "metadata.json" -path "*/cp-*/*" 2>/dev/null | sort -t'-' -k3 -n); do + [ -f "$remaining" ] || continue + _CP_META="$remaining" python3 -c " +import json,os +m=json.load(open(os.environ['_CP_META'])) +print(json.dumps({'id':m['id'],'ts':m['timestamp'],'iter':m['iteration'],'task':m.get('task_description',''),'sha':m['git_sha']})) +" >> "$tmp_index" 2>/dev/null || true + done + mv -f "$tmp_index" "$index_file" 2>/dev/null || true + fi + + log_info "Checkpoint created: ${checkpoint_id} (git: ${git_sha:0:8})" +} + +rollback_to_checkpoint() { + # Rollback state files to a specific checkpoint + # Args: $1 = checkpoint_id + local checkpoint_id="$1" + local checkpoint_dir=".loki/state/checkpoints" + + # Validate checkpoint ID (prevent path traversal) + if [[ ! "$checkpoint_id" =~ ^[a-zA-Z0-9_-]+$ ]]; then + log_error "Invalid checkpoint ID: must be alphanumeric, hyphens, underscores only" + return 1 + fi + + local cp_dir="${checkpoint_dir}/${checkpoint_id}" + + if [ ! -d "$cp_dir" ]; then + log_error "Checkpoint not found: ${checkpoint_id}" + return 1 + fi + + # Read checkpoint metadata + local git_sha + git_sha=$(_CP_META="${cp_dir}/metadata.json" python3 -c "import json, os; print(json.load(open(os.environ['_CP_META']))['git_sha'])" 2>/dev/null || echo "") + + log_warn "Rolling back to checkpoint: ${checkpoint_id}" + + # Create a pre-rollback checkpoint first + create_checkpoint "pre-rollback snapshot" "rollback" + + # Restore state files + for f in state/orchestrator.json queue/pending.json queue/completed.json queue/in-progress.json queue/current-task.json; do + if [ -f "${cp_dir}/${f}" ]; then + local target_dir=".loki/$(dirname "$f")" + mkdir -p "$target_dir" + cp "${cp_dir}/${f}" ".loki/${f}" 2>/dev/null || true + fi + done + + # Log the rollback (use python3 for safe JSON serialization) + local timestamp + timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + _RB_CPID="$checkpoint_id" _RB_SHA="$git_sha" _RB_TS="$timestamp" \ + python3 -c " +import json,os +print(json.dumps({'event':'rollback','checkpoint':os.environ['_RB_CPID'],'git_sha':os.environ['_RB_SHA'],'timestamp':os.environ['_RB_TS']})) +" >> ".loki/events.jsonl" 2>/dev/null || true + + log_info "State files restored from checkpoint: ${checkpoint_id}" + + if [ -n "$git_sha" ] && [ "$git_sha" != "no-git" ]; then + log_info "Git SHA at checkpoint: ${git_sha}" + log_info "To rollback code: git reset --hard ${git_sha}" + fi +} + +list_checkpoints() { + # List recent checkpoints + local checkpoint_dir=".loki/state/checkpoints" + local index_file="${checkpoint_dir}/index.jsonl" + local limit="${1:-10}" + + if [ ! -f "$index_file" ]; then + echo "No checkpoints found." + return + fi + + tail -n "$limit" "$index_file" | python3 -c " +import sys, json +lines = sys.stdin.readlines() +for line in reversed(lines): + try: + cp = json.loads(line) + sha = cp.get('sha','')[:8] + task = cp.get('task','')[:60] + print(f\" {cp['id']} {cp['ts']} [{sha}] {task}\") + except: + continue +" +} + +start_dashboard() { + log_header "Starting Loki Dashboard" + + # Create dashboard directory for logs + mkdir -p .loki/dashboard/logs + + # Find available port - don't kill other loki instances + local original_port=$DASHBOARD_PORT + local max_attempts=10 + local attempt=0 + + while lsof -i :$DASHBOARD_PORT &>/dev/null && [ $attempt -lt $max_attempts ]; do + # Check if it's our own dashboard + local existing_pid=$(lsof -ti :$DASHBOARD_PORT 2>/dev/null | head -1) + if [ -n "$existing_pid" ]; then + # Only kill if it's a Python/uvicorn dashboard process + local proc_cmd=$(ps -p "$existing_pid" -o comm= 2>/dev/null || true) + if [[ "$proc_cmd" == *python* ]] || [[ "$proc_cmd" == *uvicorn* ]]; then + log_step "Killing existing dashboard on port $DASHBOARD_PORT (PID: $existing_pid)..." + kill "$existing_pid" 2>/dev/null || true + sleep 1 + break + else + log_info "Port $DASHBOARD_PORT in use by non-dashboard process ($proc_cmd), skipping..." + fi + fi + ((DASHBOARD_PORT++)) + if [ "$DASHBOARD_PORT" -gt 65535 ]; then + log_error "Exhausted valid port range" + return 1 + fi + ((attempt++)) + log_info "Port $((DASHBOARD_PORT-1)) in use, trying $DASHBOARD_PORT..." + done + + if [ $attempt -ge $max_attempts ]; then + log_error "Could not find available port after $max_attempts attempts" + return 1 + fi + + # Start FastAPI dashboard server (unified UI + API) + log_step "Starting unified dashboard server..." + local log_file=".loki/dashboard/logs/dashboard.log" + local project_path=$(pwd) + + # Set environment for dashboard + export LOKI_DASHBOARD_PORT="$DASHBOARD_PORT" + export LOKI_DASHBOARD_HOST="127.0.0.1" + export LOKI_PROJECT_PATH="$project_path" + + # Determine URL scheme based on TLS configuration + local url_scheme="http" + local tls_env="" + if [ -n "${LOKI_TLS_CERT:-}" ] && [ -n "${LOKI_TLS_KEY:-}" ]; then + url_scheme="https" + tls_env="LOKI_TLS_CERT=${LOKI_TLS_CERT} LOKI_TLS_KEY=${LOKI_TLS_KEY}" + log_info "TLS enabled for dashboard" + fi + + # Ensure dashboard Python dependencies via virtualenv + # Use ~/.loki/dashboard-venv (persistent, writable, survives npm/brew upgrades) + local skill_dir="${SCRIPT_DIR%/*}" + local req_file="${skill_dir}/dashboard/requirements.txt" + local dashboard_venv="$HOME/.loki/dashboard-venv" + local python_cmd="python3" + + # Use venv python if available + if [ -x "${dashboard_venv}/bin/python3" ]; then + python_cmd="${dashboard_venv}/bin/python3" + fi + + # Check all required imports + if ! "$python_cmd" -c "import fastapi; import sqlalchemy; import aiosqlite" 2>/dev/null; then + log_step "Setting up dashboard virtualenv..." + if ! [ -x "${dashboard_venv}/bin/python3" ]; then + # Remove broken venv if exists + [ -d "$dashboard_venv" ] && rm -rf "$dashboard_venv" + mkdir -p "$HOME/.loki" + python3 -m venv "$dashboard_venv" 2>/dev/null || python3.13 -m venv "$dashboard_venv" 2>/dev/null || { + log_warn "Failed to create virtualenv" + log_warn "You may need: sudo apt install python3-venv" + } + fi + if [ -x "${dashboard_venv}/bin/python3" ]; then + python_cmd="${dashboard_venv}/bin/python3" + log_step "Installing dashboard dependencies..." + if [ -f "$req_file" ]; then + "${dashboard_venv}/bin/pip" install -r "$req_file" 2>&1 | tail -1 || { + log_warn "Pinned deps failed, trying unpinned..." + "${dashboard_venv}/bin/pip" install fastapi uvicorn pydantic websockets sqlalchemy aiosqlite 2>&1 | tail -1 || { + log_warn "Failed to install dashboard dependencies" + log_warn "Dashboard will not be available" + } + # greenlet is optional (needs C compiler on some platforms) + "${dashboard_venv}/bin/pip" install greenlet 2>/dev/null || true + } + else + "${dashboard_venv}/bin/pip" install fastapi uvicorn pydantic websockets sqlalchemy aiosqlite 2>&1 | tail -1 || { + log_warn "Failed to install dashboard dependencies" + log_warn "Dashboard will not be available" + } + "${dashboard_venv}/bin/pip" install greenlet 2>/dev/null || true + fi + else + log_warn "Failed to install dashboard dependencies" + log_warn "Run manually: python3 -m venv ${dashboard_venv} && ${dashboard_venv}/bin/pip install fastapi uvicorn sqlalchemy aiosqlite" + fi + fi + + # Start the FastAPI dashboard server + # Dashboard module is at project root (parent of autonomy/) + # LOKI_SKILL_DIR tells server.py where to find static files + LOKI_TLS_CERT="${LOKI_TLS_CERT:-}" LOKI_TLS_KEY="${LOKI_TLS_KEY:-}" \ + LOKI_SKILL_DIR="${skill_dir}" PYTHONPATH="${skill_dir}" nohup "$python_cmd" -m dashboard.server > "$log_file" 2>&1 & + DASHBOARD_PID=$! + register_pid "$DASHBOARD_PID" "dashboard" "port=${DASHBOARD_PORT:-57374}" + + # Save PID for later cleanup + mkdir -p .loki/dashboard + if ! echo "$DASHBOARD_PID" > .loki/dashboard/dashboard.pid; then + log_error "Failed to write dashboard PID file" + kill "$DASHBOARD_PID" 2>/dev/null || true + return 1 + fi + + sleep 2 + + if kill -0 "$DASHBOARD_PID" 2>/dev/null; then + DASHBOARD_LAST_ALIVE=$(date +%s) + log_info "Dashboard started (PID: $DASHBOARD_PID)" + log_info "Dashboard: ${CYAN}${url_scheme}://127.0.0.1:$DASHBOARD_PORT/${NC}" + + # Open in browser (macOS) + if [[ "$OSTYPE" == "darwin"* ]]; then + open "${url_scheme}://127.0.0.1:$DASHBOARD_PORT/" 2>/dev/null || true + fi + return 0 + else + log_warn "Dashboard failed to start" + log_warn "Check logs: $log_file" + DASHBOARD_PID="" + return 1 + fi +} + +stop_dashboard() { + # Try to kill using saved PID + if [ -n "$DASHBOARD_PID" ]; then + kill "$DASHBOARD_PID" 2>/dev/null || true + wait "$DASHBOARD_PID" 2>/dev/null || true + unregister_pid "$DASHBOARD_PID" + fi + + # Also try PID file + if [ -f ".loki/dashboard/dashboard.pid" ]; then + local saved_pid=$(cat ".loki/dashboard/dashboard.pid" 2>/dev/null) + if [ -n "$saved_pid" ]; then + kill "$saved_pid" 2>/dev/null || true + unregister_pid "$saved_pid" + fi + rm -f ".loki/dashboard/dashboard.pid" + fi +} + +# Handle dashboard crash: restart silently without triggering pause handler +# This prevents a killed dashboard from being misinterpreted as a user interrupt +handle_dashboard_crash() { + # Reentrancy guard: prevent recursive restarts from signal handlers + if [[ "$_DASHBOARD_RESTARTING" == "true" ]]; then + return 0 + fi + + if [[ "${ENABLE_DASHBOARD:-true}" != "true" ]]; then + return 0 + fi + + local dashboard_pid_file="${TARGET_DIR:-.}/.loki/dashboard/dashboard.pid" + if [[ ! -f "$dashboard_pid_file" ]]; then + return 0 + fi + + local dpid + dpid=$(cat "$dashboard_pid_file" 2>/dev/null) + if [[ -z "$dpid" ]]; then + return 0 + fi + + # Dashboard is still alive, nothing to do + if kill -0 "$dpid" 2>/dev/null; then + return 0 + fi + + # Dashboard is dead -- restart it silently (with throttle) + DASHBOARD_RESTART_COUNT=${DASHBOARD_RESTART_COUNT:-0} + local max_restarts=${DASHBOARD_MAX_RESTARTS:-3} + + if [ "$DASHBOARD_RESTART_COUNT" -ge "$max_restarts" ]; then + log_warn "Dashboard restart limit reached ($max_restarts) - disabling dashboard for this session" + ENABLE_DASHBOARD=false + return 1 + fi + + DASHBOARD_RESTART_COUNT=$((DASHBOARD_RESTART_COUNT + 1)) + log_info "Dashboard process $dpid exited, restarting silently (attempt $DASHBOARD_RESTART_COUNT/$max_restarts)..." + emit_event_json "dashboard_crash" \ + "pid=$dpid" \ + "action=auto_restart" \ + "attempt=$DASHBOARD_RESTART_COUNT" \ + "autonomy_mode=$AUTONOMY_MODE" + DASHBOARD_PID="" + rm -f "$dashboard_pid_file" + _DASHBOARD_RESTARTING=true + start_dashboard + _DASHBOARD_RESTARTING=false + return 0 +} + +# Check if a signal was caused by a child process dying (e.g., dashboard) +# rather than an actual user interrupt. Returns 0 if it was a child crash +# (handled silently), 1 if it was a real interrupt. +is_child_process_signal() { + local dashboard_pid_file="${TARGET_DIR:-.}/.loki/dashboard/dashboard.pid" + local now + now=$(date +%s) + + # If dashboard PID is set and dashboard is now dead, check timing to + # distinguish a real Ctrl+C (which kills both parent and child in the + # same process group) from an independent child crash. + if [ -n "$DASHBOARD_PID" ] && ! kill -0 "$DASHBOARD_PID" 2>/dev/null; then + local time_since_alive=$((now - DASHBOARD_LAST_ALIVE)) + if [ "$DASHBOARD_LAST_ALIVE" -gt 0 ] && [ "$time_since_alive" -lt 2 ]; then + # Dashboard was alive very recently -- it likely died from the same + # SIGINT that we just received (process group signal). Treat as real + # user interrupt, but still restart the dashboard in the background. + handle_dashboard_crash + return 1 + fi + # Dashboard has been dead for a while -- this is an independent crash + handle_dashboard_crash + return 0 + fi + + # Check PID file as fallback + if [ -f "$dashboard_pid_file" ]; then + local dpid + dpid=$(cat "$dashboard_pid_file" 2>/dev/null) + if [ -n "$dpid" ] && ! kill -0 "$dpid" 2>/dev/null; then + handle_dashboard_crash + return 0 + fi + fi + + return 1 +} + +#=============================================================================== +# Calculate Exponential Backoff +#=============================================================================== + +calculate_wait() { + local retry="$1" + local wait_time=$((BASE_WAIT * (2 ** retry))) + + # Add jitter (0-30 seconds) + local jitter=$((RANDOM % 30)) + wait_time=$((wait_time + jitter)) + + # Cap at max wait + if [ $wait_time -gt $MAX_WAIT ]; then + wait_time=$MAX_WAIT + fi + + echo $wait_time +} + +#=============================================================================== +# Rate Limit Detection +#=============================================================================== + +# Detect if output contains rate limit indicators (provider-agnostic) +# Returns: 0 if rate limit detected, 1 otherwise +is_rate_limited() { + local log_file="$1" + + # Generic patterns that work across all providers + # - HTTP 429 status code + # - "rate limit" / "rate-limit" / "ratelimit" text + # - "too many requests" text + # - "quota exceeded" text + # - "request limit" text + # - "retry after" / "retry-after" headers + if grep -qiE '(429|rate.?limit|too many requests|quota exceeded|request limit|retry.?after)' "$log_file" 2>/dev/null; then + return 0 + fi + + # Claude-specific: "resets Xam/pm" format + if grep -qE 'resets [0-9]+[ap]m' "$log_file" 2>/dev/null; then + return 0 + fi + + return 1 +} + +# Parse Claude-specific reset time from log +# Returns: seconds to wait, or 0 if no reset time found +parse_claude_reset_time() { + local log_file="$1" + + # Look for rate limit message like "resets 4am" or "resets 10pm" + local reset_time=$(grep -o "resets [0-9]\+[ap]m" "$log_file" 2>/dev/null | tail -1 | grep -o "[0-9]\+[ap]m") + + if [ -z "$reset_time" ]; then + echo 0 + return + fi + + # Parse the reset time + local hour=$(echo "$reset_time" | grep -o "[0-9]\+") + local ampm=$(echo "$reset_time" | grep -o "[ap]m") + + # Convert to 24-hour format + if [ "$ampm" = "pm" ] && [ "$hour" -ne 12 ]; then + hour=$((hour + 12)) + elif [ "$ampm" = "am" ] && [ "$hour" -eq 12 ]; then + hour=0 + fi + + # Get current time + local current_hour=$(date +%H) + local current_min=$(date +%M) + local current_sec=$(date +%S) + + # Calculate seconds until reset + local current_secs=$((current_hour * 3600 + current_min * 60 + current_sec)) + local reset_secs=$((hour * 3600)) + + local wait_secs=$((reset_secs - current_secs)) + + # If reset time is in the past, it means tomorrow + if [ $wait_secs -le 0 ]; then + wait_secs=$((wait_secs + 86400)) # Add 24 hours + fi + + # Add 2 minute buffer to ensure limit is actually reset + wait_secs=$((wait_secs + 120)) + + echo $wait_secs +} + +# Parse Retry-After header value (common across providers) +# Returns: seconds to wait, or 0 if not found +parse_retry_after() { + local log_file="$1" + + # Look for Retry-After header (case insensitive) + # Format: "Retry-After: 60" or "retry-after: 60" + local retry_secs=$(grep -ioE 'retry.?after:?\s*[0-9]+' "$log_file" 2>/dev/null | tail -1 | grep -oE '[0-9]+$') + + if [ -n "$retry_secs" ]; then + echo "$retry_secs" + else + echo 0 + fi +} + +# Calculate default backoff based on provider rate limit +# Uses PROVIDER_RATE_LIMIT_RPM from loaded provider config +# Returns: seconds to wait +calculate_rate_limit_backoff() { + local rpm="${PROVIDER_RATE_LIMIT_RPM:-50}" + + # Calculate wait time based on RPM + # If RPM is 50, that's ~1.2 requests per second + # Default backoff: 60 seconds / RPM * 60 = wait for 1 minute window + # But add some buffer, so wait for 2 minute windows + local wait_secs=$((120 * 60 / rpm)) + + # Minimum 60 seconds, maximum 300 seconds for default backoff + if [ "$wait_secs" -lt 60 ]; then + wait_secs=60 + elif [ "$wait_secs" -gt 300 ]; then + wait_secs=300 + fi + + echo $wait_secs +} + +# Detect rate limit from log and calculate wait time until reset +# Provider-agnostic: checks generic patterns first, then provider-specific +# Returns: seconds to wait, or 0 if no rate limit detected +detect_rate_limit() { + local log_file="$1" + + # First check if rate limited at all + if ! is_rate_limited "$log_file"; then + echo 0 + return + fi + + # Rate limit detected - now determine wait time + local wait_secs=0 + + # Try provider-specific reset time parsing + case "${PROVIDER_NAME:-claude}" in + claude) + wait_secs=$(parse_claude_reset_time "$log_file") + ;; + codex|gemini|cline|aider|*) + # No provider-specific reset time format known + # Fall through to generic parsing + ;; + esac + + # If no provider-specific time, try generic Retry-After header + if [ "$wait_secs" -eq 0 ]; then + wait_secs=$(parse_retry_after "$log_file") + fi + + # If still no specific time, use calculated backoff based on provider RPM + if [ "$wait_secs" -eq 0 ]; then + wait_secs=$(calculate_rate_limit_backoff) + log_debug "Using calculated backoff (${PROVIDER_RATE_LIMIT_RPM:-50} RPM): ${wait_secs}s" + fi + + echo $wait_secs +} + +# Format seconds into human-readable time +format_duration() { + local secs="$1" + local hours=$((secs / 3600)) + local mins=$(((secs % 3600) / 60)) + + if [ $hours -gt 0 ]; then + echo "${hours}h ${mins}m" + else + echo "${mins}m" + fi +} + +#=============================================================================== +# Check Completion +#=============================================================================== + +is_completed() { + # Check orchestrator state + if [ -f ".loki/state/orchestrator.json" ]; then + if command -v python3 &> /dev/null; then + local phase=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('currentPhase', ''))" 2>/dev/null || echo "") + # Accept various completion states + if [ "$phase" = "COMPLETED" ] || [ "$phase" = "complete" ] || [ "$phase" = "finalized" ] || [ "$phase" = "growth-loop" ]; then + return 0 + fi + fi + fi + + # Check for completion marker + if [ -f ".loki/COMPLETED" ]; then + return 0 + fi + + return 1 +} + +# Check if estimated cost has exceeded the budget limit +# Returns 0 (exceeded) or 1 (within budget / no limit set) +check_budget_limit() { + [[ -z "$BUDGET_LIMIT" ]] && return 1 # No limit set + + # Validate BUDGET_LIMIT is a valid number (prevent shell injection) + if ! python3 -c "float('${BUDGET_LIMIT//[^0-9.]/}')" 2>/dev/null; then + log_error "BUDGET_LIMIT is not a valid number: $BUDGET_LIMIT" + return 1 + fi + + local current_cost=0 + local efficiency_dir=".loki/metrics/efficiency" + + # Calculate cost from per-iteration efficiency files (same source as /api/cost) + if [ -d "$efficiency_dir" ]; then + current_cost=$(python3 -c " +import json, glob +total = 0.0 +pricing = { + 'opus': {'input': 5.00, 'output': 25.00}, + 'sonnet': {'input': 3.00, 'output': 15.00}, + 'haiku': {'input': 1.00, 'output': 5.00}, + 'gpt-5.3-codex': {'input': 1.50, 'output': 12.00}, + 'gemini-3-pro': {'input': 1.25, 'output': 10.00}, + 'gemini-3-flash': {'input': 0.10, 'output': 0.40}, +} +for f in glob.glob('${efficiency_dir}/*.json'): + try: + d = json.load(open(f)) + cost = d.get('cost_usd') + if cost is not None: + total += float(cost) + else: + model = d.get('model', 'sonnet').lower() + p = pricing.get(model, pricing['sonnet']) + inp = d.get('input_tokens', 0) + out = d.get('output_tokens', 0) + total += (inp / 1_000_000) * p['input'] + (out / 1_000_000) * p['output'] + except: pass +print(round(total, 4)) +" 2>/dev/null || echo "0") + fi + + # Compare against limit + local exceeded + exceeded=$(python3 -c " +import sys +try: + cost = float(sys.argv[1]) + limit = float(sys.argv[2]) + print(1 if cost >= limit else 0) +except (ValueError, IndexError): + print(0) +" "$current_cost" "$BUDGET_LIMIT" 2>/dev/null || echo "0") + + if [[ "$exceeded" == "1" ]]; then + log_warn "BUDGET LIMIT REACHED: \$${current_cost} >= \$${BUDGET_LIMIT}" + touch ".loki/PAUSE" + mkdir -p ".loki/signals" + echo "{\"type\":\"BUDGET_EXCEEDED\",\"limit\":${BUDGET_LIMIT},\"current\":${current_cost},\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > ".loki/signals/BUDGET_EXCEEDED" + # Update budget.json with latest usage + cat > ".loki/metrics/budget.json" << BUDGETUPD_EOF +{ + "limit": $BUDGET_LIMIT, + "budget_limit": $BUDGET_LIMIT, + "budget_used": $current_cost, + "exceeded": true, + "exceeded_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} +BUDGETUPD_EOF + emit_event_json "budget_exceeded" \ + "limit=${BUDGET_LIMIT}" \ + "current=${current_cost}" \ + "iteration=$ITERATION_COUNT" + return 0 + fi + + # Update budget.json with current usage (not exceeded) + if [ -n "$current_cost" ] && [ "$current_cost" != "0" ]; then + cat > ".loki/metrics/budget.json" << BUDGETUPD_EOF +{ + "limit": $BUDGET_LIMIT, + "budget_limit": $BUDGET_LIMIT, + "budget_used": $current_cost, + "exceeded": false +} +BUDGETUPD_EOF + fi + + return 1 +} + +#=============================================================================== +# Watchdog: Process Supervision and Health Monitoring +# Opt-in via LOKI_WATCHDOG=true. Detects crashed dashboard and agent processes. +#=============================================================================== + +watchdog_check() { + [[ "$WATCHDOG_ENABLED" != "true" ]] && return 0 + + # Check dashboard health + local dashboard_pid_file="${TARGET_DIR:-.}/.loki/dashboard/dashboard.pid" + if [[ -f "$dashboard_pid_file" ]]; then + local dpid + dpid=$(cat "$dashboard_pid_file" 2>/dev/null) + if [[ -n "$dpid" ]] && ! kill -0 "$dpid" 2>/dev/null; then + log_warn "WATCHDOG: Dashboard process $dpid is dead" + emit_event_json "watchdog_alert" \ + "process=dashboard" \ + "pid=$dpid" \ + "action=detected_dead" + + # Auto-restart dashboard if it was previously running + if [[ "${ENABLE_DASHBOARD:-true}" == "true" ]]; then + log_info "WATCHDOG: Restarting dashboard..." + DASHBOARD_PID="" + rm -f "$dashboard_pid_file" + start_dashboard + fi + else + # Dashboard is alive -- update last-alive timestamp + DASHBOARD_LAST_ALIVE=$(date +%s) + fi + fi + + # Check for zombie/dead agents + local agents_file=".loki/state/agents.json" + if [[ -f "$agents_file" ]]; then + local dead_count=0 + local agent_pids + agent_pids=$(python3 -c " +import json, sys +try: + agents = json.load(open('$agents_file')) + for a in agents: + pid = a.get('pid') + status = a.get('status', '') + if pid and status not in ('terminated', 'completed', 'failed', 'crashed'): + print(f\"{pid}:{a.get('id','unknown')}\") +except Exception: + pass +" 2>/dev/null || true) + + if [[ -n "$agent_pids" ]]; then + while IFS=: read -r apid aid; do + [[ -z "$apid" ]] && continue + if ! kill -0 "$apid" 2>/dev/null; then + dead_count=$((dead_count + 1)) + log_warn "WATCHDOG: Agent $aid (PID $apid) is dead" + # Update agent status in agents.json + python3 -c " +import json +try: + with open('$agents_file', 'r') as f: + agents = json.load(f) + for a in agents: + if str(a.get('pid')) == '$apid': + a['status'] = 'crashed' + a['crashed_at'] = '$(date -u +%Y-%m-%dT%H:%M:%SZ)' + with open('$agents_file', 'w') as f: + json.dump(agents, f, indent=2) +except Exception: + pass +" 2>/dev/null || true + fi + done <<< "$agent_pids" + + if [[ $dead_count -gt 0 ]]; then + emit_event_json "watchdog_alert" \ + "process=agents" \ + "dead_count=$dead_count" + fi + fi + fi + + return 0 +} + +# Check if completion promise is fulfilled in log output +check_completion_promise() { + local log_file="$1" + + # Check for the completion promise phrase in recent log output + if grep -q "COMPLETION PROMISE FULFILLED" "$log_file" 2>/dev/null; then + return 0 + fi + + # Check for custom completion promise text + if [ -n "$COMPLETION_PROMISE" ] && grep -qF "$COMPLETION_PROMISE" "$log_file" 2>/dev/null; then + return 0 + fi + + return 1 +} + +# Check if max iterations reached +check_max_iterations() { + if [ $ITERATION_COUNT -ge $MAX_ITERATIONS ]; then + log_warn "Max iterations ($MAX_ITERATIONS) reached. Stopping." + return 0 + fi + return 1 +} + +# Check if context clear was requested by agent +check_context_clear_signal() { + if [ -f ".loki/signals/CONTEXT_CLEAR_REQUESTED" ]; then + log_info "Context clear signal detected from agent" + rm -f ".loki/signals/CONTEXT_CLEAR_REQUESTED" + return 0 + fi + return 1 +} + +# Load latest ledger content for context injection +load_ledger_context() { + local ledger_content="" + + # Find most recent ledger + local latest_ledger=$(ls -t .loki/memory/ledgers/LEDGER-*.md 2>/dev/null | head -1) + + if [ -n "$latest_ledger" ] && [ -f "$latest_ledger" ]; then + ledger_content=$(cat "$latest_ledger" | head -100) + echo "$ledger_content" + fi +} + +# Load recent handoffs for context +load_handoff_context() { + local handoff_content="" + + # Find most recent handoff (last 24 hours) + local recent_handoff=$(find .loki/memory/handoffs -name "*.md" -mtime -1 2>/dev/null | head -1) + + if [ -n "$recent_handoff" ] && [ -f "$recent_handoff" ]; then + handoff_content=$(cat "$recent_handoff" | head -80) + echo "$handoff_content" + fi +} + +# Write structured handoff document (v5.49.0) +# Produces both JSON (machine-readable) and markdown (human-readable) handoffs +# Called at end of session or before context clear +write_structured_handoff() { + local reason="${1:-session_end}" + local handoff_dir=".loki/memory/handoffs" + mkdir -p "$handoff_dir" + + local timestamp + timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + local file_ts + file_ts=$(date +"%Y%m%d-%H%M%S") + local handoff_json="$handoff_dir/${file_ts}.json" + local handoff_md="$handoff_dir/${file_ts}.md" + + # Gather structured data + local files_modified="" + files_modified=$(git diff --name-only HEAD 2>/dev/null | head -20 | tr '\n' ',' | sed 's/,$//') + local recent_commits="" + recent_commits=$(git log --oneline -5 2>/dev/null | tr '\n' '|' | sed 's/|$//') + local pending_tasks=0 + local completed_tasks=0 + if [ -f ".loki/queue/pending.json" ]; then + pending_tasks=$(_QF=".loki/queue/pending.json" python3 -c "import json,os;print(len(json.load(open(os.environ['_QF']))))" 2>/dev/null || echo "0") + fi + if [ -f ".loki/queue/completed.json" ]; then + completed_tasks=$(_QF=".loki/queue/completed.json" python3 -c "import json,os;print(len(json.load(open(os.environ['_QF']))))" 2>/dev/null || echo "0") + fi + + # Write JSON handoff + _H_TS="$timestamp" \ + _H_REASON="$reason" \ + _H_ITER="${ITERATION_COUNT:-0}" \ + _H_FILES="$files_modified" \ + _H_COMMITS="$recent_commits" \ + _H_PENDING="$pending_tasks" \ + _H_COMPLETED="$completed_tasks" \ + _H_JSON="$handoff_json" \ + python3 -c " +import json, os +handoff = { + 'schema_version': '1.0.0', + 'timestamp': os.environ['_H_TS'], + 'reason': os.environ['_H_REASON'], + 'iteration': int(os.environ['_H_ITER']), + 'files_modified': [f for f in os.environ['_H_FILES'].split(',') if f], + 'recent_commits': [c for c in os.environ['_H_COMMITS'].split('|') if c], + 'task_status': { + 'pending': int(os.environ['_H_PENDING']), + 'completed': int(os.environ['_H_COMPLETED']) + }, + 'open_questions': [], + 'key_decisions': [], + 'blockers': [] +} +with open(os.environ['_H_JSON'], 'w') as f: + json.dump(handoff, f, indent=2) +" 2>/dev/null || log_warn "Failed to write structured handoff JSON" + + # Write markdown companion + cat > "$handoff_md" << HANDOFF_EOF +# Session Handoff - $timestamp + +**Reason:** $reason +**Iteration:** ${ITERATION_COUNT:-0} + +## Files Modified +$files_modified + +## Recent Commits +$(git log --oneline -5 2>/dev/null || echo "none") + +## Task Status +- Pending: $pending_tasks +- Completed: $completed_tasks + +## Notes +Session handoff generated automatically. +HANDOFF_EOF + + log_info "Structured handoff written to $handoff_json" +} + +# Load recent handoffs for context (reads both JSON and markdown) +load_handoff_context() { + local handoff_content="" + + # Prefer JSON handoffs (structured, v5.49.0+) + local recent_json + recent_json=$(find .loki/memory/handoffs -name "*.json" -mtime -1 2>/dev/null | sort -r | head -1) + + if [ -n "$recent_json" ] && [ -f "$recent_json" ]; then + handoff_content=$(_HF="$recent_json" python3 -c " +import json, os +try: + h = json.load(open(os.environ['_HF'])) + parts = [] + parts.append(f\"Handoff from {h.get('timestamp','unknown')} (reason: {h.get('reason','unknown')})\") + parts.append(f\"Iteration: {h.get('iteration',0)}\") + files = h.get('files_modified', []) + if files: + parts.append(f\"Modified files: {', '.join(files[:10])}\") + tasks = h.get('task_status', {}) + parts.append(f\"Tasks - pending: {tasks.get('pending',0)}, completed: {tasks.get('completed',0)}\") + for q in h.get('open_questions', []): + parts.append(f\"Open question: {q}\") + for b in h.get('blockers', []): + parts.append(f\"Blocker: {b}\") + print(' | '.join(parts)) +except Exception as e: + print(f'Error reading handoff: {e}') +" 2>/dev/null) + echo "$handoff_content" + return + fi + + # Fallback to markdown handoffs (pre-v5.49.0) + local recent_handoff + recent_handoff=$(find .loki/memory/handoffs -name "*.md" -mtime -1 2>/dev/null | sort -r | head -1) + + if [ -n "$recent_handoff" ] && [ -f "$recent_handoff" ]; then + handoff_content=$(cat "$recent_handoff" | head -80) + echo "$handoff_content" + fi +} + +# Load relevant learnings +load_learnings_context() { + local learnings="" + + # Get recent learnings (last 7 days) + for learning in $(find .loki/memory/learnings -name "*.md" -mtime -7 2>/dev/null | head -5); do + learnings+="$(head -30 "$learning")\n---\n" + done + + echo -e "$learnings" +} + +# Load pre-computed relevant learnings from CLI startup (SYN-008) +# Reads .loki/state/memory-context.json written by load_memory_context() in CLI +# Note: Different from get_relevant_learnings() which writes to relevant-learnings.json +load_startup_learnings() { + local learnings_file=".loki/state/memory-context.json" + local target_dir="${TARGET_DIR:-.}" + + # Check if file exists (written by CLI at startup) + if [ ! -f "$target_dir/$learnings_file" ]; then + return + fi + + # Parse and format the pre-loaded memories with JSON schema validation + python3 -c " +import sys +import json + +def validate_memory_context_schema(data): + '''Validate JSON has expected schema for memory-context.json''' + # Check required top-level keys + if not isinstance(data, dict): + return False, 'Root must be an object' + + required_keys = ['memory_count', 'memories'] + for key in required_keys: + if key not in data: + return False, f'Missing required key: {key}' + + # Validate types + if not isinstance(data.get('memory_count'), int): + return False, 'memory_count must be an integer' + if not isinstance(data.get('memories'), list): + return False, 'memories must be an array' + + # Validate memory items + for i, m in enumerate(data.get('memories', [])): + if not isinstance(m, dict): + return False, f'memories[{i}] must be an object' + # Optional: validate expected fields exist + for field in ['source', 'score', 'summary']: + if field in m: + # Just check they're the right types if present + if field == 'score' and not isinstance(m[field], (int, float)): + return False, f'memories[{i}].score must be a number' + + return True, None + +try: + with open('$target_dir/$learnings_file', 'r') as f: + data = json.load(f) + + # Validate schema before using + valid, error = validate_memory_context_schema(data) + if not valid: + sys.stderr.write(f'Invalid memory-context.json schema: {error}\\n') + sys.exit(0) + + memories = data.get('memories', []) + if not memories: + sys.exit(0) + + print('STARTUP LEARNINGS (pre-loaded):') + for m in memories[:5]: + source = m.get('source', 'unknown') + summary = m.get('summary', '')[:100] + score = m.get('score', 0) + if summary: + print(f'- [{source}|{score}] {summary}') +except json.JSONDecodeError as e: + sys.stderr.write(f'Invalid JSON in memory-context.json: {e}\\n') +except Exception as e: + pass # Silently fail for other errors +" 2>/dev/null +} + +#=============================================================================== +# Memory System Integration +#=============================================================================== + +# Retrieve relevant memories from the new memory system +retrieve_memory_context() { + local goal="$1" + local phase="$2" + local target_dir="${TARGET_DIR:-.}" + + # Check if memory system is available + if [ ! -d "$target_dir/.loki/memory" ] || [ ! -f "$target_dir/.loki/memory/index.json" ]; then + return + fi + + # Use Python to retrieve relevant context + # Pass parameters via environment variables to prevent command injection + _LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \ + _LOKI_GOAL="$goal" _LOKI_PHASE="$phase" \ + python3 << 'PYEOF' 2>/dev/null +import sys +import os + +project_dir = os.environ.get('_LOKI_PROJECT_DIR', '') +target_dir = os.environ.get('_LOKI_TARGET_DIR', '.') +goal = os.environ.get('_LOKI_GOAL', '') +phase = os.environ.get('_LOKI_PHASE', '') + +sys.path.insert(0, project_dir) +try: + from memory.retrieval import MemoryRetrieval + from memory.storage import MemoryStorage + import json + storage = MemoryStorage(f'{target_dir}/.loki/memory') + retriever = MemoryRetrieval(storage) + context = {'goal': goal, 'phase': phase} + results = retriever.retrieve_task_aware(context, top_k=3) + if results: + print('RELEVANT MEMORIES:') + for r in results[:3]: + summary = r.get('summary', r.get('pattern', ''))[:100] + source = r.get('source', 'memory') + print(f'- [{source}] {summary}') +except Exception as e: + pass # Silently fail if memory not available +PYEOF +} + +# Store episode trace after task completion +store_episode_trace() { + local task_id="$1" + local outcome="$2" + local phase="$3" + local goal="$4" + local duration="$5" + local target_dir="${TARGET_DIR:-.}" + + # Only store if memory system exists + if [ ! -d "$target_dir/.loki/memory" ]; then + return + fi + + # Pass parameters via environment variables to prevent command injection + _LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \ + _LOKI_TASK_ID="$task_id" _LOKI_OUTCOME="$outcome" _LOKI_PHASE="$phase" \ + _LOKI_GOAL="$goal" _LOKI_DURATION="$duration" \ + python3 << 'PYEOF' 2>/dev/null +import sys +import os + +project_dir = os.environ.get('_LOKI_PROJECT_DIR', '') +target_dir = os.environ.get('_LOKI_TARGET_DIR', '.') +task_id = os.environ.get('_LOKI_TASK_ID', '') +outcome = os.environ.get('_LOKI_OUTCOME', '') +phase = os.environ.get('_LOKI_PHASE', '') +goal = os.environ.get('_LOKI_GOAL', '') +duration = os.environ.get('_LOKI_DURATION', '0') + +sys.path.insert(0, project_dir) +try: + from memory.engine import MemoryEngine + from memory.schemas import EpisodeTrace + from datetime import datetime, timezone + engine = MemoryEngine(f'{target_dir}/.loki/memory') + engine.initialize() + trace = EpisodeTrace.create( + task_id=task_id, + agent='loki-orchestrator', + phase=phase, + goal=goal, + outcome=outcome, + duration_seconds=int(duration) if duration.isdigit() else 0 + ) + engine.store_episode(trace) +except Exception as e: + pass # Silently fail +PYEOF +} + +# Automatic episode capture with enriched context (v6.15.0) +# Captures git changes, files modified, and RARV phase automatically +# after every iteration -- no manual invocation needed. +auto_capture_episode() { + local iteration="$1" + local exit_code="$2" + local rarv_phase="$3" + local goal="$4" + local duration="$5" + local log_file="$6" + local target_dir="${TARGET_DIR:-.}" + + # Only capture if memory system exists + if [ ! -d "$target_dir/.loki/memory" ]; then + return + fi + + # Collect git context: files modified in this iteration + local files_modified="" + files_modified=$(cd "$target_dir" && git diff --name-only HEAD 2>/dev/null | head -20 | tr '\n' '|' || true) + + # Collect last git commit if any + local git_commit="" + git_commit=$(cd "$target_dir" && git rev-parse --short HEAD 2>/dev/null || true) + + # Determine outcome + local outcome="success" + if [ "$exit_code" -ne 0 ]; then + outcome="failure" + fi + + # Pass all context via environment variables (prevents injection) + _LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \ + _LOKI_ITERATION="$iteration" _LOKI_EXIT_CODE="$exit_code" \ + _LOKI_RARV_PHASE="$rarv_phase" _LOKI_GOAL="$goal" \ + _LOKI_DURATION="$duration" _LOKI_OUTCOME="$outcome" \ + _LOKI_FILES_MODIFIED="$files_modified" _LOKI_GIT_COMMIT="$git_commit" \ + python3 << 'PYEOF' 2>/dev/null || true +import sys +import os + +project_dir = os.environ.get('_LOKI_PROJECT_DIR', '') +target_dir = os.environ.get('_LOKI_TARGET_DIR', '.') +iteration = os.environ.get('_LOKI_ITERATION', '0') +rarv_phase = os.environ.get('_LOKI_RARV_PHASE', 'iteration') +goal = os.environ.get('_LOKI_GOAL', '') +duration = os.environ.get('_LOKI_DURATION', '0') +outcome = os.environ.get('_LOKI_OUTCOME', 'success') +files_modified = os.environ.get('_LOKI_FILES_MODIFIED', '') +git_commit = os.environ.get('_LOKI_GIT_COMMIT', '') + +sys.path.insert(0, project_dir) +try: + from memory.engine import MemoryEngine, create_storage + from memory.schemas import EpisodeTrace + + storage = create_storage(f'{target_dir}/.loki/memory') + engine = MemoryEngine(storage=storage, base_path=f'{target_dir}/.loki/memory') + engine.initialize() + + trace = EpisodeTrace.create( + task_id=f'iteration-{iteration}', + agent='loki-orchestrator', + phase=rarv_phase.upper() if rarv_phase else 'ACT', + goal=goal, + ) + trace.outcome = outcome + trace.duration_seconds = int(duration) if duration.isdigit() else 0 + trace.git_commit = git_commit if git_commit else None + trace.files_modified = [f for f in files_modified.split('|') if f] if files_modified else [] + + engine.store_episode(trace) +except Exception: + pass # Silently fail -- memory capture must never break the loop +PYEOF +} + +# Run memory consolidation pipeline +run_memory_consolidation() { + local target_dir="${TARGET_DIR:-.}" + + # Only run if memory system exists + if [ ! -d "$target_dir/.loki/memory" ]; then + return + fi + + # Pass parameters via environment variables for consistency + _LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \ + python3 << 'PYEOF' 2>/dev/null || true +import sys +import os + +project_dir = os.environ.get('_LOKI_PROJECT_DIR', '') +target_dir = os.environ.get('_LOKI_TARGET_DIR', '.') + +sys.path.insert(0, project_dir) +try: + from memory.consolidation import ConsolidationPipeline + from memory.storage import MemoryStorage + storage = MemoryStorage(f'{target_dir}/.loki/memory') + pipeline = ConsolidationPipeline(storage) + result = pipeline.consolidate(since_hours=24) + if result.patterns_created > 0: + print(f'Memory consolidation: {result.patterns_created} patterns created') +except Exception as e: + pass # Silently fail +PYEOF +} + +#=============================================================================== +# Knowledge Graph Integration (v6.0.0) +# Enrich prompts with cross-project patterns and store new learnings. +#=============================================================================== + +# Enrich prompt context with relevant cross-project patterns +enrich_from_knowledge_graph() { + local context="$1" + local max_patterns="${2:-5}" + + _LOKI_KG_CONTEXT="$context" _LOKI_KG_MAX="$max_patterns" \ + _LOKI_PROJECT_DIR="$PROJECT_DIR" \ + python3 << 'PYEOF' 2>/dev/null || echo "" +import sys +import os +import json + +project_dir = os.environ.get('_LOKI_PROJECT_DIR', '') +context = os.environ.get('_LOKI_KG_CONTEXT', '') +max_results = int(os.environ.get('_LOKI_KG_MAX', '5')) + +if not project_dir: + sys.exit(0) +sys.path.insert(0, project_dir) +try: + from memory.knowledge_graph import OrganizationKnowledgeGraph + kg = OrganizationKnowledgeGraph() + patterns = kg.query_patterns(context, max_results=max_results) + if patterns: + output = "\n## Cross-Project Knowledge (from knowledge graph)\n" + for p in patterns: + name = p.get('name', p.get('pattern', 'unnamed')) + category = p.get('category', '') + desc = p.get('description', '') + output += f"- **{name}** ({category}): {desc}\n" + print(output) +except Exception: + pass +PYEOF +} + +# Store new patterns to the knowledge graph after successful iterations +store_to_knowledge_graph() { + local target_dir="${TARGET_DIR:-.}" + + _LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \ + python3 << 'PYEOF' 2>/dev/null || true +import sys +import os + +project_dir = os.environ.get('_LOKI_PROJECT_DIR', '') +target_dir = os.environ.get('_LOKI_TARGET_DIR', '.') + +sys.path.insert(0, project_dir) +try: + from memory.knowledge_graph import OrganizationKnowledgeGraph + from pathlib import Path + + kg = OrganizationKnowledgeGraph() + project_dirs = [Path(target_dir)] + + # Extract and store patterns + patterns = kg.extract_patterns(project_dirs) + if patterns: + patterns = kg.deduplicate_patterns(patterns) + kg.save_patterns(patterns) + + # Rebuild graph + kg.build_graph(project_dirs) + kg.save_graph() +except Exception: + pass +PYEOF +} + +#=============================================================================== +# Save/Load Wrapper State +#=============================================================================== + +save_state() { + local retry_count="$1" + local status="$2" + local exit_code="$3" + + cat > ".loki/autonomy-state.json" << EOF +{ + "retryCount": $retry_count, + "status": "$status", + "lastExitCode": $exit_code, + "lastRun": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "prdPath": "$(printf '%s' "${PRD_PATH:-}" | sed 's/\\/\\\\/g; s/"/\\"/g')", + "pid": $$, + "maxRetries": $MAX_RETRIES, + "baseWait": $BASE_WAIT +} +EOF +} + +load_state() { + if [ -f ".loki/autonomy-state.json" ]; then + if command -v python3 &> /dev/null; then + # Load both retry count and status from previous session + local prev_status + prev_status=$(python3 -c "import json; print(json.load(open('.loki/autonomy-state.json')).get('status', 'unknown'))" 2>/dev/null || echo "unknown") + RETRY_COUNT=$(python3 -c "import json; print(json.load(open('.loki/autonomy-state.json')).get('retryCount', 0))" 2>/dev/null || echo "0") + + # Reset retry count if previous session ended in a terminal state + # This allows new sessions to start fresh after failures + case "$prev_status" in + failed|max_iterations_reached|max_retries_exceeded) + log_info "Previous session ended with status: $prev_status. Resetting retry count." + RETRY_COUNT=0 + ;; + esac + else + RETRY_COUNT=0 + fi + else + RETRY_COUNT=0 + fi +} + +# Load tasks from queue files for prompt injection +# Supports both array format [...] and object format {"tasks": [...]} +load_queue_tasks() { + local task_injection="" + + # Helper Python script to extract and format tasks + # Handles both formats, truncates long actions, normalizes newlines + local extract_script=' +import json +import sys + +def extract_tasks(filepath, prefix): + try: + data = json.load(open(filepath)) + # Support both formats: [...] and {"tasks": [...]} + tasks = data.get("tasks", data) if isinstance(data, dict) else data + if not isinstance(tasks, list): + return "" + + results = [] + for i, task in enumerate(tasks[:3]): # Limit to first 3 tasks + if not isinstance(task, dict): + continue + task_id = task.get("id") or "unknown" + task_type = task.get("type") or "unknown" + payload = task.get("payload", {}) + + # Extract action from payload + if isinstance(payload, dict): + action = payload.get("action") or payload.get("goal") or "" + else: + action = str(payload) if payload else "" + + # Normalize: remove newlines, truncate to 500 chars + action = str(action).replace("\n", " ").replace("\r", "")[:500] + if len(str(task.get("payload", {}).get("action", ""))) > 500: + action += "..." + + results.append(f"{prefix}[{i+1}] id={task_id} type={task_type}: {action}") + + return " ".join(results) + except: + return "" + +# Check in-progress first +in_progress = extract_tasks(".loki/queue/in-progress.json", "TASK") +pending = extract_tasks(".loki/queue/pending.json", "PENDING") + +output = [] +if in_progress: + output.append(f"IN-PROGRESS TASKS (EXECUTE THESE): {in_progress}") +if pending: + output.append(f"PENDING: {pending}") + +print(" | ".join(output)) +' + + # First check in-progress tasks (highest priority) + if [ -f ".loki/queue/in-progress.json" ] || [ -f ".loki/queue/pending.json" ]; then + task_injection=$(python3 -c "$extract_script" 2>/dev/null || echo "") + fi + + echo "$task_injection" +} + +#=============================================================================== +# Build Resume Prompt +#=============================================================================== + +build_prompt() { + local retry="$1" + local prd="$2" + local iteration="$3" + + # Build SDLC phases configuration + local phases="" + [ "$PHASE_UNIT_TESTS" = "true" ] && phases="${phases}UNIT_TESTS," + [ "$PHASE_API_TESTS" = "true" ] && phases="${phases}API_TESTS," + [ "$PHASE_E2E_TESTS" = "true" ] && phases="${phases}E2E_TESTS," + [ "$PHASE_SECURITY" = "true" ] && phases="${phases}SECURITY," + [ "$PHASE_INTEGRATION" = "true" ] && phases="${phases}INTEGRATION," + [ "$PHASE_CODE_REVIEW" = "true" ] && phases="${phases}CODE_REVIEW," + [ "$PHASE_WEB_RESEARCH" = "true" ] && phases="${phases}WEB_RESEARCH," + [ "$PHASE_PERFORMANCE" = "true" ] && phases="${phases}PERFORMANCE," + [ "$PHASE_ACCESSIBILITY" = "true" ] && phases="${phases}ACCESSIBILITY," + [ "$PHASE_REGRESSION" = "true" ] && phases="${phases}REGRESSION," + [ "$PHASE_UAT" = "true" ] && phases="${phases}UAT," + phases="${phases%,}" # Remove trailing comma + + # Ralph Wiggum Mode - Reason-Act-Reflect-VERIFY cycle with self-verification loop (Boris Cherny pattern) + local rarv_instruction="RALPH WIGGUM MODE ACTIVE. Use Reason-Act-Reflect-VERIFY cycle: 1) REASON - READ .loki/CONTINUITY.md including 'Mistakes & Learnings' section to avoid past errors. CHECK .loki/state/relevant-learnings.json for cross-project learnings from previous projects (mistakes to avoid, patterns to apply). Check .loki/state/ and .loki/queue/, identify next task. CHECK .loki/state/resources.json for system resource warnings - if CPU or memory is high, reduce parallel agent spawning or pause non-critical tasks. Limit to MAX_PARALLEL_AGENTS=${MAX_PARALLEL_AGENTS}. If queue empty, find new improvements. 2) ACT - Execute task, write code, commit changes atomically (git checkpoint). 3) REFLECT - Update .loki/CONTINUITY.md with progress, update state, identify NEXT improvement. Save valuable learnings for future projects. 4) VERIFY - Run automated tests (unit, integration, E2E), check compilation/build, verify against spec. IF VERIFICATION FAILS: a) Capture error details (stack trace, logs), b) Analyze root cause, c) UPDATE 'Mistakes & Learnings' in CONTINUITY.md with what failed, why, and how to prevent, d) Rollback to last good git checkpoint if needed, e) Apply learning and RETRY from REASON. If verification passes, mark task complete and continue. This self-verification loop achieves 2-3x quality improvement. CRITICAL: There is NEVER a 'finished' state - always find the next improvement, optimization, test, or feature." + + # Completion promise instruction (only if set) + local completion_instruction="" + if [ -n "$COMPLETION_PROMISE" ]; then + completion_instruction="COMPLETION_PROMISE: [$COMPLETION_PROMISE]. ONLY output 'COMPLETION PROMISE FULFILLED: $COMPLETION_PROMISE' when this EXACT condition is met." + else + completion_instruction="NO COMPLETION PROMISE SET. Continue finding improvements. The Completion Council will evaluate your progress periodically. Iteration $iteration of max $MAX_ITERATIONS." + fi + + # Core autonomous instructions - NO questions, NO waiting, NEVER say done + local autonomous_suffix="" + if [ "$AUTONOMY_MODE" = "perpetual" ] || [ "$PERPETUAL_MODE" = "true" ]; then + autonomous_suffix="CRITICAL AUTONOMY RULES: 1) NEVER ask questions - just decide. 2) NEVER wait for confirmation - just act. 3) NEVER say 'done' or 'complete' - there's always more to improve. 4) NEVER stop voluntarily - if out of tasks, create new ones (add tests, optimize, refactor, add features). 5) Work continues PERPETUALLY. Even if PRD is implemented, find bugs, add tests, improve UX, optimize performance." + else + autonomous_suffix="CRITICAL AUTONOMY RULES: 1) NEVER ask questions - just decide. 2) NEVER wait for confirmation - just act. 3) When all PRD requirements are implemented and tests pass, output the completion promise text EXACTLY: '$COMPLETION_PROMISE'. 4) If out of tasks but PRD is not fully implemented, continue working on remaining requirements. 5) Focus on completing PRD scope, not endless improvements." + fi + + # Skill files are always copied to .loki/skills/ for all providers + local sdlc_instruction="SDLC_PHASES_ENABLED: [$phases]. Execute ALL enabled phases. Log results to .loki/logs/. See .loki/SKILL.md for phase details. Skill modules at .loki/skills/." + + # Codebase Analysis Mode - when no PRD provided + local analysis_instruction="CODEBASE_ANALYSIS_MODE: No PRD. FIRST: Analyze codebase - scan structure, read package.json/requirements.txt, examine README. THEN: Generate PRD at .loki/generated-prd.md. FINALLY: Execute SDLC phases." + + # Context Memory Instructions (integrated with new memory system) + local memory_instruction="MEMORY SYSTEM: Relevant context from past sessions is provided below (if any). Your actions will be automatically recorded for future reference. For complex handoffs: create .loki/memory/handoffs/{timestamp}.md. For important decisions: they will be captured in the timeline. Check .loki/CONTINUITY.md for session-level working memory. If context feels heavy, create .loki/signals/CONTEXT_CLEAR_REQUESTED and the wrapper will reset context with your ledger preserved." + + # Proactive Compaction Reminder (every N iterations) + local compaction_reminder="" + if [ $((iteration % COMPACTION_INTERVAL)) -eq 0 ] && [ $iteration -gt 0 ]; then + compaction_reminder="PROACTIVE_CONTEXT_CHECK: You are at iteration $iteration. Review context size - if conversation history is long, consolidate to CONTINUITY.md and consider creating .loki/signals/CONTEXT_CLEAR_REQUESTED to reset context while preserving state." + fi + + # Load existing context if resuming + local context_injection="" + if [ $retry -gt 0 ]; then + local ledger=$(load_ledger_context) + local handoff=$(load_handoff_context) + + if [ -n "$ledger" ]; then + context_injection="PREVIOUS_LEDGER_STATE: $ledger" + fi + if [ -n "$handoff" ]; then + context_injection="$context_injection RECENT_HANDOFF: $handoff" + fi + fi + + # Load pre-computed startup learnings (from CLI load_memory_context) + # These are loaded once at CLI start and cached in .loki/state/memory-context.json + local startup_learnings="" + if [ $iteration -eq 1 ]; then + startup_learnings=$(load_startup_learnings) + if [ -n "$startup_learnings" ]; then + context_injection="$context_injection $startup_learnings" + fi + fi + + # Retrieve relevant memories from new memory system + local memory_context="" + # Determine goal for memory retrieval + local goal_for_memory="" + if [ -n "$prd" ]; then + goal_for_memory="Execute PRD at $prd" + else + goal_for_memory="Analyze codebase and generate improvements" + fi + # Determine current phase + local phase_for_memory="iteration-$iteration" + memory_context=$(retrieve_memory_context "$goal_for_memory" "$phase_for_memory") + if [ -n "$memory_context" ]; then + context_injection="$context_injection $memory_context" + fi + + # Gate failure injection (v6.7.0) - tells LLM what to fix + local gate_failure_context="" + if [ -f "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt" ]; then + local failures + failures=$(cat "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt") + gate_failure_context="QUALITY GATE FAILURES FROM PREVIOUS ITERATION: [$failures]. " + if [ -f "${TARGET_DIR:-.}/.loki/quality/static-analysis.json" ]; then + local sa_summary + sa_summary=$(python3 -c "import json; d=json.load(open('${TARGET_DIR:-.}/.loki/quality/static-analysis.json')); print(d.get('summary',''))" 2>/dev/null || echo "") + [ -n "$sa_summary" ] && gate_failure_context="${gate_failure_context}Static analysis: ${sa_summary}. " + fi + if [ -f "${TARGET_DIR:-.}/.loki/quality/test-results.json" ]; then + local test_summary + test_summary=$(python3 -c "import json; d=json.load(open('${TARGET_DIR:-.}/.loki/quality/test-results.json')); print(d.get('summary',''))" 2>/dev/null || echo "") + [ -n "$test_summary" ] && gate_failure_context="${gate_failure_context}Tests: ${test_summary}. " + fi + gate_failure_context="${gate_failure_context}FIX THESE ISSUES BEFORE PROCEEDING WITH NEW WORK." + fi + + # Human directive injection (from HUMAN_INPUT.md) + # NOTE: Do NOT unset LOKI_HUMAN_INPUT here - build_prompt runs in a subshell + # (command substitution) so unset would not affect the parent shell. + # The caller (run_autonomous) clears it after consuming the prompt. + local human_directive="" + if [ -n "${LOKI_HUMAN_INPUT:-}" ]; then + human_directive="HUMAN_DIRECTIVE (PRIORITY): $LOKI_HUMAN_INPUT Execute this directive BEFORE continuing normal tasks." + fi + + # Queue task injection (from dashboard or API) + local queue_tasks="" + queue_tasks=$(load_queue_tasks) + if [ -n "$queue_tasks" ]; then + queue_tasks="QUEUED_TASKS (PRIORITY): $queue_tasks. Execute these tasks BEFORE finding new improvements." + fi + + # Build memory context section (only if we have context) + local memory_context_section="" + if [ -n "$context_injection" ]; then + memory_context_section="CONTEXT: $context_injection" + fi + + # PRD Checklist status injection (v5.44.0) + local checklist_status="" + if [ -n "$prd" ] && [ ! -f ".loki/checklist/checklist.json" ]; then + # First iteration with PRD but no checklist yet: instruct AI to create it + checklist_status="PRD_CHECKLIST_INIT: Create .loki/checklist/checklist.json from the PRD. Extract requirements into categories with items. Each item needs: id, title, description, priority (critical|major|minor), and verification checks (file_exists, file_contains, tests_pass, grep_codebase, command). This checklist will be auto-verified every ${CHECKLIST_INTERVAL:-5} iterations." + elif type checklist_summary &>/dev/null && [ -f ".loki/checklist/verification-results.json" ]; then + checklist_status=$(checklist_summary 2>/dev/null || true) + if [ -n "$checklist_status" ]; then + checklist_status="PRD_CHECKLIST_STATUS: ${checklist_status}. Review failing items and prioritize fixing them in this iteration." + fi + fi + + # App Runner status injection (v5.45.0) + local app_runner_info="" + if [ -f ".loki/app-runner/state.json" ]; then + app_runner_info=$(python3 -c " +import json +try: + d = json.load(open('.loki/app-runner/state.json')) + s = d.get('status', '') + if s == 'running': + print('APP_RUNNING_AT: ' + d.get('url', '') + ' (auto-restarts on code changes). Method: ' + d.get('method', '')) + elif s == 'crashed': + print('APP_CRASHED: Application has crashed ' + str(d.get('crash_count', 0)) + ' times. Check .loki/app-runner/app.log for errors.') +except: pass +" 2>/dev/null || true) + fi + + # Playwright verification status injection (v5.46.0) + local playwright_info="" + if [ -f ".loki/verification/playwright-results.json" ]; then + playwright_info=$(python3 -c " +import json +try: + d = json.load(open('.loki/verification/playwright-results.json')) + if d.get('passed'): + print('PLAYWRIGHT_SMOKE_TEST: PASSED - App loads correctly.') + else: + errors = d.get('errors', []) + checks = d.get('checks', {}) + failing = [k for k, v in checks.items() if not v] + print('PLAYWRIGHT_SMOKE_TEST: FAILED - ' + ', '.join(failing[:3]) + ('. Errors: ' + '; '.join(errors[:3]) if errors else '')) +except: pass +" 2>/dev/null || true) + fi + + # BMAD context injection (if available) + local bmad_context="" + if [[ -f ".loki/bmad-metadata.json" ]]; then + local bmad_arch="" + if [[ -f ".loki/bmad-architecture-summary.md" ]]; then + bmad_arch=$(head -c 16000 ".loki/bmad-architecture-summary.md") + fi + local bmad_tasks="" + if [[ -f ".loki/bmad-tasks.json" ]]; then + bmad_tasks=$(python3 -c " +import json, sys +try: + with open('.loki/bmad-tasks.json') as f: + data = json.load(f) + out = json.dumps(data, indent=None) + if len(out) > 32000 and isinstance(data, list): + while len(json.dumps(data, indent=None)) > 32000 and data: + data.pop() + out = json.dumps(data, indent=None) + print(out[:32000]) +except: pass +" 2>/dev/null) + fi + local bmad_validation="" + if [[ -f ".loki/bmad-validation.md" ]]; then + bmad_validation=$(head -c 8000 ".loki/bmad-validation.md") + fi + bmad_context="BMAD_CONTEXT: This project uses BMAD Method structured artifacts. Architecture decisions and epic/story breakdown are provided below." + if [[ -n "$bmad_arch" ]]; then + bmad_context="$bmad_context ARCHITECTURE DECISIONS: $bmad_arch" + fi + if [[ -n "$bmad_tasks" ]]; then + bmad_context="$bmad_context EPIC/STORY TASKS (from BMAD): $bmad_tasks" + fi + if [[ -n "$bmad_validation" ]]; then + bmad_context="$bmad_context ARTIFACT VALIDATION: $bmad_validation" + fi + fi + + # OpenSpec delta context injection (if available) + local openspec_context="" + if [[ -f ".loki/openspec/delta-context.json" ]]; then + openspec_context=$(_DELTA_FILE=".loki/openspec/delta-context.json" python3 -c " +import json, os +try: + with open(os.environ['_DELTA_FILE']) as f: + data = json.load(f) + parts = ['OPENSPEC DELTA CONTEXT:'] + for domain, deltas in data.get('deltas', {}).items(): + for req in deltas.get('added', []): + parts.append(f' ADDED [{domain}]: {req[\"name\"]} - Create new code following existing patterns') + for req in deltas.get('modified', []): + parts.append(f' MODIFIED [{domain}]: {req[\"name\"]} - Find and update existing code, do NOT create new files. Previously: {req.get(\"previously\", \"N/A\")}') + for req in deltas.get('removed', []): + parts.append(f' REMOVED [{domain}]: {req[\"name\"]} - Deprecate or remove. Reason: {req.get(\"reason\", \"N/A\")}') + parts.append(f'Complexity: {data.get(\"complexity\", \"unknown\")}') + print(' '.join(parts)) +except Exception: + pass +" 2>/dev/null || true) + fi + + # Degraded providers with small models need simplified prompts + # Full RARV/SDLC instructions overwhelm models < 30B parameters + if [ "${PROVIDER_DEGRADED:-false}" = "true" ]; then + local prd_content="" + if [ -n "$prd" ] && [ -f "$prd" ]; then + prd_content=$(head -c 4000 "$prd") + fi + + if [ $retry -eq 0 ]; then + if [ -n "$prd" ]; then + echo "You are a coding assistant. Read and implement the requirements from the PRD below. Write working code, run tests if possible, and commit changes. ${human_directive:+Priority: $human_directive} ${queue_tasks:+Tasks: $queue_tasks} PRD contents: $prd_content" + else + echo "You are a coding assistant. Analyze this codebase and suggest improvements. Write working code and commit changes. ${human_directive:+Priority: $human_directive} ${queue_tasks:+Tasks: $queue_tasks}" + fi + else + if [ -n "$prd" ]; then + echo "You are a coding assistant. Continue working on iteration $iteration. Review what exists, implement remaining PRD requirements, fix any issues, add tests. ${human_directive:+Priority: $human_directive} ${queue_tasks:+Tasks: $queue_tasks} PRD contents: $prd_content" + else + echo "You are a coding assistant. Continue working on iteration $iteration. Review what exists, improve code, fix bugs, add tests. ${human_directive:+Priority: $human_directive} ${queue_tasks:+Tasks: $queue_tasks}" + fi + fi + else + if [ $retry -eq 0 ]; then + if [ -n "$prd" ]; then + echo "Loki Mode with PRD at $prd. $human_directive $gate_failure_context $queue_tasks $bmad_context $openspec_context $checklist_status $app_runner_info $playwright_info $memory_context_section $rarv_instruction $memory_instruction $compaction_reminder $completion_instruction $sdlc_instruction $autonomous_suffix" + else + echo "Loki Mode. $human_directive $gate_failure_context $queue_tasks $bmad_context $openspec_context $checklist_status $app_runner_info $playwright_info $memory_context_section $analysis_instruction $rarv_instruction $memory_instruction $compaction_reminder $completion_instruction $sdlc_instruction $autonomous_suffix" + fi + else + if [ -n "$prd" ]; then + echo "Loki Mode - Resume iteration #$iteration (retry #$retry). PRD: $prd. $human_directive $gate_failure_context $queue_tasks $bmad_context $openspec_context $checklist_status $app_runner_info $playwright_info $memory_context_section $rarv_instruction $memory_instruction $compaction_reminder $completion_instruction $sdlc_instruction $autonomous_suffix" + else + echo "Loki Mode - Resume iteration #$iteration (retry #$retry). $human_directive $gate_failure_context $queue_tasks $bmad_context $openspec_context $checklist_status $app_runner_info $playwright_info $memory_context_section Use .loki/generated-prd.md if exists. $rarv_instruction $memory_instruction $compaction_reminder $completion_instruction $sdlc_instruction $autonomous_suffix" + fi + fi + fi +} + +#=============================================================================== +# BMAD Task Queue Population +#=============================================================================== + +# Populate the task queue from BMAD epic/story artifacts +# Only runs once -- skips if queue was already populated from BMAD +populate_bmad_queue() { + # Skip if no BMAD tasks file + if [[ ! -f ".loki/bmad-tasks.json" ]]; then + return 0 + fi + + # Skip if already populated (marker file) + if [[ -f ".loki/queue/.bmad-populated" ]]; then + log_info "BMAD queue already populated, skipping" + return 0 + fi + + log_step "Populating task queue from BMAD stories..." + + # Ensure queue directory exists + mkdir -p ".loki/queue" + + # Read BMAD tasks and create queue entries + python3 << 'BMAD_QUEUE_EOF' +import json +import os +import sys + +bmad_tasks_path = ".loki/bmad-tasks.json" +pending_path = ".loki/queue/pending.json" +completed_stories_path = ".loki/bmad-completed-stories.json" + +try: + with open(bmad_tasks_path, "r") as f: + bmad_data = json.load(f) +except (json.JSONDecodeError, FileNotFoundError) as e: + print(f"Warning: Could not read BMAD tasks: {e}", file=sys.stderr) + sys.exit(0) + +# Load completed stories from sprint-status (if available) +completed_stories = set() +if os.path.exists(completed_stories_path): + try: + with open(completed_stories_path, "r") as f: + completed_list = json.load(f) + if isinstance(completed_list, list): + completed_stories = {s.lower() for s in completed_list if isinstance(s, str)} + except (json.JSONDecodeError, FileNotFoundError): + pass + +# Extract stories from BMAD structure +# Supports both flat list and nested epic/story format +stories = [] +if isinstance(bmad_data, list): + stories = bmad_data +elif isinstance(bmad_data, dict): + # Handle {"epics": [...]} or {"tasks": [...]} formats + for key in ("epics", "tasks", "stories"): + if key in bmad_data: + items = bmad_data[key] + if isinstance(items, list): + for item in items: + if isinstance(item, dict) and "stories" in item: + # Epic with nested stories + epic_name = item.get("title", item.get("name", "")) + for story in item["stories"]: + if isinstance(story, dict): + story.setdefault("epic", epic_name) + stories.append(story) + else: + stories.append(item) + break + +if not stories: + print("No BMAD stories found to queue", file=sys.stderr) + sys.exit(0) + +# Filter out completed stories from sprint-status +skipped_count = 0 +if completed_stories: + filtered = [] + for story in stories: + if isinstance(story, dict): + title = story.get("title", story.get("name", "")).lower() + if title and title in completed_stories: + skipped_count += 1 + continue + filtered.append(story) + stories = filtered + if skipped_count > 0: + print(f"Skipped {skipped_count} completed stories (from sprint-status.yml)", file=sys.stderr) + +# Load existing pending tasks (if any) +existing = [] +if os.path.exists(pending_path): + try: + with open(pending_path, "r") as f: + data = json.load(f) + if isinstance(data, list): + existing = data + elif isinstance(data, dict) and "tasks" in data: + existing = data["tasks"] + except (json.JSONDecodeError, FileNotFoundError): + existing = [] + +# Convert BMAD stories to queue task format (with deduplication) +existing_ids = {t.get("id") for t in existing if isinstance(t, dict)} +for i, story in enumerate(stories): + if not isinstance(story, dict): + continue + task_id = f"bmad-{i+1}" + if task_id in existing_ids: + continue + task = { + "id": task_id, + "title": story.get("title", story.get("name", f"BMAD Story {i+1}")), + "description": story.get("description", story.get("action", "")), + "priority": story.get("priority", "medium"), + "source": "bmad", + } + epic = story.get("epic", "") + if epic: + task["epic"] = epic + acceptance = story.get("acceptance_criteria", story.get("criteria", [])) + if acceptance: + task["acceptance_criteria"] = acceptance + existing.append(task) + +# Write updated pending queue +with open(pending_path, "w") as f: + json.dump(existing, f, indent=2) + +msg = f"Added {len(stories)} BMAD stories to task queue" +if skipped_count > 0: + msg += f" (skipped {skipped_count} completed)" +print(msg) +BMAD_QUEUE_EOF + + if [[ $? -ne 0 ]]; then + log_warn "Failed to populate BMAD queue (python3 error)" + return 0 + fi + + # Mark as populated so we don't re-add on restart + touch ".loki/queue/.bmad-populated" + log_info "BMAD queue population complete" +} + +#=============================================================================== +# OpenSpec Task Queue Population +#=============================================================================== + +# Populate the task queue from OpenSpec task artifacts +# Only runs once -- skips if queue was already populated from OpenSpec +populate_openspec_queue() { + # Skip if no OpenSpec tasks file + if [[ ! -f ".loki/openspec-tasks.json" ]]; then + return 0 + fi + + # Skip if already populated (marker file) + if [[ -f ".loki/queue/.openspec-populated" ]]; then + log_info "OpenSpec queue already populated, skipping" + return 0 + fi + + log_step "Populating task queue from OpenSpec tasks..." + + # Ensure queue directory exists + mkdir -p ".loki/queue" + + # Read OpenSpec tasks and create queue entries + python3 << 'OPENSPEC_QUEUE_EOF' +import json +import sys + +openspec_tasks_path = ".loki/openspec-tasks.json" +pending_path = ".loki/queue/pending.json" + +try: + with open(openspec_tasks_path, "r") as f: + openspec_tasks = json.load(f) +except (json.JSONDecodeError, FileNotFoundError) as e: + print(f"Warning: Could not read OpenSpec tasks: {e}", file=sys.stderr) + sys.exit(0) + +# Load existing queue +existing = [] +try: + with open(pending_path, "r") as f: + existing = json.load(f) +except (json.JSONDecodeError, FileNotFoundError): + pass + +# Convert OpenSpec tasks to queue format (skip completed tasks) +for task in openspec_tasks: + if task.get("status") == "completed": + continue + queue_entry = { + "id": task.get("id", "openspec-unknown"), + "title": task.get("title", "Untitled"), + "description": f"[OpenSpec] {task.get('group', 'General')}: {task.get('title', '')}", + "priority": task.get("priority", "medium"), + "status": "pending", + "source": "openspec", + "metadata": { + "openspec_source": task.get("source", "tasks.md"), + "openspec_group": task.get("group", ""), + } + } + existing.append(queue_entry) + +with open(pending_path, "w") as f: + json.dump(existing, f, indent=2) + +pending_count = sum(1 for t in openspec_tasks if t.get('status') != 'completed') +if pending_count == 0: + print("WARNING: All OpenSpec tasks are already marked as completed. No tasks added to queue.", file=sys.stderr) + print("Check your tasks.md file -- all checkboxes are checked.", file=sys.stderr) +else: + print(f"Added {pending_count} OpenSpec tasks to queue") +OPENSPEC_QUEUE_EOF + + if [[ $? -ne 0 ]]; then + log_warn "Failed to populate OpenSpec queue (python3 error)" + return 0 + fi + + # Mark as populated so we don't re-add on restart + touch ".loki/queue/.openspec-populated" + log_info "OpenSpec queue population complete" +} + +#=============================================================================== +# Main Autonomous Loop +#=============================================================================== + +run_autonomous() { + local prd_path="$1" + + log_header "Starting Autonomous Execution" + + # Auto-detect PRD if not provided + if [ -z "$prd_path" ]; then + log_step "No PRD provided, searching for existing PRD files..." + local found_prd="" + + # Search common PRD file patterns (markdown and JSON) + for pattern in "PRD.md" "prd.md" "PRD.json" "prd.json" \ + "REQUIREMENTS.md" "requirements.md" "requirements.json" \ + "SPEC.md" "spec.md" "spec.json" \ + "docs/PRD.md" "docs/prd.md" "docs/PRD.json" "docs/prd.json" \ + "docs/REQUIREMENTS.md" "docs/requirements.md" "docs/requirements.json" \ + "docs/SPEC.md" "docs/spec.md" "docs/spec.json" \ + ".github/PRD.md" ".github/PRD.json" "PROJECT.md" "project.md" "project.json"; do + if [ -f "$pattern" ]; then + found_prd="$pattern" + break + fi + done + + if [ -n "$found_prd" ]; then + log_info "Found existing PRD: $found_prd" + prd_path="$found_prd" + elif [ -f ".loki/generated-prd.md" ]; then + log_info "Using previously generated PRD: .loki/generated-prd.md" + prd_path=".loki/generated-prd.md" + elif [ -f ".loki/generated-prd.json" ]; then + log_info "Using previously generated PRD: .loki/generated-prd.json" + prd_path=".loki/generated-prd.json" + else + log_info "No PRD found - will analyze codebase and generate one" + fi + fi + + log_info "PRD: ${prd_path:-Codebase Analysis Mode}" + log_info "Max retries: $MAX_RETRIES" + log_info "Max iterations: $MAX_ITERATIONS" + log_info "Completion promise: $COMPLETION_PROMISE" + log_info "Completion council: ${COUNCIL_ENABLED:-true} (${COUNCIL_SIZE:-3} members, ${COUNCIL_THRESHOLD:-2}/${COUNCIL_SIZE:-3} majority)" + log_info "Base wait: ${BASE_WAIT}s" + log_info "Max wait: ${MAX_WAIT}s" + log_info "Autonomy mode: $AUTONOMY_MODE" + if [ -n "$BUDGET_LIMIT" ]; then + log_info "Budget limit: \$$BUDGET_LIMIT" + fi + # Only show Claude-specific features for Claude provider + if [ "${PROVIDER_NAME:-claude}" = "claude" ]; then + log_info "Prompt repetition (Haiku): $PROMPT_REPETITION" + log_info "Confidence routing: $CONFIDENCE_ROUTING" + fi + echo "" + + load_state + local retry=$RETRY_COUNT + + # Initialize Completion Council (v5.25.0) + if type council_init &>/dev/null; then + council_init "$prd_path" + fi + + # PRD Quality Analysis and Checklist Init (v5.44.0) + if [ -n "$prd_path" ] && [ -f "$prd_path" ]; then + if [ -f "${SCRIPT_DIR}/prd-analyzer.py" ]; then + log_step "Analyzing PRD quality..." + python3 "${SCRIPT_DIR}/prd-analyzer.py" "$prd_path" \ + --output ".loki/prd-observations.md" \ + ${LOKI_INTERACTIVE_PRD:+--interactive} 2>/dev/null || true + fi + if type checklist_init &>/dev/null; then + checklist_init "$prd_path" + fi + fi + + # Auto-derive completion promise from PRD (v6.10.0) + # When PRD exists but no explicit promise, auto-derive one and switch to checkpoint mode + if [ -n "$prd_path" ] && [ -f "$prd_path" ] && [ -z "$COMPLETION_PROMISE" ]; then + if [ "${LOKI_AUTO_COMPLETION_PROMISE:-true}" = "true" ]; then + COMPLETION_PROMISE="All PRD requirements implemented and tests passing" + log_info "Auto-derived completion promise: $COMPLETION_PROMISE" + # PRD-driven work is finite; switch from perpetual to checkpoint + if [ "${LOKI_FORCE_PERPETUAL:-false}" != "true" ] && [ "$AUTONOMY_MODE" = "perpetual" ]; then + AUTONOMY_MODE="checkpoint" + PERPETUAL_MODE="false" + log_info "Switched autonomy mode: perpetual -> checkpoint (PRD-driven work is finite)" + fi + fi + fi + + # Populate task queue from BMAD artifacts (if present, runs once) + populate_bmad_queue + + # Populate task queue from OpenSpec artifacts (if present, runs once) + populate_openspec_queue + + # Check max iterations before starting + if check_max_iterations; then + log_error "Max iterations already reached. Reset with: rm .loki/autonomy-state.json" + return 1 + fi + + while [ $retry -lt $MAX_RETRIES ]; do + # Increment iteration count + ((ITERATION_COUNT++)) + + # Check max iterations + if check_max_iterations; then + save_state $retry "max_iterations_reached" 0 + return 0 + fi + + # Check for human intervention (PAUSE, HUMAN_INPUT.md, STOP) + check_human_intervention + local intervention_result=$? + case $intervention_result in + 1) continue ;; # PAUSE handled, restart loop + 2) return 0 ;; # STOP requested + esac + + # Check budget limit (creates PAUSE file if exceeded) + if check_budget_limit; then + log_warn "Session paused due to budget limit. Remove .loki/PAUSE to resume." + save_state $retry "budget_exceeded" 0 + continue # Will hit PAUSE check on next iteration + fi + + # Watchdog: periodic process health check (opt-in via LOKI_WATCHDOG=true) + if [[ "$WATCHDOG_ENABLED" == "true" ]]; then + local now_epoch + now_epoch=$(date +%s) + if (( now_epoch - LAST_WATCHDOG_CHECK >= WATCHDOG_INTERVAL )); then + watchdog_check + LAST_WATCHDOG_CHECK=$now_epoch + fi + fi + + # Auto-track iteration start (for dashboard task queue) + track_iteration_start "$ITERATION_COUNT" "$prd_path" + + local prompt=$(build_prompt $retry "$prd_path" $ITERATION_COUNT) + + # BUG #5 fix: Clear LOKI_HUMAN_INPUT in the parent shell after build_prompt + # consumed it. build_prompt runs in a subshell (command substitution), so + # any unset inside it does not affect the parent. Clear here to prevent + # the same directive from repeating every iteration. + if [ -n "${LOKI_HUMAN_INPUT:-}" ]; then + unset LOKI_HUMAN_INPUT + rm -f "${TARGET_DIR:-.}/.loki/HUMAN_INPUT.md" + fi + + echo "" + log_header "Attempt $((retry + 1)) of $MAX_RETRIES" + log_info "Prompt: $prompt" + echo "" + + save_state $retry "running" 0 + + # Run AI provider with live output + local start_time=$(date +%s) + local log_file=".loki/logs/autonomy-$(date +%Y%m%d).log" + local agent_log=".loki/logs/agent.log" + + # Ensure agent.log exists for dashboard real-time view + # (Dashboard reads this file for terminal output) + # Keep history but limit size to ~1MB to prevent memory issues + if [ -f "$agent_log" ] && [ "$(stat -f%z "$agent_log" 2>/dev/null || stat -c%s "$agent_log" 2>/dev/null)" -gt 1000000 ]; then + # Trim to last 500KB + tail -c 500000 "$agent_log" > "$agent_log.tmp" && mv "$agent_log.tmp" "$agent_log" + fi + touch "$agent_log" + echo "" >> "$agent_log" + echo "════════════════════════════════════════════════════════════════" >> "$agent_log" + echo " NEW SESSION - $(date)" >> "$agent_log" + echo "════════════════════════════════════════════════════════════════" >> "$agent_log" + + echo "" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${CYAN} ${PROVIDER_DISPLAY_NAME:-CLAUDE CODE} OUTPUT (live)${NC}" + if [ "${PROVIDER_DEGRADED:-false}" = "true" ]; then + echo -e "${YELLOW} [DEGRADED MODE: Sequential execution only]${NC}" + fi + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + + # Log start time (to both archival and dashboard logs) + echo "=== Session started at $(date) ===" | tee -a "$log_file" "$agent_log" + echo "=== Provider: ${PROVIDER_NAME:-claude} ===" | tee -a "$log_file" "$agent_log" + echo "=== Prompt (truncated): ${prompt:0:200}... ===" | tee -a "$log_file" "$agent_log" + + # Dynamic tier selection based on RARV cycle phase + CURRENT_TIER=$(get_rarv_tier "$ITERATION_COUNT") + local rarv_phase=$(get_rarv_phase_name "$ITERATION_COUNT") + local tier_param=$(get_provider_tier_param "$CURRENT_TIER") + echo "=== RARV Phase: $rarv_phase, Tier: $CURRENT_TIER ($tier_param) ===" | tee -a "$log_file" "$agent_log" + log_info "RARV Phase: $rarv_phase -> Tier: $CURRENT_TIER ($tier_param)" + + # Emit OTEL phase span (if OTEL is enabled) + if [ -n "${LOKI_OTEL_ENDPOINT:-}" ]; then + emit_event_pending "otel_span_start" \ + "span_name=rarv.phase.$rarv_phase" \ + "iteration=$ITERATION_COUNT" \ + "phase=$rarv_phase" \ + "tier=$CURRENT_TIER" + fi + + set +e + # Policy engine check (P0.5-2: blocks execution if policy denies) + local policy_context="{\"provider\":\"${PROVIDER_NAME:-claude}\",\"iteration\":$ITERATION_COUNT,\"tier\":\"$CURRENT_TIER\"}" + if ! check_policy "pre_execution" "$policy_context"; then + log_error "Execution blocked by policy engine" + save_state $retry "policy_blocked" 1 + track_iteration_complete "$ITERATION_COUNT" "1" + continue + fi + + # Audit: record CLI invocation + audit_agent_action "cli_invoke" "Starting iteration $ITERATION_COUNT" "provider=${PROVIDER_NAME:-claude},tier=$CURRENT_TIER" + + # Provider-specific invocation with dynamic tier selection + local exit_code=0 + case "${PROVIDER_NAME:-claude}" in + claude) + # Claude: Full features with stream-json output and agent tracking + # Uses dynamic tier for model selection based on RARV phase + # Pass tier to Python via environment for dashboard display + { LOKI_CURRENT_MODEL="$tier_param" \ + claude --dangerously-skip-permissions --model "$tier_param" -p "$prompt" \ + --output-format stream-json --verbose 2>&1 | \ + tee -a "$log_file" "$agent_log" | \ + python3 -u -c ' +import sys +import json +import os +from datetime import datetime, timezone + +# ANSI colors +CYAN = "\033[0;36m" +GREEN = "\033[0;32m" +YELLOW = "\033[1;33m" +MAGENTA = "\033[0;35m" +DIM = "\033[2m" +NC = "\033[0m" + +# Get current model tier from environment (set by run.sh dynamic tier selection) +CURRENT_MODEL = os.environ.get("LOKI_CURRENT_MODEL", "sonnet") + +# Agent tracking +AGENTS_FILE = ".loki/state/agents.json" +QUEUE_IN_PROGRESS = ".loki/queue/in-progress.json" +active_agents = {} # tool_id -> agent_info +orchestrator_id = "orchestrator-main" +session_start = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + +def init_orchestrator(): + """Initialize the main orchestrator agent (always visible).""" + active_agents[orchestrator_id] = { + "agent_id": orchestrator_id, + "tool_id": orchestrator_id, + "agent_type": "orchestrator", + "model": CURRENT_MODEL, + "current_task": "Initializing...", + "status": "active", + "spawned_at": session_start, + "tasks_completed": [], + "tool_count": 0 + } + save_agents() + +def update_orchestrator_task(tool_name, description=""): + """Update orchestrator current task based on tool usage.""" + if orchestrator_id in active_agents: + active_agents[orchestrator_id]["tool_count"] = active_agents[orchestrator_id].get("tool_count", 0) + 1 + if description: + active_agents[orchestrator_id]["current_task"] = f"{tool_name}: {description[:80]}" + else: + active_agents[orchestrator_id]["current_task"] = f"Using {tool_name}..." + save_agents() + +def load_agents(): + """Load existing agents from file.""" + try: + if os.path.exists(AGENTS_FILE): + with open(AGENTS_FILE, "r") as f: + data = json.load(f) + return {a.get("tool_id", a.get("agent_id")): a for a in data if isinstance(a, dict)} + except: + pass + return {} + +def save_agents(): + """Save agents to file for dashboard.""" + try: + os.makedirs(os.path.dirname(AGENTS_FILE), exist_ok=True) + agents_list = list(active_agents.values()) + with open(AGENTS_FILE, "w") as f: + json.dump(agents_list, f, indent=2) + except Exception as e: + print(f"{YELLOW}[Agent save error: {e}]{NC}", file=sys.stderr) + +def save_in_progress(tasks): + """Save in-progress tasks to queue file.""" + try: + os.makedirs(os.path.dirname(QUEUE_IN_PROGRESS), exist_ok=True) + with open(QUEUE_IN_PROGRESS, "w") as f: + json.dump(tasks, f, indent=2) + except: + pass + +def process_stream(): + global active_agents + active_agents = load_agents() + + # Always show the main orchestrator + init_orchestrator() + print(f"{MAGENTA}[Orchestrator Active]{NC} Main agent started", flush=True) + + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + msg_type = data.get("type", "") + + if msg_type == "assistant": + # Extract and print assistant text + message = data.get("message", {}) + content = message.get("content", []) + for item in content: + if item.get("type") == "text": + text = item.get("text", "") + if text: + print(text, end="", flush=True) + elif item.get("type") == "tool_use": + tool = item.get("name", "unknown") + tool_id = item.get("id", "") + tool_input = item.get("input", {}) + + # Extract description based on tool type + tool_desc = "" + if tool == "Read": + tool_desc = tool_input.get("file_path", "") + elif tool == "Edit" or tool == "Write": + tool_desc = tool_input.get("file_path", "") + elif tool == "Bash": + tool_desc = tool_input.get("description", tool_input.get("command", "")[:60]) + elif tool == "Grep": + tool_desc = "pattern: " + tool_input.get("pattern", "") + elif tool == "Glob": + tool_desc = tool_input.get("pattern", "") + + # Update orchestrator with current tool activity + update_orchestrator_task(tool, tool_desc) + + # Track Agent tool calls (agent spawning) + if tool == "Agent": + agent_type = tool_input.get("subagent_type", "general-purpose") + description = tool_input.get("description", "") + model = tool_input.get("model", "sonnet") + + agent_info = { + "agent_id": f"agent-{tool_id[:8]}", + "tool_id": tool_id, + "agent_type": agent_type, + "model": model, + "current_task": description, + "status": "active", + "spawned_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "tasks_completed": [] + } + active_agents[tool_id] = agent_info + save_agents() + print(f"\n{MAGENTA}[Agent Spawned: {agent_type}]{NC} {description}", flush=True) + + # Track TodoWrite for task updates + elif tool == "TodoWrite": + todos = tool_input.get("todos", []) + in_progress = [t for t in todos if t.get("status") == "in_progress"] + save_in_progress([{"id": f"todo-{i}", "type": "todo", "payload": {"action": t.get("content", "")}} for i, t in enumerate(in_progress)]) + print(f"\n{CYAN}[Tasks]{NC} {len(todos)} items", flush=True) + + elif msg_type == "user": + # Tool results - check for agent completion + content = data.get("message", {}).get("content", []) + for item in content: + if item.get("type") == "tool_result": + tool_id = item.get("tool_use_id", "") + + # Mark agent as completed if it was a spawned agent + if tool_id in active_agents: + active_agents[tool_id]["status"] = "completed" + active_agents[tool_id]["completed_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + save_agents() + print(f"\n{GREEN}[Agent Complete]{NC} {active_agents[tool_id].get('current_task', '')}", flush=True) + + elif msg_type == "result": + # Session complete - mark all agents as completed + completed_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + for agent_id in active_agents: + if active_agents[agent_id].get("status") == "active": + active_agents[agent_id]["status"] = "completed" + active_agents[agent_id]["completed_at"] = completed_at + active_agents[agent_id]["current_task"] = "Session complete" + + # Add session stats to orchestrator + if orchestrator_id in active_agents: + tool_count = active_agents[orchestrator_id].get("tool_count", 0) + active_agents[orchestrator_id]["tasks_completed"].append(f"{tool_count} tools used") + + save_agents() + print(f"\n{GREEN}[Session complete]{NC}", flush=True) + is_error = data.get("is_error", False) + sys.exit(1 if is_error else 0) + + except json.JSONDecodeError: + pass + except Exception as e: + pass + +if __name__ == "__main__": + try: + process_stream() + except KeyboardInterrupt: + sys.exit(130) + except BrokenPipeError: + sys.exit(0) +' + } && exit_code=0 || exit_code=$? + ;; + + codex) + # Codex: Degraded mode - no stream-json, no agent tracking + # Uses positional prompt after exec subcommand + # Note: Effort is set via env var, not CLI flag + # Uses dynamic tier from RARV phase (tier_param already set above) + { CODEX_MODEL_REASONING_EFFORT="$tier_param" \ + codex exec --full-auto \ + "$prompt" 2>&1 | tee -a "$log_file" "$agent_log"; \ + } && exit_code=0 || exit_code=$? + ;; + + gemini) + # Gemini: Degraded mode - no stream-json, no agent tracking + # Uses invoke_gemini helper for rate limit fallback to flash model + local model="${PROVIDER_MODEL:-${GEMINI_DEFAULT_PRO:-gemini-3-pro-preview}}" + local fallback="${PROVIDER_MODEL_FALLBACK:-${GEMINI_DEFAULT_FLASH:-gemini-3-flash-preview}}" + echo "[loki] Gemini model: $model (fallback: $fallback), tier: $tier_param" >> "$log_file" + echo "[loki] Gemini model: $model (fallback: $fallback), tier: $tier_param" >> "$agent_log" + + # Try primary model, fallback on rate limit + local tmp_output + tmp_output=$(mktemp) + { gemini --approval-mode=yolo --model "$model" "$prompt" < /dev/null 2>&1 | tee "$tmp_output" | tee -a "$log_file" "$agent_log"; \ + } && exit_code=0 || exit_code=$? + + if [[ $exit_code -ne 0 ]] && grep -qiE "(rate.?limit|429|quota|resource.?exhausted)" "$tmp_output"; then + log_warn "Rate limit hit on $model, falling back to $fallback" + echo "[loki] Fallback to $fallback due to rate limit" >> "$log_file" + gemini --approval-mode=yolo --model "$fallback" "$prompt" < /dev/null 2>&1 | tee -a "$log_file" "$agent_log" + exit_code=${PIPESTATUS[0]} + fi + rm -f "$tmp_output" + ;; + + cline) + # Cline: Tier 2 - near-full mode with subagents and MCP + echo "[loki] Cline model: ${LOKI_CLINE_MODEL:-default}, tier: $tier_param" >> "$log_file" + echo "[loki] Cline model: ${LOKI_CLINE_MODEL:-default}, tier: $tier_param" >> "$agent_log" + { invoke_cline "$prompt" 2>&1 | tee -a "$log_file" "$agent_log"; \ + } && exit_code=0 || exit_code=$? + ;; + aider) + # Aider: Tier 3 - degraded mode, 18+ providers + echo "[loki] Aider model: ${AIDER_DEFAULT_MODEL:-${LOKI_AIDER_MODEL:-claude-sonnet-4-5-20250929}}, tier: $tier_param" >> "$log_file" + echo "[loki] Aider model: ${AIDER_DEFAULT_MODEL:-${LOKI_AIDER_MODEL:-claude-sonnet-4-5-20250929}}, tier: $tier_param" >> "$agent_log" + { invoke_aider "$prompt" 2>&1 | tee -a "$log_file" "$agent_log"; \ + } && exit_code=0 || exit_code=$? + ;; + + *) + log_error "Unknown provider: ${PROVIDER_NAME:-unknown}" + local exit_code=1 + ;; + esac + + echo "" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + + # Log end time + echo "=== Session ended at $(date) with exit code $exit_code ===" >> "$log_file" + + local end_time=$(date +%s) + local duration=$((end_time - start_time)) + + log_info "${PROVIDER_DISPLAY_NAME:-Claude} exited with code $exit_code after ${duration}s" + save_state $retry "exited" $exit_code + + # Auto-track iteration completion (for dashboard task queue) + track_iteration_complete "$ITERATION_COUNT" "$exit_code" + + # End OTEL phase span (if OTEL is enabled) + if [ -n "${LOKI_OTEL_ENDPOINT:-}" ]; then + emit_event_pending "otel_span_end" \ + "span_name=rarv.phase.$rarv_phase" \ + "status=$([[ $exit_code -eq 0 ]] && echo ok || echo error)" + fi + + # PRD Checklist verification on interval (v5.44.0) + if type checklist_should_verify &>/dev/null && checklist_should_verify; then + checklist_verify + fi + + # App Runner: init after first successful iteration (v5.45.0) + if [ "${APP_RUNNER_INITIALIZED:-}" != "true" ] && [ $exit_code -eq 0 ] && \ + [ "${LOKI_APP_RUNNER:-true}" = "true" ] && type app_runner_init &>/dev/null; then + if app_runner_init; then + app_runner_start || log_warn "App runner: failed to start application" + APP_RUNNER_INITIALIZED=true + fi + fi + + # App Runner: restart on code changes (v5.45.0) + if [ "${APP_RUNNER_INITIALIZED:-}" = "true" ] && type app_runner_should_restart &>/dev/null; then + if app_runner_should_restart; then + app_runner_restart || log_warn "App runner: failed to restart application" + fi + fi + + # App Runner: watchdog check (v5.45.0) + if [ "${APP_RUNNER_INITIALIZED:-}" = "true" ] && type app_runner_watchdog &>/dev/null; then + app_runner_watchdog + fi + + # Playwright smoke test on interval (v5.46.0) + if type playwright_verify_should_run &>/dev/null && playwright_verify_should_run; then + if [ -f ".loki/app-runner/state.json" ]; then + local app_url + app_url=$(python3 -c "import json; d=json.load(open('.loki/app-runner/state.json')); print(d.get('url','') if d.get('status')=='running' else '')" 2>/dev/null || true) + if [ -n "$app_url" ]; then + playwright_verify_app "$app_url" || true + fi + fi + fi + + # App Runner: check for dashboard control signals (v5.45.0) + if [ "${APP_RUNNER_INITIALIZED:-}" = "true" ]; then + if [ -f ".loki/app-runner/restart-signal" ]; then + rm -f ".loki/app-runner/restart-signal" + log_info "App runner: restart signal received from dashboard" + app_runner_restart || true + fi + if [ -f ".loki/app-runner/stop-signal" ]; then + rm -f ".loki/app-runner/stop-signal" + log_info "App runner: stop signal received from dashboard" + app_runner_stop || true + fi + fi + + # Update session continuity file for next iteration / agent handoff + update_continuity + + # Checkpoint after each iteration (v5.57.0) + create_checkpoint "iteration-${ITERATION_COUNT} complete" "iteration-${ITERATION_COUNT}" + + # Quality gates (v6.10.0 - escalation ladder) + local gate_failures="" + if [ "${LOKI_HARD_GATES:-true}" = "true" ]; then + # Static analysis gate + if [ "${PHASE_STATIC_ANALYSIS:-true}" = "true" ]; then + if enforce_static_analysis; then + clear_gate_failure "static_analysis" + else + local sa_count + sa_count=$(track_gate_failure "static_analysis") + gate_failures="${gate_failures}static_analysis," + log_warn "Static analysis FAILED ($sa_count consecutive) - findings injected into next iteration" + fi + fi + # Test coverage gate + if [ "${PHASE_UNIT_TESTS:-true}" = "true" ]; then + if enforce_test_coverage; then + clear_gate_failure "test_coverage" + else + local tc_count + tc_count=$(track_gate_failure "test_coverage") + gate_failures="${gate_failures}test_coverage," + log_warn "Test coverage gate FAILED ($tc_count consecutive) - must pass next iteration" + fi + fi + # Code review gate (upgraded from advisory, with escalation) + if [ "$PHASE_CODE_REVIEW" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then + if run_code_review; then + clear_gate_failure "code_review" + else + local cr_count + cr_count=$(track_gate_failure "code_review") + if [ "$cr_count" -ge "$GATE_PAUSE_LIMIT" ]; then + log_error "Gate escalation: code_review failed $cr_count times (>= $GATE_PAUSE_LIMIT) - forcing PAUSE for human intervention" + echo "PAUSE" > "${TARGET_DIR:-.}/.loki/signals/GATE_ESCALATION" + echo "code_review gate failed $cr_count consecutive times" >> "${TARGET_DIR:-.}/.loki/signals/GATE_ESCALATION" + touch "${TARGET_DIR:-.}/.loki/signals/PAUSE" + elif [ "$cr_count" -ge "$GATE_ESCALATE_LIMIT" ]; then + log_warn "Gate escalation: code_review failed $cr_count times (>= $GATE_ESCALATE_LIMIT) - escalating" + echo "ESCALATE" > "${TARGET_DIR:-.}/.loki/signals/GATE_ESCALATION" + gate_failures="${gate_failures}code_review_ESCALATED," + elif [ "$cr_count" -ge "$GATE_CLEAR_LIMIT" ]; then + log_warn "Gate cleared: code_review failed $cr_count times (>= $GATE_CLEAR_LIMIT) - passing gate this iteration, counter continues" + else + gate_failures="${gate_failures}code_review," + log_warn "Code review BLOCKED ($cr_count consecutive) - Critical/High findings" + fi + fi + fi + # Store gate failures for prompt injection + if [ -n "$gate_failures" ]; then + echo "$gate_failures" > "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt" + else + rm -f "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt" + fi + else + if [ "$PHASE_CODE_REVIEW" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then + run_code_review || log_warn "Code review found issues - check .loki/quality/reviews/" + fi + fi + + # Automatic episode capture after every RARV iteration (v6.15.0) + # Captures RARV phase, git changes, and iteration context automatically + auto_capture_episode "$ITERATION_COUNT" "$exit_code" "${rarv_phase:-iteration}" \ + "${prd_path:-codebase-analysis}" "$duration" "$log_file" + + # Check for success - ONLY stop on explicit completion promise + # There's never a "complete" product - always improvements, bugs, features + if [ $exit_code -eq 0 ]; then + # Episode trace already captured by auto_capture_episode above (v6.15.0) + + # Track iteration for Completion Council convergence detection + if type council_track_iteration &>/dev/null; then + council_track_iteration "$log_file" + fi + + # Perpetual mode: NEVER stop, always continue + if [ "$PERPETUAL_MODE" = "true" ]; then + log_info "Perpetual mode: Ignoring exit, continuing immediately..." + ((retry++)) + continue # Immediately start next iteration, no wait + fi + + # Completion Council check (v5.25.0) - multi-agent voting on completion + # Runs before completion promise check since council is more comprehensive + if type council_should_stop &>/dev/null && council_should_stop; then + echo "" + log_header "COMPLETION COUNCIL: PROJECT COMPLETE" + log_info "Council voted to stop (convergence detected + requirements verified)" + log_info "Running memory consolidation..." + run_memory_consolidation + notify_all_complete + save_state $retry "council_approved" 0 + return 0 + fi + + # Only stop if EXPLICIT completion promise text was output + if [ -n "$COMPLETION_PROMISE" ] && check_completion_promise "$log_file"; then + echo "" + log_header "COMPLETION PROMISE FULFILLED: $COMPLETION_PROMISE" + log_info "Explicit completion promise detected in output." + # Run memory consolidation on successful completion + log_info "Running memory consolidation..." + run_memory_consolidation + notify_all_complete + save_state $retry "completion_promise_fulfilled" 0 + return 0 + fi + + # Warn if Claude says it's "done" but no explicit promise + if is_completed; then + log_warn "${PROVIDER_DISPLAY_NAME:-Claude} claims completion, but no explicit promise fulfilled." + log_warn "Council will evaluate at next check interval (every ${COUNCIL_CHECK_INTERVAL:-5} iterations)" + fi + + # SUCCESS exit - continue IMMEDIATELY to next iteration (no wait!) + log_info "Iteration complete. Continuing to next iteration..." + ((retry++)) + continue # Immediately start next iteration, no exponential backoff + fi + + # Only apply retry logic for ERRORS (non-zero exit code) + # Episode trace already captured by auto_capture_episode above (v6.15.0) + + # Checkpoint failed iteration state (v5.57.0) + create_checkpoint "iteration-${ITERATION_COUNT} failed (exit=$exit_code)" "iteration-${ITERATION_COUNT}-fail" + + # Handle retry - check for rate limit first + local rate_limit_wait=$(detect_rate_limit "$log_file") + local wait_time + + if [ $rate_limit_wait -gt 0 ]; then + wait_time=$rate_limit_wait + local human_time=$(format_duration $wait_time) + log_warn "Rate limit detected! Waiting until reset (~$human_time)..." + log_info "Rate limit resets at approximately $(date -v+${wait_time}S '+%I:%M %p' 2>/dev/null || date -d "+${wait_time} seconds" '+%I:%M %p' 2>/dev/null || echo 'soon')" + notify_rate_limit "$wait_time" + else + wait_time=$(calculate_wait $retry) + log_warn "Will retry in ${wait_time}s..." + fi + + log_info "Press Ctrl+C to cancel" + + # Countdown with progress + local remaining=$wait_time + local interval=10 + # Use longer interval for long waits + if [ $wait_time -gt 1800 ]; then + interval=60 + fi + + while [ $remaining -gt 0 ]; do + local human_remaining=$(format_duration $remaining) + printf "\r${YELLOW}Resuming in ${human_remaining}...${NC} " + sleep $interval + remaining=$((remaining - interval)) + done + echo "" + + ((retry++)) + done + + log_error "Max retries ($MAX_RETRIES) exceeded" + save_state $retry "failed" 1 + return 1 +} + +#=============================================================================== +# Human Intervention Mechanism (Auto-Claude pattern) +#=============================================================================== + +# Track interrupt state for Ctrl+C pause/exit behavior +INTERRUPT_COUNT=0 +INTERRUPT_LAST_TIME=0 +PAUSED=false + +# Check for human intervention signals +check_human_intervention() { + local loki_dir="${TARGET_DIR:-.}/.loki" + + # Check for PAUSE file + # BUG #4 fix: Check handle_pause return value before deleting PAUSE file. + # handle_pause returns 1 if STOP was requested during the pause, so we must + # propagate that as return 2 (stop) instead of always returning 1 (continue). + if [ -f "$loki_dir/PAUSE" ]; then + # In perpetual mode: auto-clear PAUSE files and continue without waiting + # EXCEPT when PAUSE was created by budget limit enforcement + if [ "$AUTONOMY_MODE" = "perpetual" ] || [ "$PERPETUAL_MODE" = "true" ]; then + if [ -f "$loki_dir/signals/BUDGET_EXCEEDED" ]; then + log_warn "PAUSE file created by budget limit - NOT auto-clearing in perpetual mode" + log_warn "Budget limit reached. Remove .loki/signals/BUDGET_EXCEEDED and .loki/PAUSE to continue." + notify_intervention_needed "Budget limit reached - execution paused" 2>/dev/null || true + handle_pause + local pause_result=$? + rm -f "$loki_dir/PAUSE" + if [ "$pause_result" -eq 1 ]; then + return 2 + fi + return 1 + fi + log_warn "PAUSE file detected but autonomy mode is perpetual - auto-clearing" + notify_intervention_needed "PAUSE file auto-cleared in perpetual mode" 2>/dev/null || true + rm -f "$loki_dir/PAUSE" "$loki_dir/PAUSED.md" + # Restart dashboard if it crashed (likely cause of the PAUSE) + handle_dashboard_crash + return 0 + fi + log_warn "PAUSE file detected - pausing execution" + notify_intervention_needed "Execution paused via PAUSE file" + handle_pause + local pause_result=$? + rm -f "$loki_dir/PAUSE" + if [ "$pause_result" -eq 1 ]; then + # STOP was requested during pause + return 2 + fi + return 1 + fi + + # Check for PAUSE_AT_CHECKPOINT (checkpoint mode deferred pause) + if [ -f "$loki_dir/PAUSE_AT_CHECKPOINT" ]; then + if [ "$AUTONOMY_MODE" = "checkpoint" ]; then + log_warn "Checkpoint pause requested - pausing now" + rm -f "$loki_dir/PAUSE_AT_CHECKPOINT" + notify_intervention_needed "Execution paused at checkpoint" + touch "$loki_dir/PAUSE" + handle_pause + local pause_result=$? + rm -f "$loki_dir/PAUSE" + if [ "$pause_result" -eq 1 ]; then + return 2 + fi + return 1 + else + # Clean up stale checkpoint pause file + rm -f "$loki_dir/PAUSE_AT_CHECKPOINT" + fi + fi + + # Check for HUMAN_INPUT.md (prompt injection) + # Security: Check it's a regular file (not symlink) to prevent symlink attacks + if [ -f "$loki_dir/HUMAN_INPUT.md" ] && [ ! -L "$loki_dir/HUMAN_INPUT.md" ]; then + # Security: Prompt injection disabled by default for enterprise security + if [ "${LOKI_PROMPT_INJECTION:-false}" != "true" ]; then + log_warn "HUMAN_INPUT.md detected but prompt injection is DISABLED" + log_warn "To enable, set LOKI_PROMPT_INJECTION=true (only in trusted environments)" + # Move to rejected instead of processed + mkdir -p "$loki_dir/logs" 2>/dev/null + mv "$loki_dir/HUMAN_INPUT.md" "$loki_dir/logs/human-input-REJECTED-$(date +%Y%m%d-%H%M%S).md" 2>/dev/null || rm -f "$loki_dir/HUMAN_INPUT.md" + else + # Security: Check file size (1MB limit) + local file_size + file_size=$(stat -f%z "$loki_dir/HUMAN_INPUT.md" 2>/dev/null || stat -c%s "$loki_dir/HUMAN_INPUT.md" 2>/dev/null || echo "0") + if [ "$file_size" -gt 1048576 ]; then + log_warn "HUMAN_INPUT.md exceeds 1MB size limit, rejecting" + mkdir -p "$loki_dir/logs" 2>/dev/null + mv "$loki_dir/HUMAN_INPUT.md" "$loki_dir/logs/human-input-REJECTED-TOOLARGE-$(date +%Y%m%d-%H%M%S).md" 2>/dev/null || rm -f "$loki_dir/HUMAN_INPUT.md" + else + local human_input=$(cat "$loki_dir/HUMAN_INPUT.md") + if [ -n "$human_input" ]; then + log_info "Human input detected:" + echo "$human_input" + echo "" + # Move to processed + mkdir -p "$loki_dir/logs" 2>/dev/null + mv "$loki_dir/HUMAN_INPUT.md" "$loki_dir/logs/human-input-$(date +%Y%m%d-%H%M%S).md" + # Inject into next prompt + export LOKI_HUMAN_INPUT="$human_input" + return 0 + fi + fi + fi + elif [ -L "$loki_dir/HUMAN_INPUT.md" ]; then + # Security: Reject symlinks + log_warn "HUMAN_INPUT.md is a symlink - rejected for security" + rm -f "$loki_dir/HUMAN_INPUT.md" + fi + + # Check for council force-review signal (from dashboard) + if [ -f "$loki_dir/signals/COUNCIL_REVIEW_REQUESTED" ]; then + log_info "Council force-review requested from dashboard" + rm -f "$loki_dir/signals/COUNCIL_REVIEW_REQUESTED" + if type council_checklist_gate &>/dev/null && ! council_checklist_gate; then + log_info "Council force-review: blocked by checklist hard gate" + elif type council_vote &>/dev/null && council_vote; then + log_header "COMPLETION COUNCIL: FORCE REVIEW - PROJECT COMPLETE" + # BUG #17 fix: Write COMPLETED marker, generate council report, and + # run memory consolidation (matching the normal council approval path + # in council_should_stop). + echo "Council force-review approved at iteration $ITERATION_COUNT on $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$loki_dir/COMPLETED" + if type council_write_report &>/dev/null; then + council_write_report + fi + log_info "Running memory consolidation..." + run_memory_consolidation + notify_all_complete + save_state ${RETRY_COUNT:-0} "council_force_approved" 0 + return 2 # Stop + fi + log_info "Council force-review: voted to continue" + fi + + # Check for STOP file (immediate stop) + if [ -f "$loki_dir/STOP" ]; then + log_warn "STOP file detected - stopping execution" + rm -f "$loki_dir/STOP" + return 2 + fi + + return 0 +} + +# Handle pause state - wait for resume +handle_pause() { + PAUSED=true + local loki_dir="${TARGET_DIR:-.}/.loki" + + log_header "Execution Paused" + echo "" + log_info "To resume: Remove .loki/PAUSE or press Enter" + log_info "To add instructions: echo 'your instructions' > .loki/HUMAN_INPUT.md" + log_info "To stop completely: touch .loki/STOP" + echo "" + + # Create resume instructions file + cat > "$loki_dir/PAUSED.md" << 'EOF' +# Loki Mode - Paused + +Execution is currently paused. Options: + +1. **Resume**: Press Enter in terminal or `rm .loki/PAUSE` +2. **Add Instructions**: `echo "Focus on fixing the login bug" > .loki/HUMAN_INPUT.md` +3. **Stop**: `touch .loki/STOP` + +Current state is saved. You can inspect: +- `.loki/CONTINUITY.md` - Progress and context +- `.loki/STATUS.txt` - Current status +- `.loki/logs/` - Session logs +EOF + + # Wait for resume signal (unified: file removal, keyboard, or STOP) + while [ "$PAUSED" = "true" ]; do + # Check for stop signal + if [ -f "$loki_dir/STOP" ]; then + rm -f "$loki_dir/STOP" "$loki_dir/PAUSED.md" + PAUSED=false + return 1 + fi + + # Check if PAUSE file was removed (by CLI, API, or dashboard) + if [ ! -f "$loki_dir/PAUSE" ]; then + PAUSED=false + break + fi + + # Check for any key press (non-blocking) + if read -t 1 -n 1 2>/dev/null; then + rm -f "$loki_dir/PAUSE" + PAUSED=false + break + fi + + sleep 1 + done + + rm -f "$loki_dir/PAUSED.md" + log_info "Resuming execution..." + PAUSED=false + return 0 +} + +#=============================================================================== +# Cleanup Handler (with Ctrl+C pause support) +#=============================================================================== + +cleanup() { + # Block further signals during critical cleanup operations + trap '' INT TERM + + local current_time=$(date +%s) + local time_diff=$((current_time - INTERRUPT_LAST_TIME)) + local loki_dir="${TARGET_DIR:-.}/.loki" + + # If STOP file exists, this is an external stop (from `loki stop` CLI) + # Exit immediately without entering interactive pause mode + if [ -f "$loki_dir/STOP" ]; then + echo "" + log_warn "Stop signal received - shutting down" + rm -f "$loki_dir/STOP" "$loki_dir/PAUSE" "$loki_dir/PAUSED.md" 2>/dev/null + if type app_runner_cleanup &>/dev/null; then + app_runner_cleanup + fi + stop_dashboard + stop_status_monitor + kill_all_registered + rm -f "$loki_dir/loki.pid" 2>/dev/null + # Clean up per-session PID file if running with session ID + if [ -n "${LOKI_SESSION_ID:-}" ]; then + rm -f "$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pid" 2>/dev/null + fi + if [ -f "$loki_dir/session.json" ]; then + _LOKI_SESSION_FILE="$loki_dir/session.json" python3 -c " +import json, os +sf = os.environ['_LOKI_SESSION_FILE'] +try: + with open(sf, 'r+') as f: + d = json.load(f); d['status'] = 'stopped' + f.seek(0); f.truncate(); json.dump(d, f) +except (json.JSONDecodeError, OSError): pass +" 2>/dev/null || true + fi + save_state ${RETRY_COUNT:-0} "stopped" 0 + emit_event_json "session_end" "result=0" "reason=stop_requested" + log_info "Session stopped." + exit 0 + fi + + # If double Ctrl+C within 2 seconds, exit immediately + if [ "$time_diff" -lt 2 ] && [ "$INTERRUPT_COUNT" -gt 0 ]; then + echo "" + log_warn "Double interrupt - stopping immediately" + if type app_runner_cleanup &>/dev/null; then + app_runner_cleanup + fi + stop_dashboard + stop_status_monitor + kill_all_registered + rm -f "$loki_dir/loki.pid" "$loki_dir/PAUSE" 2>/dev/null + # Clean up per-session PID file if running with session ID + if [ -n "${LOKI_SESSION_ID:-}" ]; then + rm -f "$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pid" 2>/dev/null + fi + # Mark session.json as stopped + if [ -f "$loki_dir/session.json" ]; then + _LOKI_SESSION_FILE="$loki_dir/session.json" python3 -c " +import json, os +sf = os.environ['_LOKI_SESSION_FILE'] +try: + with open(sf, 'r+') as f: + d = json.load(f); d['status'] = 'stopped' + f.seek(0); f.truncate(); json.dump(d, f) +except (json.JSONDecodeError, OSError): pass +" 2>/dev/null || true + fi + save_state ${RETRY_COUNT:-0} "interrupted" 130 + emit_event_json "session_end" "result=130" "reason=interrupted" + log_info "State saved. Run again to resume." + exit 130 + fi + + # Re-enable signals for pause mode + trap cleanup INT TERM + + # Check if this signal was caused by a child process dying (e.g., dashboard) + # rather than an actual user interrupt. In that case, handle silently. + if is_child_process_signal; then + log_info "Child process exit detected, handled silently" + # Do NOT reset INTERRUPT_COUNT -- preserves double-Ctrl+C escape capability + return + fi + + # In perpetual/autonomous mode: NEVER pause, NEVER wait for input + # Log the interrupt but continue the iteration loop immediately + if [ "$AUTONOMY_MODE" = "perpetual" ] || [ "$PERPETUAL_MODE" = "true" ]; then + INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1)) + INTERRUPT_LAST_TIME=$current_time + echo "" + log_warn "Interrupt received in perpetual mode - ignoring (not pausing)" + log_info "To stop: touch .loki/STOP or press Ctrl+C twice within 2 seconds" + echo "" + # Check and restart dashboard if it died + handle_dashboard_crash + # Do NOT reset INTERRUPT_COUNT -- let it accumulate so double-Ctrl+C escape works + return + fi + + # In checkpoint mode: only pause at explicit checkpoint boundaries, not on + # random signals. A signal during normal execution is treated as noise. + if [ "$AUTONOMY_MODE" = "checkpoint" ]; then + INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1)) + INTERRUPT_LAST_TIME=$current_time + echo "" + log_warn "Interrupt received in checkpoint mode - will pause at next checkpoint" + log_info "To stop immediately: press Ctrl+C again within 2 seconds" + echo "" + # Mark that a pause was requested for the next checkpoint + touch "${TARGET_DIR:-.}/.loki/PAUSE_AT_CHECKPOINT" + handle_dashboard_crash + # Do NOT reset INTERRUPT_COUNT -- let it accumulate so double-Ctrl+C escape works + return + fi + + # Supervised mode (or unrecognized): original behavior - pause and show options + INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1)) + INTERRUPT_LAST_TIME=$current_time + + echo "" + log_warn "Interrupt received - pausing..." + log_info "Press Ctrl+C again within 2 seconds to exit" + log_info "Or wait to add instructions..." + echo "" + + # Create pause state + touch "${TARGET_DIR:-.}/.loki/PAUSE" + handle_pause + + # Reset interrupt count after pause + INTERRUPT_COUNT=0 +} + +#=============================================================================== +# Main Entry Point +#=============================================================================== + +main() { + trap cleanup INT TERM + SESSION_START_EPOCH=$(date +%s) + + echo "" + echo -e "${BOLD}${BLUE}" + echo " ██╗ ██████╗ ██╗ ██╗██╗ ███╗ ███╗ ██████╗ ██████╗ ███████╗" + echo " ██║ ██╔═══██╗██║ ██╔╝██║ ████╗ ████║██╔═══██╗██╔══██╗██╔════╝" + echo " ██║ ██║ ██║█████╔╝ ██║ ██╔████╔██║██║ ██║██║ ██║█████╗ " + echo " ██║ ██║ ██║██╔═██╗ ██║ ██║╚██╔╝██║██║ ██║██║ ██║██╔══╝ " + echo " ███████╗╚██████╔╝██║ ██╗██║ ██║ ╚═╝ ██║╚██████╔╝██████╔╝███████╗" + echo " ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝" + echo -e "${NC}" + echo -e " ${CYAN}Autonomous Multi-Agent Startup System${NC}" + echo -e " ${CYAN}Version: $(cat "$PROJECT_DIR/VERSION" 2>/dev/null || echo "4.x.x")${NC}" + echo "" + + # Parse arguments + PRD_PATH="" + REMAINING_ARGS=() + while [[ $# -gt 0 ]]; do + case "$1" in + --parallel) + PARALLEL_MODE=true + shift + ;; + --allow-haiku) + export LOKI_ALLOW_HAIKU=true + log_info "Haiku model enabled for fast tier" + shift + ;; + --provider) + if [[ -n "${2:-}" ]]; then + LOKI_PROVIDER="$2" + # Reload provider config + if [ -f "$PROVIDERS_DIR/loader.sh" ]; then + if ! validate_provider "$LOKI_PROVIDER"; then + log_error "Unknown provider: $LOKI_PROVIDER" + log_info "Supported providers: ${SUPPORTED_PROVIDERS[*]}" + exit 1 + fi + if ! load_provider "$LOKI_PROVIDER"; then + log_error "Failed to load provider config: $LOKI_PROVIDER" + exit 1 + fi + fi + shift 2 + else + log_error "--provider requires a value (claude, codex, gemini, cline, aider)" + exit 1 + fi + ;; + --provider=*) + LOKI_PROVIDER="${1#*=}" + # Reload provider config + if [ -f "$PROVIDERS_DIR/loader.sh" ]; then + if ! validate_provider "$LOKI_PROVIDER"; then + log_error "Unknown provider: $LOKI_PROVIDER" + log_info "Supported providers: ${SUPPORTED_PROVIDERS[*]}" + exit 1 + fi + if ! load_provider "$LOKI_PROVIDER"; then + log_error "Failed to load provider config: $LOKI_PROVIDER" + exit 1 + fi + fi + shift + ;; + --bg|--background) + BACKGROUND_MODE=true + shift + ;; + --interactive-prd|--interactive) + LOKI_INTERACTIVE_PRD=true + shift + ;; + --help|-h) + echo "Usage: ./autonomy/run.sh [OPTIONS] [PRD_PATH]" + echo "" + echo "Options:" + echo " --parallel Enable git worktree-based parallel workflows" + echo " --allow-haiku Enable Haiku model for fast tier (default: disabled)" + echo " --provider Provider: claude (default), codex, gemini, cline, aider" + echo " --bg, --background Run in background mode" + echo " --interactive-prd Interactive PRD pre-flight analysis" + echo " --help, -h Show this help message" + echo "" + echo "Environment variables: See header comments in this script" + echo "" + echo "Provider capabilities:" + if [ -f "$PROVIDERS_DIR/loader.sh" ]; then + print_capability_matrix + fi + exit 0 + ;; + *) + if [ -z "$PRD_PATH" ] && [[ ! "$1" == -* ]]; then + PRD_PATH="$1" + fi + REMAINING_ARGS+=("$1") + shift + ;; + esac + done + # Safe expansion for empty arrays with set -u + if [ ${#REMAINING_ARGS[@]} -gt 0 ]; then + set -- "${REMAINING_ARGS[@]}" + else + set -- + fi + + # Validate PRD if provided + if [ -n "$PRD_PATH" ] && [ ! -f "$PRD_PATH" ]; then + log_error "PRD file not found: $PRD_PATH" + exit 1 + fi + + # Handle background mode + if [ "$BACKGROUND_MODE" = "true" ]; then + # Initialize .loki directory first + mkdir -p .loki/logs + + local log_file=".loki/logs/background-$(date +%Y%m%d-%H%M%S).log" + local pid_file + if [ -n "${LOKI_SESSION_ID:-}" ]; then + mkdir -p ".loki/sessions/${LOKI_SESSION_ID}" + pid_file=".loki/sessions/${LOKI_SESSION_ID}/loki.pid" + else + pid_file=".loki/loki.pid" + fi + local project_path=$(pwd) + local project_name=$(basename "$project_path") + + echo "" + log_info "Starting Loki Mode in background..." + + # Build command without --bg flag + local cmd_args=() + [ -n "$PRD_PATH" ] && cmd_args+=("$PRD_PATH") + [ "$PARALLEL_MODE" = "true" ] && cmd_args+=("--parallel") + [ -n "$LOKI_PROVIDER" ] && cmd_args+=("--provider" "$LOKI_PROVIDER") + [ "${LOKI_ALLOW_HAIKU:-}" = "true" ] && cmd_args+=("--allow-haiku") + + # Run in background using the ORIGINAL script (not the temp copy) + # CRITICAL: Unset LOKI_RUNNING_FROM_TEMP so the background process does its own self-copy + # Otherwise it would run directly from the original file and the trap would delete it + local original_script="$SCRIPT_DIR/run.sh" + LOKI_RUNNING_FROM_TEMP='' nohup "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & + local bg_pid=$! + echo "$bg_pid" > "$pid_file" + register_pid "$bg_pid" "background-session" "log=$log_file" + + echo "" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN} Loki Mode Running in Background${NC}" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo -e " ${CYAN}Project:${NC} $project_name" + echo -e " ${CYAN}Path:${NC} $project_path" + echo -e " ${CYAN}PID:${NC} $bg_pid" + echo -e " ${CYAN}Log:${NC} $log_file" + echo -e " ${CYAN}Dashboard:${NC} http://127.0.0.1:${DASHBOARD_PORT}/" + echo "" + echo -e "${YELLOW}Control Commands:${NC}" + echo -e " ${DIM}Pause:${NC} touch .loki/PAUSE" + echo -e " ${DIM}Resume:${NC} rm .loki/PAUSE" + echo -e " ${DIM}Stop:${NC} touch .loki/STOP ${DIM}or${NC} kill $bg_pid" + echo -e " ${DIM}Logs:${NC} tail -f $log_file" + echo -e " ${DIM}Status:${NC} cat .loki/STATUS.txt" + echo "" + + exit 0 + fi + + # Show provider info + log_info "Provider: ${PROVIDER_DISPLAY_NAME:-Claude Code} (${PROVIDER_NAME:-claude})" + if [ "${PROVIDER_DEGRADED:-false}" = "true" ]; then + log_warn "Degraded mode: Parallel agents and Task tool not available" + # Check if array exists and has elements before iterating + if [ -n "${PROVIDER_DEGRADED_REASONS+x}" ] && [ ${#PROVIDER_DEGRADED_REASONS[@]} -gt 0 ]; then + log_info "Limitations:" + for reason in "${PROVIDER_DEGRADED_REASONS[@]}"; do + log_info " - $reason" + done + fi + fi + + # Show parallel mode status + if [ "$PARALLEL_MODE" = "true" ]; then + if [ "${PROVIDER_HAS_PARALLEL:-false}" = "true" ]; then + log_info "Parallel mode enabled (git worktrees)" + else + log_warn "Parallel mode requested but not supported by ${PROVIDER_NAME:-unknown}" + log_warn "Running in sequential mode instead" + PARALLEL_MODE=false + fi + fi + + # Validate API keys for the selected provider + if ! validate_api_keys; then + exit 1 + fi + + # Check prerequisites (unless skipped) + if [ "$SKIP_PREREQS" != "true" ]; then + if ! check_prerequisites; then + exit 1 + fi + else + log_warn "Skipping prerequisite checks (LOKI_SKIP_PREREQS=true)" + fi + + # Check skill installation + if ! check_skill_installed; then + exit 1 + fi + + # Initialize .loki directory + init_loki_dir + + # Initialize session continuity file with empty template + update_continuity + + # Session lock: prevent concurrent sessions + # Per-session locking (v6.4.0): LOKI_SESSION_ID enables multiple concurrent + # sessions (e.g., loki run 52 -d && loki run 54 -d). Each session gets its + # own PID/lock files under .loki/sessions//. + # Without LOKI_SESSION_ID, the global .loki/loki.pid lock is used (single session). + local pid_file lock_file + if [ -n "${LOKI_SESSION_ID:-}" ]; then + mkdir -p ".loki/sessions/${LOKI_SESSION_ID}" + pid_file=".loki/sessions/${LOKI_SESSION_ID}/loki.pid" + lock_file=".loki/sessions/${LOKI_SESSION_ID}/session.lock" + else + pid_file=".loki/loki.pid" + lock_file=".loki/session.lock" + fi + + # Use flock for atomic locking to prevent TOCTOU race conditions + if command -v flock >/dev/null 2>&1; then + # Create lock file + touch "$lock_file" + + # Open FD 200 at process scope so flock persists for entire session lifetime + # (block-scoped redirection would release the lock when the block exits) + exec 200>"$lock_file" + + # Try to acquire exclusive lock (non-blocking) + if ! flock -n 200 2>/dev/null; then + if [ -n "${LOKI_SESSION_ID:-}" ]; then + log_error "Session '${LOKI_SESSION_ID}' is already running (locked)" + log_error "Stop it first with: loki stop ${LOKI_SESSION_ID}" + else + log_error "Another Loki session is already running (locked)" + log_error "Stop it first with: loki stop" + fi + exit 1 + fi + + # Check PID file after acquiring lock + if [ -f "$pid_file" ]; then + local existing_pid + existing_pid=$(cat "$pid_file" 2>/dev/null) + # Skip if it's our own PID or parent PID (background mode writes PID before child starts) + if [ -n "$existing_pid" ] && [ "$existing_pid" != "$$" ] && [ "$existing_pid" != "$PPID" ] && kill -0 "$existing_pid" 2>/dev/null; then + if [ -n "${LOKI_SESSION_ID:-}" ]; then + log_error "Session '${LOKI_SESSION_ID}' is already running (PID: $existing_pid)" + log_error "Stop it first with: loki stop ${LOKI_SESSION_ID}" + else + log_error "Another Loki session is already running (PID: $existing_pid)" + log_error "Stop it first with: loki stop" + fi + exit 1 + fi + fi + else + # Fallback to original behavior if flock not available + log_warn "flock not available - using non-atomic PID check (race condition possible)" + if [ -f "$pid_file" ]; then + local existing_pid + existing_pid=$(cat "$pid_file" 2>/dev/null) + # Skip if it's our own PID or parent PID (background mode writes PID before child starts) + if [ -n "$existing_pid" ] && [ "$existing_pid" != "$$" ] && [ "$existing_pid" != "$PPID" ] && kill -0 "$existing_pid" 2>/dev/null; then + if [ -n "${LOKI_SESSION_ID:-}" ]; then + log_error "Session '${LOKI_SESSION_ID}' is already running (PID: $existing_pid)" + log_error "Stop it first with: loki stop ${LOKI_SESSION_ID}" + else + log_error "Another Loki session is already running (PID: $existing_pid)" + log_error "Stop it first with: loki stop" + fi + exit 1 + fi + fi + fi + + # Write PID file for ALL modes (foreground + background) + echo "$$" > "$pid_file" + # Store session ID in state for dashboard/status visibility + if [ -n "${LOKI_SESSION_ID:-}" ]; then + echo "${LOKI_SESSION_ID}" > ".loki/sessions/${LOKI_SESSION_ID}/session_id" + fi + + # Initialize PID registry and clean up orphans from previous sessions + init_pid_registry + local orphan_count + orphan_count=$(cleanup_orphan_pids) + if [ "$orphan_count" -gt 0 ]; then + log_warn "Killed $orphan_count orphaned process(es) from previous session" + fi + + # Copy skill files to .loki/skills/ - makes CLI self-contained + # No need to install Claude Code skill separately + copy_skill_files + + # Import GitHub issues if enabled (v4.1.0) + if [ "$GITHUB_IMPORT" = "true" ]; then + import_github_issues + # Notify GitHub that imported issues are being worked on (v5.41.0) + sync_github_in_progress_tasks + fi + + # Start web dashboard (if enabled) + if [ "$ENABLE_DASHBOARD" = "true" ]; then + start_dashboard + else + log_info "Dashboard disabled (LOKI_DASHBOARD=false)" + fi + + # Start status monitor (background updates to .loki/STATUS.txt) + start_status_monitor + + # Start resource monitor (background CPU/memory checks) + start_resource_monitor + + # Initialize cross-project learnings database + init_learnings_db + + # Load relevant learnings for this project context + if [ -n "$PRD_PATH" ] && [ -f "$PRD_PATH" ]; then + get_relevant_learnings "$(head -100 "$PRD_PATH")" + load_solutions_context "$(head -100 "$PRD_PATH")" + else + get_relevant_learnings "general development" + load_solutions_context "general development" + fi + + # Setup agent branch protection (isolates agent changes to a feature branch) + setup_agent_branch + + # Log session start for audit + audit_log "SESSION_START" "prd=$PRD_PATH,dashboard=$ENABLE_DASHBOARD,staged_autonomy=$STAGED_AUTONOMY,parallel=$PARALLEL_MODE" + audit_agent_action "session_start" "Session started" "prd=$PRD_PATH,provider=${PROVIDER_NAME:-claude}" + + # Emit session start event for dashboard + emit_event_json "session_start" \ + "provider=${PROVIDER_NAME:-claude}" \ + "prd=${PRD_PATH:-}" \ + "parallel=${PARALLEL_MODE:-false}" \ + "complexity=${DETECTED_COMPLEXITY:-standard}" \ + "pid=$$" + + # Anonymous usage telemetry + loki_telemetry "session_start" \ + "provider=${PROVIDER_NAME:-claude}" \ + "complexity=${DETECTED_COMPLEXITY:-standard}" \ + "parallel=${PARALLEL_MODE:-false}" 2>/dev/null || true + + # Start enterprise background services (OTEL bridge, etc.) + start_enterprise_services + + # Also emit session_start to pending dir for OTEL bridge + if [ -n "${LOKI_OTEL_ENDPOINT:-}" ]; then + emit_event_pending "session_start" \ + "provider=${PROVIDER_NAME:-claude}" \ + "prd=${PRD_PATH:-}" + fi + + # Run in appropriate mode + local result=0 + if [ "$PARALLEL_MODE" = "true" ]; then + # Check bash version before attempting parallel mode + if ! check_parallel_support; then + log_warn "Parallel mode unavailable, falling back to sequential mode" + PARALLEL_MODE=false + fi + fi + + if [ "$PARALLEL_MODE" = "true" ]; then + # Parallel mode: orchestrate multiple worktrees + log_header "Running in Parallel Mode" + log_info "Max worktrees: $MAX_WORKTREES" + log_info "Max parallel sessions: $MAX_PARALLEL_SESSIONS" + + # Run main session + orchestrator + ( + # Start main development session + run_autonomous "$PRD_PATH" + ) & + local main_pid=$! + register_pid "$main_pid" "parallel-main" "" + + # Run parallel orchestrator + run_parallel_orchestrator & + local orchestrator_pid=$! + register_pid "$orchestrator_pid" "parallel-orchestrator" "" + + # Wait for main session (orchestrator continues watching) + wait $main_pid || result=$? + + # Signal orchestrator to stop + kill $orchestrator_pid 2>/dev/null || true + wait $orchestrator_pid 2>/dev/null || true + + # Cleanup parallel streams + cleanup_parallel_streams + else + # Standard mode: single session + run_autonomous "$PRD_PATH" || result=$? + fi + + # Final GitHub sync: sync all completed tasks and create PR (v5.41.0) + sync_github_completed_tasks + if [ "$GITHUB_PR" = "true" ] && [ "$result" = "0" ]; then + local feature_name="${PRD_PATH:-Codebase improvements}" + feature_name=$(basename "$feature_name" .md 2>/dev/null || echo "$feature_name") + create_github_pr "$feature_name" + fi + + # Extract and save learnings from this session + extract_learnings_from_session + + # Compound learnings into structured solution files (v5.30.0) + compound_session_to_solutions + + # Log checkpoint count before final checkpoint (v5.57.0) + local cp_count=$(find .loki/state/checkpoints -maxdepth 1 -type d -name "cp-*" 2>/dev/null | wc -l | tr -d ' ') + log_info "Session checkpoints: ${cp_count}" + + # Create session-end checkpoint (v5.34.0) + create_checkpoint "session end (iterations=$ITERATION_COUNT)" "session-end" + + # Emit session_end to pending dir for OTEL bridge (before stopping services) + if [ -n "${LOKI_OTEL_ENDPOINT:-}" ]; then + emit_event_pending "session_end" \ + "result=$result" \ + "iterations=$ITERATION_COUNT" + fi + + # Stop enterprise background services (OTEL bridge, etc.) + stop_enterprise_services + + # Log session end for audit + audit_log "SESSION_END" "result=$result,prd=$PRD_PATH" + + # Emit session end event for dashboard + emit_event_json "session_end" \ + "result=$result" \ + "provider=${PROVIDER_NAME:-claude}" \ + "iterations=$ITERATION_COUNT" + + # Anonymous usage telemetry + local session_duration=$(($(date +%s) - ${SESSION_START_EPOCH:-$(date +%s)})) + loki_telemetry "session_end" \ + "provider=${PROVIDER_NAME:-claude}" \ + "duration=$session_duration" \ + "iterations=$ITERATION_COUNT" \ + "result=$result" 2>/dev/null || true + + # Emit learning signal for session completion (SYN-018) + if [ "$result" = "0" ]; then + emit_learning_signal success_pattern \ + --source cli \ + --action "session_complete" \ + --pattern-name "full_session" \ + --action-sequence '["init", "setup", "run_iterations", "extract_learnings", "cleanup"]' \ + --outcome success \ + --context "{\"provider\":\"${PROVIDER_NAME:-claude}\",\"iterations\":$ITERATION_COUNT,\"prd\":\"${PRD_PATH:-}\"}" + emit_learning_signal workflow_pattern \ + --source cli \ + --action "session_complete" \ + --workflow-name "loki_session" \ + --steps '["prerequisites", "setup", "autonomous_loop", "learnings", "cleanup"]' \ + --outcome success \ + --context "{\"iterations\":$ITERATION_COUNT}" + else + emit_learning_signal error_pattern \ + --source cli \ + --action "session_failed" \ + --error-type "SessionFailure" \ + --error-message "Session failed with result code $result" \ + --recovery-steps '["Check logs at .loki/logs/", "Review iteration outputs", "Check for rate limits", "Restart session"]' \ + --context "{\"provider\":\"${PROVIDER_NAME:-claude}\",\"iterations\":$ITERATION_COUNT,\"exit_code\":$result}" + fi + + # Write structured handoff for future sessions (v5.49.0) + write_structured_handoff "session_end_result_${result}" 2>/dev/null || true + + # Create PR from agent branch if branch protection was enabled + create_session_pr + audit_agent_action "session_stop" "Session ended" "result=$result,iterations=$ITERATION_COUNT" + + # Cleanup + if type app_runner_cleanup &>/dev/null; then + app_runner_cleanup + fi + stop_dashboard + stop_status_monitor + local loki_dir="${TARGET_DIR:-.}/.loki" + rm -f "$loki_dir/loki.pid" 2>/dev/null + # Clean up per-session PID file if running with session ID + if [ -n "${LOKI_SESSION_ID:-}" ]; then + rm -f "$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pid" 2>/dev/null + fi + # Mark session.json as stopped + if [ -f "$loki_dir/session.json" ]; then + _LOKI_SESSION_FILE="$loki_dir/session.json" python3 -c " +import json, os +sf = os.environ['_LOKI_SESSION_FILE'] +try: + with open(sf, 'r+') as f: + d = json.load(f); d['status'] = 'stopped' + f.seek(0); f.truncate(); json.dump(d, f) +except (json.JSONDecodeError, OSError): pass +" 2>/dev/null || true + fi + + exit $result +} + +# Run main only when executed directly (not when sourced by loki CLI) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi