baseline: recover workspace from iCloud damage
|
|
@ -0,0 +1,10 @@
|
|||
*.code-workspace
|
||||
*.py
|
||||
*.lock
|
||||
target/
|
||||
.DS_Store
|
||||
.worktree/
|
||||
.claude/
|
||||
.loki/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
|
|
@ -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 }
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Audio Oxide</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Audio Oxide</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.else-if.audio-oxide</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>au-o2-gui</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Audio Oxide needs microphone access for audio recording.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 66.3 66.3" style="enable-background:new 0 0 66.3 66.3;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#11007F;}
|
||||
.st1{fill:#F9C543;}
|
||||
.st2{font-family:'ArialMT';}
|
||||
.st3{font-size:20.3402px;}
|
||||
.st4{fill:#DD0074;}
|
||||
.st5{font-size:12px;}
|
||||
.st6{font-size:28.3253px;}
|
||||
</style>
|
||||
<g id="Text">
|
||||
<circle class="st0" cx="45.5" cy="33.1" r="20"/>
|
||||
<circle class="st1" cx="20.8" cy="33.1" r="20"/>
|
||||
<circle class="st1" cx="45.5" cy="33.1" r="18.8"/>
|
||||
<circle class="st0" cx="20.8" cy="33.1" r="18.8"/>
|
||||
<text transform="matrix(1 0 0 1 7.4957 39.8482)" class="st1 st2 st3">Au</text>
|
||||
<text transform="matrix(1 0 0 1 30.163 42.6969)" class="st4 st2 st5">2</text>
|
||||
<text transform="matrix(1 0 0 1 38.9518 42.6969)" class="st0 st2 st6">O</text>
|
||||
<text transform="matrix(1 0 0 1 56.5909 43.1403)" class="st4 st2 st5">3</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 18 8 13 13 15 21 6"/>
|
||||
<circle class="fillable" fill="none" cx="8" cy="13" r="2"/>
|
||||
<circle class="fillable" fill="none" cx="13" cy="15" r="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 355 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 268 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect class="fillable" fill="none" x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 339 B |
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="19" x2="20" y2="19"/>
|
||||
<line x1="6" y1="19" x2="6" y2="9"/>
|
||||
<line x1="10" y1="19" x2="10" y2="12"/>
|
||||
<line x1="14" y1="19" x2="14" y2="12"/>
|
||||
<line x1="18" y1="19" x2="18" y2="12"/>
|
||||
<circle class="fillable" fill="none" cx="6" cy="7" r="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 447 B |
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle class="fillable" fill="none" cx="6" cy="6" r="3"/>
|
||||
<circle class="fillable" fill="none" cx="6" cy="18" r="3"/>
|
||||
<line x1="20" y1="4" x2="8.12" y2="15.88"/>
|
||||
<line x1="14.47" y1="14.48" x2="20" y2="20"/>
|
||||
<line x1="8.12" y1="8.12" x2="12" y2="12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 445 B |
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17.65 6.35A8 8 0 0 0 4 12"/>
|
||||
<polyline points="4 8 4 12 8 12"/>
|
||||
<path d="M6.35 17.65A8 8 0 0 0 20 12"/>
|
||||
<polyline points="20 16 20 12 16 12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 345 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12c2-4 5-6 7-4s3 6 5 4 4-6 5-4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 238 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon class="fillable" fill="none" points="3,6 11,12 3,18"/>
|
||||
<polygon class="fillable" fill="none" points="11,6 19,12 11,18"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path class="fillable" fill="none" d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="2" x2="12" y2="22"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||
<line x1="19.07" y1="4.93" x2="4.93" y2="19.07"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 402 B |
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon class="fillable" fill="none" points="11,5 6,9 2,9 2,15 6,15 11,19"/>
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 355 B |
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2v8"/>
|
||||
<circle class="fillable" fill="none" cx="12" cy="14" r="4"/>
|
||||
<path d="M12 18v4"/>
|
||||
<path d="M8 14H2"/>
|
||||
<path d="M22 14h-6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 336 B |
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 7 8 12 4 17"/>
|
||||
<polyline points="20 7 16 12 20 17"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 304 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect class="fillable" fill="none" x="3" y="11" width="18" height="10" rx="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path class="fillable" fill="none" d="M8 20h8l-2-14h-4z"/>
|
||||
<line x1="12" y1="16" x2="16" y2="6"/>
|
||||
<circle cx="16.5" cy="5.5" r="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 327 B |
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon class="fillable" fill="none" points="11,5 6,9 2,9 2,15 6,15 11,19"/>
|
||||
<line x1="22" y1="9" x2="16" y2="15"/>
|
||||
<line x1="16" y1="9" x2="22" y2="15"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 8 7 12 3 16"/>
|
||||
<polyline points="21 8 17 12 21 16"/>
|
||||
<line x1="7" y1="12" x2="17" y2="12"/>
|
||||
<line x1="12" y1="7" x2="12" y2="17"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 343 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path class="fillable" fill="none" d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 354 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect class="fillable" fill="none" x="5" y="4" width="4" height="16" rx="1"/>
|
||||
<rect class="fillable" fill="none" x="15" y="4" width="4" height="16" rx="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 349 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon class="fillable" fill="none" points="6,4 20,12 6,20"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 256 B |
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 4v16"/>
|
||||
<path d="M16 4v16"/>
|
||||
<path d="M4 8h4"/>
|
||||
<path d="M4 16h4"/>
|
||||
<path d="M16 8h4"/>
|
||||
<path d="M16 16h4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 299 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="8"/>
|
||||
<circle class="fillable" fill="none" cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 285 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle class="fillable" fill="none" cx="12" cy="12" r="7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 278 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 231 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon class="fillable" fill="none" points="13,6 5,12 13,18"/>
|
||||
<polygon class="fillable" fill="none" points="21,6 13,12 21,18"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 323 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="5" x2="5" y2="19"/>
|
||||
<polygon class="fillable" fill="none" points="20,5 9,12 20,19"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 294 B |
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path class="fillable" fill="none" d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 374 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="7"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="7" y1="17" x2="17" y2="7"/>
|
||||
<polyline points="7 7 17 7 17 17"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 851 B |
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path class="fillable" fill="none" d="M3 18V6a2 2 0 0 1 2-2h3.28a1 1 0 0 1 .71.3l2.3 2.4a1 1 0 0 0 .71.3H19a2 2 0 0 1 2 2v2"/>
|
||||
<path d="M8 18a6 6 0 0 1 12 0"/>
|
||||
<circle cx="14" cy="12" r="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect class="fillable" fill="none" x="5" y="5" width="14" height="14" rx="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path class="fillable" fill="none" d="M7 21l-4-4a1 1 0 0 1 0-1.41l9.59-9.59a2 2 0 0 1 2.83 0l3.58 3.59a2 2 0 0 1 0 2.82L10 21z"/>
|
||||
<line x1="7" y1="21" x2="21" y2="21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path class="fillable" fill="none" d="M7 11h10l1 9H6z"/>
|
||||
<path d="M8 11V8a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/>
|
||||
<path d="M10 7V5h4v2"/>
|
||||
<line x1="9" y1="15" x2="15" y2="15"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path class="fillable" fill="none" d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 278 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path class="fillable" fill="none" d="M4 4l6 15 2-6 6-2z"/>
|
||||
<line x1="13" y1="13" x2="20" y2="20"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 292 B |
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle class="fillable" fill="none" cx="6" cy="6" r="3"/>
|
||||
<circle class="fillable" fill="none" cx="6" cy="18" r="3"/>
|
||||
<line x1="20" y1="4" x2="8.12" y2="15.88"/>
|
||||
<line x1="14.47" y1="14.48" x2="20" y2="20"/>
|
||||
<line x1="8.12" y1="8.12" x2="12" y2="12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 445 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="7"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12c1.5-3 3-5 5-5s3.5 3 5 5 3 5 5 5 3.5-3 5-5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="3" x2="12" y2="12"/>
|
||||
<line x1="12" y1="12" x2="5" y2="21"/>
|
||||
<line x1="12" y1="12" x2="19" y2="21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 310 B |
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="3" x2="5" y2="12"/>
|
||||
<line x1="19" y1="3" x2="19" y2="12"/>
|
||||
<line x1="5" y1="12" x2="12" y2="12"/>
|
||||
<line x1="19" y1="12" x2="12" y2="12"/>
|
||||
<line x1="12" y1="12" x2="12" y2="21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="4" height="16" rx="1"/>
|
||||
<rect class="fillable" fill="none" x="7" y="4" width="4" height="8" rx="0"/>
|
||||
<rect x="11" y="4" width="4" height="16" rx="1"/>
|
||||
<rect class="fillable" fill="none" x="15" y="4" width="4" height="8" rx="0"/>
|
||||
<rect x="19" y="4" width="2" height="16" rx="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="7" height="5" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="5" rx="1"/>
|
||||
<rect x="3" y="11" width="7" height="5" rx="1"/>
|
||||
<rect x="14" y="11" width="7" height="5" rx="1"/>
|
||||
<polygon points="5,13 5,15 7,14" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="3" y1="9" x2="21" y2="9"/>
|
||||
<line x1="3" y1="15" x2="21" y2="15"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/>
|
||||
<line x1="15" y1="3" x2="15" y2="21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
<circle class="fillable" fill="none" cx="12" cy="8" r="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 326 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||
<path class="fillable" fill="none" d="M4 19.5V5a2 2 0 0 1 2-2h14v14H6.5A2.5 2.5 0 0 0 4 19.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 333 B |
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="21" x2="4" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="3"/>
|
||||
<line x1="20" y1="21" x2="20" y2="3"/>
|
||||
<rect class="fillable" fill="none" x="2" y="8" width="4" height="4" rx="1"/>
|
||||
<rect class="fillable" fill="none" x="10" y="14" width="4" height="4" rx="1"/>
|
||||
<rect class="fillable" fill="none" x="18" y="6" width="4" height="4" rx="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 541 B |
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path class="fillable" fill="none" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13"/>
|
||||
<line x1="8" y1="17" x2="13" y2="17"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 406 B |
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect class="fillable" fill="none" x="3" y="3" width="4" height="4" rx="0.5"/>
|
||||
<rect x="9" y="3" width="4" height="4" rx="0.5"/>
|
||||
<rect class="fillable" fill="none" x="15" y="3" width="4" height="4" rx="0.5"/>
|
||||
<rect x="3" y="10" width="4" height="4" rx="0.5"/>
|
||||
<rect class="fillable" fill="none" x="9" y="10" width="4" height="4" rx="0.5"/>
|
||||
<rect x="15" y="10" width="4" height="4" rx="0.5"/>
|
||||
<rect x="3" y="17" width="4" height="4" rx="0.5"/>
|
||||
<rect class="fillable" fill="none" x="9" y="17" width="4" height="4" rx="0.5"/>
|
||||
<rect class="fillable" fill="none" x="15" y="17" width="4" height="4" rx="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 796 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 357 B |
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12h3l2-6 3 12 3-12 2 6h3"/>
|
||||
<rect class="fillable" fill="none" x="19" y="8" width="3" height="8" rx="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 319 B |
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 66.3 66.3" style="enable-background:new 0 0 66.3 66.3;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#11007F;}
|
||||
.st1{fill:#F9C543;}
|
||||
.st2{font-family:'ArialMT';}
|
||||
.st3{font-size:20.3402px;}
|
||||
.st4{fill:#DD0074;}
|
||||
.st5{font-size:12px;}
|
||||
.st6{font-size:28.3253px;}
|
||||
.st7{fill:none;stroke:#FFFFFF;stroke-width:0.75;stroke-miterlimit:10;}
|
||||
</style>
|
||||
<g id="Text">
|
||||
<circle class="st0" cx="45.5" cy="33.1" r="20"/>
|
||||
<circle class="st1" cx="20.8" cy="33.1" r="20"/>
|
||||
<circle class="st1" cx="45.5" cy="33.1" r="18.8"/>
|
||||
<circle class="st0" cx="20.8" cy="33.1" r="18.8"/>
|
||||
<text transform="matrix(1 0 0 1 7.4957 39.8482)" class="st1 st2 st3">Au</text>
|
||||
<text transform="matrix(1 0 0 1 30.163 42.6969)" class="st4 st2 st5">2</text>
|
||||
<text transform="matrix(1 0 0 1 38.9518 42.6969)" class="st0 st2 st6">O</text>
|
||||
<text transform="matrix(1 0 0 1 56.5909 43.1403)" class="st4 st2 st5">3</text>
|
||||
</g>
|
||||
<circle class="st7" cx="33.1" cy="33.1" r="32.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1,49 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<defs>
|
||||
<linearGradient id="g1" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#007AFF"/>
|
||||
<stop offset="100%" stop-color="#005CBF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="128" cy="128" r="120" fill="none" stroke="url(#g1)" stroke-width="4"/>
|
||||
<circle cx="128" cy="128" r="100" fill="none" stroke="#007AFF" stroke-width="1.5" opacity="0.3"/>
|
||||
<!-- Waveform -->
|
||||
<path d="M 40 128
|
||||
Q 56 128, 64 108
|
||||
Q 72 88, 80 128
|
||||
Q 88 168, 96 128
|
||||
Q 100 108, 108 68
|
||||
Q 116 28, 124 128
|
||||
Q 132 228, 140 128
|
||||
Q 144 88, 152 128
|
||||
Q 160 168, 168 128
|
||||
Q 176 88, 184 128
|
||||
Q 192 148, 200 128
|
||||
Q 208 118, 216 128"
|
||||
fill="none" stroke="url(#g1)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- Analytic envelope (ghost) -->
|
||||
<path d="M 40 128
|
||||
Q 56 126, 64 118
|
||||
Q 72 110, 80 114
|
||||
Q 88 118, 96 98
|
||||
Q 104 78, 112 70
|
||||
Q 120 62, 128 66
|
||||
Q 136 70, 144 82
|
||||
Q 152 94, 160 102
|
||||
Q 168 110, 176 114
|
||||
Q 184 118, 192 122
|
||||
Q 200 124, 216 128"
|
||||
fill="none" stroke="#007AFF" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.5" stroke-linecap="round"/>
|
||||
<path d="M 40 128
|
||||
Q 56 130, 64 138
|
||||
Q 72 146, 80 142
|
||||
Q 88 138, 96 158
|
||||
Q 104 178, 112 186
|
||||
Q 120 194, 128 190
|
||||
Q 136 186, 144 174
|
||||
Q 152 162, 160 154
|
||||
Q 168 146, 176 142
|
||||
Q 184 138, 192 134
|
||||
Q 200 132, 216 128"
|
||||
fill="none" stroke="#007AFF" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -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<AutomationPoint>,
|
||||
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<f32> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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<ClipboardEntry>,
|
||||
}
|
||||
|
||||
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(); }
|
||||
}
|
||||
|
|
@ -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<std::io::Error> for XtcError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
XtcError::Io(e)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
mod error;
|
||||
mod xtc;
|
||||
|
||||
pub use xtc::{XtcDecoder, XtcEncoder};
|
||||
|
|
@ -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<Self, XtcError> {
|
||||
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::<u32>() {
|
||||
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<f32>, Vec<f32>), 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<i32> {
|
||||
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<flacenc::component::MetadataBlockData, XtcError> {
|
||||
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<f32> = (0..n).map(|i| (i as f32 / n as f32 * std::f32::consts::TAU).sin()).collect();
|
||||
let real_r: Vec<f32> = (0..n).map(|i| (i as f32 / n as f32 * std::f32::consts::TAU * 2.0).sin()).collect();
|
||||
let imag_l: Vec<f32> = (0..n).map(|i| (i as f32 / n as f32 * std::f32::consts::TAU).cos()).collect();
|
||||
let imag_r: Vec<f32> = (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<f32> = (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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<crate::track::Track>,
|
||||
#[serde(default)]
|
||||
pub markers: Vec<crate::timing::Marker>,
|
||||
#[serde(default)]
|
||||
pub tempo_points: Vec<crate::timing::TempoPoint>,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<crate::track::TrackGroup>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Mutex<BufWriter<Box<dyn Write + Send>>>> = 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<dyn Write + Send> = 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<dyn Write + Send> = 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)*) => {()};
|
||||
}
|
||||
|
|
@ -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<oxforge::mdk::MidiPlaybackNote> = 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Message> {
|
||||
match message {
|
||||
Message::TriggerClip { track_index, region_id } => {
|
||||
if let Some(track) = self.tracks.get(track_index) {
|
||||
let track_clip_ids: Vec<uuid::Uuid> = 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<uuid::Uuid> = 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Message> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Message> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u16> = 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u32> = 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<u32> = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
use std::path::Path;
|
||||
|
||||
pub(crate) fn decode_region_audio(path: &Path, project_rate: u32) -> Option<(Vec<f32>, Vec<f32>)> {
|
||||
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<f32>, audio_r: Vec<f32>, rate: f32) -> (Vec<f32>, Vec<f32>) {
|
||||
if (rate - 1.0).abs() < 0.001 {
|
||||
return (audio_l, audio_r);
|
||||
}
|
||||
crate::engine::resample::stretch_stereo(&audio_l, &audio_r, rate)
|
||||
}
|
||||
|
|
@ -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<Message>) {
|
||||
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<uuid::Uuid> =
|
||||
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<oxforge::mdk::MidiPlaybackNote> = 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Message> {
|
||||
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::<u32>().unwrap_or(self.current_position.bar).max(1);
|
||||
let beat = self.lcd_beat_input.parse::<u32>().unwrap_or(self.current_position.beat).clamp(1, self.time_signature_numerator as u32);
|
||||
let tick = self.lcd_tick_input.parse::<u32>().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<Message> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u64> = 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<Region> = 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u32>,
|
||||
pub descriptors: HashMap<u32, Vec<oxforge::mdk::ParameterDescriptor>>,
|
||||
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<Track>,
|
||||
pub(crate) modal_state: Option<ModalState>,
|
||||
pub(crate) engine: Option<EngineHandle>,
|
||||
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<ScrollSource>,
|
||||
|
||||
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<usize>,
|
||||
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<crate::timing::Marker>,
|
||||
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<crate::track::TrackGroup>,
|
||||
pub(crate) active_clips: std::collections::HashSet<uuid::Uuid>,
|
||||
pub(crate) score_note_duration: score::ScoreNoteDuration,
|
||||
pub(crate) icons: IconSet,
|
||||
pub(crate) meter_levels: HashMap<String, (f32, f32)>,
|
||||
pub(crate) master_meter: (f32, f32),
|
||||
|
||||
pub(crate) session_player_config: SessionPlayerConfig,
|
||||
pub(crate) session_player_bars: u32,
|
||||
pub(crate) discovered_plugins: Vec<PluginInfo>,
|
||||
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<uuid::Uuid> },
|
||||
|
||||
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<Message> {
|
||||
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<u32> {
|
||||
self.module_gui.module_for_window(window_id)
|
||||
}
|
||||
|
||||
pub fn module_window_view(&self, window_id: window::Id) -> Option<Element<'_, Message>> {
|
||||
self.module_gui.module_window_view(window_id)
|
||||
}
|
||||
|
||||
pub fn module_window_title(&self, window_id: window::Id) -> Option<String> {
|
||||
self.module_gui.module_window_title(window_id)
|
||||
}
|
||||
|
||||
pub fn close_module_window_by_id(&mut self, window_id: window::Id) -> Task<Message> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
use iced::Task;
|
||||
|
||||
use super::{Editor, Message};
|
||||
|
||||
impl Editor {
|
||||
pub(crate) fn handle_module_gui(&mut self, message: Message) -> Task<Message> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Message> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<crate::region::MidiNote> = 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<f32> = Vec::new();
|
||||
let mut all_r: Vec<f32> = 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Message> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Message> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Message> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||