init commit
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="EMPTY_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/audio-oxide.iml" filepath="$PROJECT_DIR$/.idea/audio-oxide.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
[package]
|
||||||
|
name = "audio-oxide"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "audio-oxide"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# --- GUI Framework ---
|
||||||
|
iced = { version = "0.13.1", features = ["tokio", "debug", "canvas", "advanced", "multi-window", "system", "svg", "tiny-skia", "web-colors", "markdown"] }
|
||||||
|
|
||||||
|
# --- Core Audio & Hardware ---
|
||||||
|
cpal = "0.16.0"
|
||||||
|
ringbuf = "0.4.8"
|
||||||
|
crossbeam-channel = "0.5.12"
|
||||||
|
|
||||||
|
# --- Module System ---
|
||||||
|
oxforge = { path = "../oxforge" }
|
||||||
|
libloading = "0.8"
|
||||||
|
rustfft = "6"
|
||||||
|
|
||||||
|
# --- 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"
|
||||||
|
|
@ -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,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="M9 20h6v-4a3 3 0 0 0-6 0z"/>
|
||||||
|
<rect x="8" y="11" width="8" height="5" rx="1"/>
|
||||||
|
<line x1="10" y1="11" x2="10" y2="8"/>
|
||||||
|
<line x1="14" y1="11" x2="14" y2="8"/>
|
||||||
|
<path d="M10 8a2 2 0 0 1 4 0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 418 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" 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,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,122 @@
|
||||||
|
Compiling audio-oxide v0.1.0 (/Users/pszsh/Staging/audio-oxide/audio-oxide)
|
||||||
|
warning: field `0` is never read
|
||||||
|
--> src/editor.rs:29:15
|
||||||
|
|
|
||||||
|
29 | Transport(transport::Message),
|
||||||
|
| --------- ^^^^^^^^^^^^^^^^^^
|
||||||
|
| |
|
||||||
|
| field in this variant
|
||||||
|
|
|
||||||
|
= note: `Message` has derived impls for the traits `Clone` and `Debug`, but these are intentionally ignored during dead code analysis
|
||||||
|
= note: `#[warn(dead_code)]` on by default
|
||||||
|
help: consider changing the field to be of unit type to suppress this warning while preserving the field numbering, or remove the field
|
||||||
|
|
|
||||||
|
29 - Transport(transport::Message),
|
||||||
|
29 + Transport(()),
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: field `0` is never read
|
||||||
|
--> src/editor.rs:33:23
|
||||||
|
|
|
||||||
|
33 | TrackListScrolled(scrollable::Viewport),
|
||||||
|
| ----------------- ^^^^^^^^^^^^^^^^^^^^
|
||||||
|
| |
|
||||||
|
| field in this variant
|
||||||
|
|
|
||||||
|
= note: `Message` has derived impls for the traits `Clone` and `Debug`, but these are intentionally ignored during dead code analysis
|
||||||
|
help: consider changing the field to be of unit type to suppress this warning while preserving the field numbering, or remove the field
|
||||||
|
|
|
||||||
|
33 - TrackListScrolled(scrollable::Viewport),
|
||||||
|
33 + TrackListScrolled(()),
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: field `0` is never read
|
||||||
|
--> src/editor.rs:34:14
|
||||||
|
|
|
||||||
|
34 | Timeline(timeline::Message),
|
||||||
|
| -------- ^^^^^^^^^^^^^^^^^
|
||||||
|
| |
|
||||||
|
| field in this variant
|
||||||
|
|
|
||||||
|
= note: `Message` has derived impls for the traits `Clone` and `Debug`, but these are intentionally ignored during dead code analysis
|
||||||
|
help: consider changing the field to be of unit type to suppress this warning while preserving the field numbering, or remove the field
|
||||||
|
|
|
||||||
|
34 - Timeline(timeline::Message),
|
||||||
|
34 + Timeline(()),
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: field `0` is never read
|
||||||
|
--> src/editor.rs:35:22
|
||||||
|
|
|
||||||
|
35 | TimelineScrolled(scrollable::Viewport),
|
||||||
|
| ---------------- ^^^^^^^^^^^^^^^^^^^^
|
||||||
|
| |
|
||||||
|
| field in this variant
|
||||||
|
|
|
||||||
|
= note: `Message` has derived impls for the traits `Clone` and `Debug`, but these are intentionally ignored during dead code analysis
|
||||||
|
help: consider changing the field to be of unit type to suppress this warning while preserving the field numbering, or remove the field
|
||||||
|
|
|
||||||
|
35 - TimelineScrolled(scrollable::Viewport),
|
||||||
|
35 + TimelineScrolled(()),
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: variants `SelectTrack` and `SelectRegion` are never constructed
|
||||||
|
--> src/gui/editor/timeline.rs:15:5
|
||||||
|
|
|
||||||
|
14 | pub enum Message {
|
||||||
|
| ------- variants in this enum
|
||||||
|
15 | SelectTrack,
|
||||||
|
| ^^^^^^^^^^^
|
||||||
|
16 | SelectRegion,
|
||||||
|
| ^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: `Message` has derived impls for the traits `Clone` and `Debug`, but these are intentionally ignored during dead code analysis
|
||||||
|
|
||||||
|
warning: associated function `new` is never used
|
||||||
|
--> src/region.rs:16:12
|
||||||
|
|
|
||||||
|
14 | impl Region {
|
||||||
|
| ----------- associated function in this implementation
|
||||||
|
15 | /// Creates a new region.
|
||||||
|
16 | pub fn new(start_time: MusicalTime, duration: MusicalTime) -> Self {
|
||||||
|
| ^^^
|
||||||
|
|
||||||
|
warning: constant `TICKS_PER_BEAT` is never used
|
||||||
|
--> src/timing.rs:6:11
|
||||||
|
|
|
||||||
|
6 | pub const TICKS_PER_BEAT: u32 = 960;
|
||||||
|
| ^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
warning: method `as_total_ticks` is never used
|
||||||
|
--> src/timing.rs:23:12
|
||||||
|
|
|
||||||
|
16 | impl MusicalTime {
|
||||||
|
| ---------------- method in this implementation
|
||||||
|
...
|
||||||
|
23 | pub fn as_total_ticks(&self, beats_per_bar: u32) -> u64 {
|
||||||
|
| ^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
warning: constant `TRACK_HEIGHT` is never used
|
||||||
|
--> src/track.rs:6:11
|
||||||
|
|
|
||||||
|
6 | pub const TRACK_HEIGHT: f32 = 125.0;
|
||||||
|
| ^^^^^^^^^^^^
|
||||||
|
|
||||||
|
warning: hiding a lifetime that's elided elsewhere is confusing
|
||||||
|
--> src/editor.rs:114:17
|
||||||
|
|
|
||||||
|
114 | pub fn view(&self) -> Element<Message> {
|
||||||
|
| ^^^^^ ---------------- the same lifetime is hidden here
|
||||||
|
| |
|
||||||
|
| the lifetime is elided here
|
||||||
|
|
|
||||||
|
= help: the same lifetime is referred to in inconsistent ways, making the signature confusing
|
||||||
|
= note: `#[warn(mismatched_lifetime_syntaxes)]` on by default
|
||||||
|
help: use `'_` for type paths
|
||||||
|
|
|
||||||
|
114 | pub fn view(&self) -> Element<'_, Message> {
|
||||||
|
| +++
|
||||||
|
|
||||||
|
warning: `audio-oxide` (bin "audio-oxide") generated 10 warnings
|
||||||
|
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.17s
|
||||||
|
Running `target/debug/audio-oxide`
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from markdownify import markdownify as md
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Make sure you have the required libraries:
|
||||||
|
# pip install requests beautifulsoup4 markdownify
|
||||||
|
|
||||||
|
def sanitize_filename(url_path):
|
||||||
|
"""Creates a safe filename from a URL path."""
|
||||||
|
sanitized = re.sub(r'[^a-zA-Z0-9\.]+', '_', url_path)
|
||||||
|
if not sanitized or sanitized.endswith('_html'):
|
||||||
|
sanitized = sanitized.replace('_html', '')
|
||||||
|
if not sanitized:
|
||||||
|
return "index.md"
|
||||||
|
return sanitized.strip('_').lower()[:100] + ".md"
|
||||||
|
|
||||||
|
def scrape_and_convert_url(url, session):
|
||||||
|
"""
|
||||||
|
Fetches a single URL, finds its main documentation content,
|
||||||
|
and converts it to Markdown text.
|
||||||
|
"""
|
||||||
|
print(f"Fetching: {url}")
|
||||||
|
try:
|
||||||
|
response = session.get(url, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.text, 'html.parser')
|
||||||
|
|
||||||
|
# 1. Try to find the most specific ID first.
|
||||||
|
main_content = soup.find(id="main-content")
|
||||||
|
|
||||||
|
# 2. If that fails, fall back to finding the standard <main> HTML tag.
|
||||||
|
if not main_content:
|
||||||
|
main_content = soup.find("main")
|
||||||
|
|
||||||
|
if main_content:
|
||||||
|
# Prepend the source URL to the content
|
||||||
|
header = f"# Source: {url}\n\n"
|
||||||
|
markdown_text = md(str(main_content), heading_style="ATX")
|
||||||
|
print(f" -> Success: Converted content.")
|
||||||
|
return header + markdown_text
|
||||||
|
else:
|
||||||
|
print(f" -> Warning: Could not find a recognizable main content section in page.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f" -> Error: Failed to fetch URL {url}. Reason: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f" -> Error: An unexpected error occurred for {url}. Reason: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def process_url_list(base_url, paths, output_dir):
|
||||||
|
"""
|
||||||
|
Processes a list of URL paths, converts their content to Markdown,
|
||||||
|
and saves each into a separate file in the output directory.
|
||||||
|
"""
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
print(f"Saving files to '{output_dir}/' directory.")
|
||||||
|
|
||||||
|
with requests.Session() as session:
|
||||||
|
session.headers.update({'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'})
|
||||||
|
|
||||||
|
processed_urls = set()
|
||||||
|
for path in paths:
|
||||||
|
full_url = base_url + path
|
||||||
|
cleaned_url = full_url.split('#')[0]
|
||||||
|
|
||||||
|
if not cleaned_url or not cleaned_url.startswith("http") or cleaned_url in processed_urls:
|
||||||
|
continue
|
||||||
|
|
||||||
|
processed_urls.add(cleaned_url)
|
||||||
|
|
||||||
|
content = scrape_and_convert_url(cleaned_url, session)
|
||||||
|
|
||||||
|
if content:
|
||||||
|
filename = sanitize_filename(path)
|
||||||
|
filepath = os.path.join(output_dir, filename)
|
||||||
|
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
print(f" -> Saved: {filepath}")
|
||||||
|
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
print(f"\n🎉 Conversion complete! All content saved to the '{output_dir}' directory.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
BASE_URL = "https://doc.rust-lang.org/book/"
|
||||||
|
URL_PATHS = [
|
||||||
|
"", "appendix-00.html", "appendix-01-keywords.html", "appendix-02-operators.html",
|
||||||
|
"appendix-03-derivable-traits.html", "appendix-04-useful-development-tools.html",
|
||||||
|
"appendix-05-editions.html", "appendix-06-translation.html", "appendix-07-nightly-rust.html",
|
||||||
|
"ch00-00-introduction.html", "ch01-00-getting-started.html", "ch01-01-installation.html",
|
||||||
|
"ch01-02-hello-world.html", "ch01-03-hello-cargo.html", "ch02-00-guessing-game-tutorial.html",
|
||||||
|
"ch03-00-common-programming-concepts.html", "ch03-01-variables-and-mutability.html",
|
||||||
|
"ch03-02-data-types.html", "ch03-03-how-functions-work.html", "ch03-04-comments.html",
|
||||||
|
"ch03-05-control-flow.html", "ch04-00-understanding-ownership.html", "ch04-01-what-is-ownership.html",
|
||||||
|
"ch04-02-references-and-borrowing.html", "ch04-03-slices.html", "ch05-00-structs.html",
|
||||||
|
"ch05-01-defining-structs.html", "ch05-02-example-structs.html", "ch05-03-method-syntax.html",
|
||||||
|
"ch06-00-enums.html", "ch06-01-defining-an-enum.html", "ch06-02-match.html", "ch06-03-if-let.html",
|
||||||
|
"ch07-00-managing-growing-projects-with-packages-crates-and-modules.html", "ch07-01-packages-and-crates.html",
|
||||||
|
"ch07-02-defining-modules-to-control-scope-and-privacy.html", "ch07-03-paths-for-referring-to-an-item-in-the-module-tree.html",
|
||||||
|
"ch07-04-bringing-paths-into-scope-with-the-use-keyword.html", "ch07-05-separating-modules-into-different-files.html",
|
||||||
|
"ch08-00-common-collections.html", "ch08-01-vectors.html", "ch08-02-strings.html",
|
||||||
|
"ch08-03-hash-maps.html", "ch09-00-error-handling.html", "ch09-01-unrecoverable-errors-with-panic.html",
|
||||||
|
"ch09-02-recoverable-errors-with-result.html", "ch09-03-to-panic-or-not-to-panic.html",
|
||||||
|
"ch10-00-generics.html", "ch10-01-syntax.html", "ch10-02-traits.html",
|
||||||
|
"ch10-03-lifetime-syntax.html", "ch11-00-testing.html", "ch11-01-writing-tests.html",
|
||||||
|
"ch11-02-running-tests.html", "ch11-03-test-organization.html", "ch12-00-an-io-project.html",
|
||||||
|
"ch12-01-accepting-command-line-arguments.html", "ch12-02-reading-a-file.html",
|
||||||
|
"ch12-03-improving-error-handling-and-modularity.html", "ch12-04-testing-the-librarys-functionality.html",
|
||||||
|
"ch12-05-working-with-environment-variables.html", "ch12-06-writing-to-stderr-instead-of-stdout.html",
|
||||||
|
"ch13-00-functional-features.html", "ch13-01-closures.html", "ch13-02-iterators.html",
|
||||||
|
"ch13-03-improving-our-io-project.html", "ch13-04-performance.html", "ch14-00-more-about-cargo.html",
|
||||||
|
"ch14-01-release-profiles.html", "ch14-02-publishing-to-crates-io.html",
|
||||||
|
"ch14-03-cargo-workspaces.html", "ch14-04-installing-binaries.html", "ch14-05-extending-cargo.html",
|
||||||
|
"ch15-00-smart-pointers.html", "ch15-01-box.html", "ch15-02-deref.html", "ch15-03-drop.html",
|
||||||
|
"ch15-04-rc.html", "ch15-05-interior-mutability.html", "ch15-06-reference-cycles.html",
|
||||||
|
"ch16-00-concurrency.html", "ch16-01-threads.html", "ch16-02-message-passing.html",
|
||||||
|
"ch16-03-shared-state.html", "ch16-04-extensible-concurrency-sync-and-send.html",
|
||||||
|
"ch17-00-async-await.html", "ch17-01-futures-and-syntax.html", "ch17-02-concurrency-with-async.html",
|
||||||
|
"ch17-03-more-futures.html", "ch17-04-streams.html", "ch17-05-traits-for-async.html",
|
||||||
|
"ch17-06-futures-tasks-threads.html", "ch18-00-oop.html", "ch18-01-what-is-oo.html",
|
||||||
|
"ch18-02-trait-objects.html", "ch18-03-oo-design-patterns.html", "ch19-00-patterns.html",
|
||||||
|
"ch19-01-all-the-places-for-patterns.html", "ch19-02-refutability.html", "ch19-03-pattern-syntax.html",
|
||||||
|
"ch20-00-advanced-features.html", "ch20-01-unsafe-rust.html", "ch20-02-advanced-traits.html",
|
||||||
|
"ch20-03-advanced-types.html", "ch20-04-advanced-functions-and-closures.html", "ch20-05-macros.html",
|
||||||
|
"ch21-00-final-project-a-web-server.html", "ch21-01-single-threaded.html", "ch21-02-multithreaded.html",
|
||||||
|
"ch21-03-graceful-shutdown-and-cleanup.html"
|
||||||
|
]
|
||||||
|
|
||||||
|
OUTPUT_DIRECTORY = "rust_book_markdown"
|
||||||
|
|
||||||
|
process_url_list(BASE_URL, URL_PATHS, OUTPUT_DIRECTORY)
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from markdownify import markdownify as md
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Make sure you have the required libraries:
|
||||||
|
# pip install requests beautifulsoup4 markdownify
|
||||||
|
|
||||||
|
def sanitize_filename(url):
|
||||||
|
"""Creates a safe filename from a URL."""
|
||||||
|
if "docs.rs/" in url:
|
||||||
|
url = url.split("docs.rs/", 1)[1]
|
||||||
|
sanitized = re.sub(r'[^a-zA-Z0-9\.]+', '_', url)
|
||||||
|
return sanitized.strip('_').lower()[:100]
|
||||||
|
|
||||||
|
def scrape_and_convert_url(url, session):
|
||||||
|
"""
|
||||||
|
Fetches a single URL, finds its main documentation content,
|
||||||
|
and converts it to Markdown text.
|
||||||
|
"""
|
||||||
|
print(f"Fetching: {url}")
|
||||||
|
try:
|
||||||
|
response = session.get(url, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.text, 'html.parser')
|
||||||
|
|
||||||
|
# --- THIS IS THE CORRECTED LOGIC ---
|
||||||
|
# First, try to find the main content by its ID, which is the most reliable selector.
|
||||||
|
main_content = soup.find(id="main-content")
|
||||||
|
|
||||||
|
# If that fails (which is unlikely), fall back to the class name as a safety measure.
|
||||||
|
if not main_content:
|
||||||
|
main_content = soup.find(class_="main-content")
|
||||||
|
# --- END OF CORRECTION ---
|
||||||
|
|
||||||
|
if main_content:
|
||||||
|
markdown_text = md(str(main_content), heading_style="ATX")
|
||||||
|
print(f" -> Success: Converted content.")
|
||||||
|
return markdown_text
|
||||||
|
else:
|
||||||
|
print(f" -> Warning: Could not find a recognizable main content section in page.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f" -> Error: Failed to fetch URL {url}. Reason: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f" -> Error: An unexpected error occurred for {url}. Reason: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def process_url_list(urls, output_file):
|
||||||
|
"""
|
||||||
|
Processes a list of URLs, converts their content to Markdown,
|
||||||
|
and saves everything into a single output file.
|
||||||
|
"""
|
||||||
|
with requests.Session() as session:
|
||||||
|
session.headers.update({'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'})
|
||||||
|
|
||||||
|
with open(output_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write("# Iced Pocket Guide\n\n")
|
||||||
|
|
||||||
|
processed_urls = set()
|
||||||
|
for url in urls:
|
||||||
|
cleaned_url = url.split('#')[0]
|
||||||
|
if not cleaned_url or not cleaned_url.startswith("http") or cleaned_url in processed_urls:
|
||||||
|
if cleaned_url in processed_urls:
|
||||||
|
print(f"Skipping duplicate URL: {cleaned_url}")
|
||||||
|
else:
|
||||||
|
print(f"Skipping invalid or anchor-only URL: {url}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
processed_urls.add(cleaned_url)
|
||||||
|
|
||||||
|
f.write(f"\n---\n\n")
|
||||||
|
f.write(f"## Source: [{cleaned_url}]({cleaned_url})\n\n")
|
||||||
|
|
||||||
|
content = scrape_and_convert_url(cleaned_url, session)
|
||||||
|
|
||||||
|
if content:
|
||||||
|
f.write(content)
|
||||||
|
else:
|
||||||
|
f.write("*Failed to retrieve or convert content for this URL.*")
|
||||||
|
|
||||||
|
f.write("\n\n")
|
||||||
|
|
||||||
|
print(f"\n🎉 Conversion complete! All content saved to '{output_file}'.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
pocket_guide_urls = [
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/advanced/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/application/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/application/struct.Application.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/widget/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/widget/struct.Container.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/widget/struct.Column.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/widget/struct.Row.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/enum.Length.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/alignment/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/enum.Alignment.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/type.Element.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/struct.Task.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/task/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/struct.Subscription.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/stream/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/daemon/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/daemon/struct.Daemon.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/theme/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/enum.Theme.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/settings/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/settings/struct.Settings.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/window/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/keyboard/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/mouse/index.html",
|
||||||
|
"https://docs.rs/iced/0.13.1/iced/touch/index.html"
|
||||||
|
]
|
||||||
|
|
||||||
|
OUTPUT_FILENAME = "iced_pocket_guide.md"
|
||||||
|
|
||||||
|
process_url_list(pocket_guide_urls, OUTPUT_FILENAME)
|
||||||
|
|
@ -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,130 @@
|
||||||
|
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")]
|
||||||
|
pub default_buffer_size: u32,
|
||||||
|
#[serde(default = "default_audio_device")]
|
||||||
|
pub default_audio_device: String,
|
||||||
|
#[serde(default = "default_recording_format")]
|
||||||
|
pub recording_format: RecordingFormat,
|
||||||
|
#[serde(default = "default_bit_depth")]
|
||||||
|
pub recording_bit_depth: u16,
|
||||||
|
|
||||||
|
// 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_buffer_size: 512,
|
||||||
|
default_audio_device: "Default".to_string(),
|
||||||
|
recording_format: RecordingFormat::Wav,
|
||||||
|
recording_bit_depth: 24,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingFormat {
|
||||||
|
pub const ALL: [RecordingFormat; 3] = [RecordingFormat::Wav, RecordingFormat::Aiff, RecordingFormat::Caf];
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct ProjectConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub buffer_size: u32,
|
||||||
|
pub audio_device: String,
|
||||||
|
#[serde(default = "default_audio_device")]
|
||||||
|
pub audio_input_device: String,
|
||||||
|
pub tempo: f32,
|
||||||
|
pub time_signature_numerator: u8,
|
||||||
|
pub time_signature_denominator: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProjectConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "Untitled".to_string(),
|
||||||
|
sample_rate: 48000,
|
||||||
|
buffer_size: 512,
|
||||||
|
audio_device: "Default".to_string(),
|
||||||
|
audio_input_device: "Default".to_string(),
|
||||||
|
tempo: 120.0,
|
||||||
|
time_signature_numerator: 4,
|
||||||
|
time_signature_denominator: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_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,702 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use iced::widget::{
|
||||||
|
button, column, container, mouse_area, row, scrollable, stack, Column, Space,
|
||||||
|
};
|
||||||
|
use iced::{alignment, Alignment, Background, Color, Element, Length, Task, Theme};
|
||||||
|
use oxforge::mdk::ToGuiMessage;
|
||||||
|
|
||||||
|
use crate::behaviors;
|
||||||
|
use crate::config::ProjectConfig;
|
||||||
|
use crate::engine::{EngineCommand, EngineEvent, EngineHandle, TransportState};
|
||||||
|
use crate::gui::editor::{
|
||||||
|
control_bar, editor_pane, inspector, menu_bar, mixer, new_track_wizard, timeline, toolbar,
|
||||||
|
track_header, visualizer,
|
||||||
|
};
|
||||||
|
use crate::gui::icons::IconSet;
|
||||||
|
use crate::modules::VisualizationFrame;
|
||||||
|
use crate::timing::MusicalTime;
|
||||||
|
use crate::track::{Track, TRACK_HEIGHT};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Tool {
|
||||||
|
Pointer,
|
||||||
|
Pencil,
|
||||||
|
Eraser,
|
||||||
|
Scissors,
|
||||||
|
Glue,
|
||||||
|
Zoom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tool {
|
||||||
|
pub const ALL: [Tool; 6] = [
|
||||||
|
Tool::Pointer,
|
||||||
|
Tool::Pencil,
|
||||||
|
Tool::Eraser,
|
||||||
|
Tool::Scissors,
|
||||||
|
Tool::Glue,
|
||||||
|
Tool::Zoom,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Tool::Pointer => "Ptr",
|
||||||
|
Tool::Pencil => "Pen",
|
||||||
|
Tool::Eraser => "Ers",
|
||||||
|
Tool::Scissors => "Cut",
|
||||||
|
Tool::Glue => "Glue",
|
||||||
|
Tool::Zoom => "Zoom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hint(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Tool::Pointer => "Pointer",
|
||||||
|
Tool::Pencil => "Pencil",
|
||||||
|
Tool::Eraser => "Eraser",
|
||||||
|
Tool::Scissors => "Scissors",
|
||||||
|
Tool::Glue => "Glue",
|
||||||
|
Tool::Zoom => "Zoom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BottomPanelMode {
|
||||||
|
Editor,
|
||||||
|
Mixer,
|
||||||
|
Visualizer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ScrollSource {
|
||||||
|
TrackList,
|
||||||
|
Timeline,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ModalState {
|
||||||
|
NewTrackWizard(new_track_wizard::State),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Editor {
|
||||||
|
project_config: ProjectConfig,
|
||||||
|
tracks: Vec<Track>,
|
||||||
|
modal_state: Option<ModalState>,
|
||||||
|
menu_state: menu_bar::State,
|
||||||
|
engine: Option<EngineHandle>,
|
||||||
|
|
||||||
|
// Transport / playback
|
||||||
|
transport: TransportState,
|
||||||
|
current_position: MusicalTime,
|
||||||
|
tempo: f32,
|
||||||
|
time_signature_numerator: u8,
|
||||||
|
time_signature_denominator: u8,
|
||||||
|
|
||||||
|
// Tool selection
|
||||||
|
active_tool: Tool,
|
||||||
|
|
||||||
|
// Panel visibility
|
||||||
|
show_inspector: bool,
|
||||||
|
show_bottom_panel: bool,
|
||||||
|
bottom_panel_mode: BottomPanelMode,
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
header_width: f32,
|
||||||
|
bottom_panel_height: f32,
|
||||||
|
|
||||||
|
// Scroll sync
|
||||||
|
track_list_scrollable_id: scrollable::Id,
|
||||||
|
timeline_scrollable_id: scrollable::Id,
|
||||||
|
scroll_offset_y: f32,
|
||||||
|
scroll_source: Option<ScrollSource>,
|
||||||
|
|
||||||
|
// Mode toggles
|
||||||
|
cycle_enabled: bool,
|
||||||
|
metronome_enabled: bool,
|
||||||
|
count_in_enabled: bool,
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
selected_track: Option<usize>,
|
||||||
|
|
||||||
|
// Track creation counter for color assignment
|
||||||
|
track_count: usize,
|
||||||
|
|
||||||
|
// Zoom
|
||||||
|
h_zoom: f32,
|
||||||
|
v_zoom: f32,
|
||||||
|
|
||||||
|
// Module system
|
||||||
|
module_names: HashMap<u32, String>,
|
||||||
|
latest_viz_frame: Option<VisualizationFrame>,
|
||||||
|
viz_rotation: f32,
|
||||||
|
|
||||||
|
icons: IconSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
// Transport
|
||||||
|
PlayPressed,
|
||||||
|
StopPressed,
|
||||||
|
RecordPressed,
|
||||||
|
RewindPressed,
|
||||||
|
FastForwardPressed,
|
||||||
|
|
||||||
|
// Mode toggles
|
||||||
|
CycleToggled,
|
||||||
|
MetronomeToggled,
|
||||||
|
CountInToggled,
|
||||||
|
|
||||||
|
// Tool
|
||||||
|
ToolSelected(Tool),
|
||||||
|
|
||||||
|
// Panel visibility
|
||||||
|
ToggleInspector,
|
||||||
|
ToggleBottomPanel,
|
||||||
|
SetBottomPanelMode(BottomPanelMode),
|
||||||
|
|
||||||
|
// Track management
|
||||||
|
ShowNewTrackWizard,
|
||||||
|
NewTrackWizard(new_track_wizard::Message),
|
||||||
|
TrackHeader(usize, track_header::Message),
|
||||||
|
|
||||||
|
// Timeline
|
||||||
|
Timeline(timeline::Message),
|
||||||
|
|
||||||
|
// Scroll
|
||||||
|
TrackListScrolled(scrollable::Viewport),
|
||||||
|
TimelineScrolled(scrollable::Viewport),
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
EscapePressed,
|
||||||
|
CloseModal,
|
||||||
|
|
||||||
|
// Menu bar
|
||||||
|
MenuBar(menu_bar::Message),
|
||||||
|
MenuAction(behaviors::Action),
|
||||||
|
|
||||||
|
// Edit menu
|
||||||
|
EditAction(behaviors::Action),
|
||||||
|
|
||||||
|
// Zoom
|
||||||
|
ZoomH(f32),
|
||||||
|
ZoomV(f32),
|
||||||
|
|
||||||
|
// Module management
|
||||||
|
AddModuleToTrack(usize, String),
|
||||||
|
RemoveModuleFromTrack(usize, u32),
|
||||||
|
|
||||||
|
// Engine
|
||||||
|
EngineTick,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editor {
|
||||||
|
pub fn new(project_path: PathBuf) -> (Self, Task<Message>) {
|
||||||
|
let config_path = project_path.join("project.toml");
|
||||||
|
let project_config: ProjectConfig = fs::read_to_string(config_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|content| toml::from_str(&content).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let tempo = project_config.tempo;
|
||||||
|
let ts_num = project_config.time_signature_numerator;
|
||||||
|
let ts_den = project_config.time_signature_denominator;
|
||||||
|
|
||||||
|
let engine = Some(EngineHandle::spawn(
|
||||||
|
project_config.sample_rate,
|
||||||
|
project_config.buffer_size,
|
||||||
|
&project_config.audio_device,
|
||||||
|
&project_config.audio_input_device,
|
||||||
|
));
|
||||||
|
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
project_config,
|
||||||
|
tracks: Vec::new(),
|
||||||
|
modal_state: None,
|
||||||
|
menu_state: menu_bar::State::new(),
|
||||||
|
engine,
|
||||||
|
transport: TransportState::Stopped,
|
||||||
|
current_position: MusicalTime::new(1, 1, 0),
|
||||||
|
tempo,
|
||||||
|
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: 280.0,
|
||||||
|
bottom_panel_height: 250.0,
|
||||||
|
track_list_scrollable_id: scrollable::Id::unique(),
|
||||||
|
timeline_scrollable_id: scrollable::Id::unique(),
|
||||||
|
scroll_offset_y: 0.0,
|
||||||
|
scroll_source: None,
|
||||||
|
cycle_enabled: false,
|
||||||
|
metronome_enabled: false,
|
||||||
|
count_in_enabled: false,
|
||||||
|
selected_track: None,
|
||||||
|
track_count: 0,
|
||||||
|
h_zoom: 100.0,
|
||||||
|
v_zoom: 1.0,
|
||||||
|
module_names: HashMap::new(),
|
||||||
|
latest_viz_frame: None,
|
||||||
|
viz_rotation: 0.0,
|
||||||
|
icons: IconSet::load(),
|
||||||
|
},
|
||||||
|
Task::none(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&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 => {
|
||||||
|
self.transport = TransportState::Stopped;
|
||||||
|
self.current_position = MusicalTime::new(1, 1, 0);
|
||||||
|
if let Some(ref engine) = self.engine {
|
||||||
|
engine.send(EngineCommand::SetTransportState(TransportState::Stopped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::RecordPressed => {}
|
||||||
|
Message::RewindPressed => {
|
||||||
|
self.current_position = MusicalTime::new(1, 1, 0);
|
||||||
|
}
|
||||||
|
Message::FastForwardPressed => {}
|
||||||
|
Message::CycleToggled => self.cycle_enabled = !self.cycle_enabled,
|
||||||
|
Message::MetronomeToggled => self.metronome_enabled = !self.metronome_enabled,
|
||||||
|
Message::CountInToggled => self.count_in_enabled = !self.count_in_enabled,
|
||||||
|
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::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::EscapePressed => {
|
||||||
|
if self.menu_state.open.is_some() {
|
||||||
|
self.menu_state.open = None;
|
||||||
|
} else {
|
||||||
|
self.modal_state = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::CloseModal => {
|
||||||
|
self.modal_state = None;
|
||||||
|
}
|
||||||
|
Message::ShowNewTrackWizard => {
|
||||||
|
self.modal_state =
|
||||||
|
Some(ModalState::NewTrackWizard(new_track_wizard::State::default()));
|
||||||
|
}
|
||||||
|
Message::MenuBar(msg) => match msg {
|
||||||
|
menu_bar::Message::Open(id) => {
|
||||||
|
if self.menu_state.open == Some(id) {
|
||||||
|
self.menu_state.open = None;
|
||||||
|
} else {
|
||||||
|
self.menu_state.open = Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menu_bar::Message::Close => {
|
||||||
|
self.menu_state.open = None;
|
||||||
|
}
|
||||||
|
menu_bar::Message::Action(action) => {
|
||||||
|
self.menu_state.open = None;
|
||||||
|
return Task::done(Message::MenuAction(action));
|
||||||
|
}
|
||||||
|
menu_bar::Message::ShowNewTrackWizard => {
|
||||||
|
self.menu_state.open = None;
|
||||||
|
self.modal_state =
|
||||||
|
Some(ModalState::NewTrackWizard(new_track_wizard::State::default()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Message::MenuAction(_) => {}
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.tracks.push(track);
|
||||||
|
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,
|
||||||
|
track_header::Message::SoloToggled => track.soloed = !track.soloed,
|
||||||
|
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 = vol,
|
||||||
|
track_header::Message::PanChanged(pan) => track.pan = pan,
|
||||||
|
track_header::Message::Delete => {
|
||||||
|
let removed = self.tracks.remove(i);
|
||||||
|
if let Some(ref engine) = self.engine {
|
||||||
|
engine.send(EngineCommand::RemoveBus {
|
||||||
|
name: removed.bus_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::EditAction(action) => {
|
||||||
|
use behaviors::Action::*;
|
||||||
|
match action {
|
||||||
|
SelectAll => {
|
||||||
|
for t in &mut self.tracks {
|
||||||
|
t.selected = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Delete => {
|
||||||
|
if let Some(i) = self.selected_track {
|
||||||
|
if i < self.tracks.len() {
|
||||||
|
let removed = self.tracks.remove(i);
|
||||||
|
if let Some(ref engine) = self.engine {
|
||||||
|
engine.send(EngineCommand::RemoveBus {
|
||||||
|
name: removed.bus_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.selected_track = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.tracks.insert(i + 1, dup);
|
||||||
|
self.track_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Undo, Redo, Cut, Copy, Paste: stubs for now
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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(_) => {}
|
||||||
|
Message::TrackListScrolled(viewport) => {
|
||||||
|
if self.scroll_source == Some(ScrollSource::Timeline) {
|
||||||
|
self.scroll_source = None;
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
self.scroll_source = Some(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(ScrollSource::TrackList) {
|
||||||
|
self.scroll_source = None;
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
self.scroll_source = Some(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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Message::AddModuleToTrack(track_idx, module_type) => {
|
||||||
|
if let Some(track) = self.tracks.get(track_idx) {
|
||||||
|
let chain_pos = track.module_chain.len();
|
||||||
|
if let Some(ref engine) = self.engine {
|
||||||
|
engine.send(EngineCommand::LoadModuleOnBus {
|
||||||
|
bus_name: track.bus_name.clone(),
|
||||||
|
module_type,
|
||||||
|
chain_position: chain_pos,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::RemoveModuleFromTrack(track_idx, module_id) => {
|
||||||
|
if let Some(track) = self.tracks.get_mut(track_idx) {
|
||||||
|
track.module_chain.retain(|&id| id != module_id);
|
||||||
|
self.module_names.remove(&module_id);
|
||||||
|
if let Some(ref engine) = self.engine {
|
||||||
|
engine.send(EngineCommand::UnloadModule { module_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::EngineTick => {
|
||||||
|
if let Some(ref engine) = self.engine {
|
||||||
|
for event in engine.poll_events() {
|
||||||
|
match event {
|
||||||
|
EngineEvent::TransportPosition(pos) => {
|
||||||
|
self.current_position = pos;
|
||||||
|
}
|
||||||
|
EngineEvent::Error(e) => {
|
||||||
|
eprintln!("engine error: {}", e);
|
||||||
|
}
|
||||||
|
EngineEvent::BusCreated(_) => {}
|
||||||
|
EngineEvent::GraphRebuilt => {}
|
||||||
|
EngineEvent::ModuleLoaded { bus_name, module_id, module_type } => {
|
||||||
|
self.module_names.insert(module_id, module_type);
|
||||||
|
for track in &mut self.tracks {
|
||||||
|
if track.bus_name == bus_name {
|
||||||
|
if !track.module_chain.contains(&module_id) {
|
||||||
|
track.module_chain.push(module_id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll visualization messages
|
||||||
|
for (_module_id, msg) in engine.poll_gui_messages() {
|
||||||
|
if let ToGuiMessage::VisualizationData { data } = msg {
|
||||||
|
if let Some(frame) = VisualizationFrame::deserialize(&data) {
|
||||||
|
self.latest_viz_frame = Some(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view(&self) -> Element<'_, Message> {
|
||||||
|
let selected_track_ref = self.selected_track.and_then(|i| self.tracks.get(i));
|
||||||
|
|
||||||
|
// Control bar
|
||||||
|
let control_bar = control_bar::view(
|
||||||
|
&self.transport,
|
||||||
|
&self.current_position,
|
||||||
|
self.tempo,
|
||||||
|
self.time_signature_numerator,
|
||||||
|
self.time_signature_denominator,
|
||||||
|
self.cycle_enabled,
|
||||||
|
self.metronome_enabled,
|
||||||
|
self.count_in_enabled,
|
||||||
|
self.show_inspector,
|
||||||
|
self.show_bottom_panel,
|
||||||
|
&self.bottom_panel_mode,
|
||||||
|
&self.icons,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toolbar
|
||||||
|
let toolbar = toolbar::view(&self.active_tool, &self.icons);
|
||||||
|
|
||||||
|
// Track headers
|
||||||
|
let effective_track_height = TRACK_HEIGHT * self.v_zoom;
|
||||||
|
let track_headers: Element<_> = container(
|
||||||
|
scrollable(
|
||||||
|
self.tracks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.fold(Column::new().spacing(0), |col, (i, track)| {
|
||||||
|
col.push(
|
||||||
|
track_header::view(track, &self.icons, effective_track_height)
|
||||||
|
.map(move |msg| Message::TrackHeader(i, msg)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.id(self.track_list_scrollable_id.clone())
|
||||||
|
.on_scroll(Message::TrackListScrolled),
|
||||||
|
)
|
||||||
|
.width(self.header_width)
|
||||||
|
.into();
|
||||||
|
|
||||||
|
// Timeline
|
||||||
|
let timeline_el: Element<_> = scrollable(
|
||||||
|
timeline::view(
|
||||||
|
&self.project_config,
|
||||||
|
&self.tracks,
|
||||||
|
self.current_position,
|
||||||
|
self.active_tool,
|
||||||
|
self.h_zoom,
|
||||||
|
self.v_zoom,
|
||||||
|
)
|
||||||
|
.map(Message::Timeline),
|
||||||
|
)
|
||||||
|
.id(self.timeline_scrollable_id.clone())
|
||||||
|
.on_scroll(Message::TimelineScrolled)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into();
|
||||||
|
|
||||||
|
// Arrangement area (headers + timeline)
|
||||||
|
let arrangement = row![track_headers, timeline_el].align_y(Alignment::Start);
|
||||||
|
|
||||||
|
// Build middle section with optional inspector
|
||||||
|
let middle: Element<_> = if self.show_inspector {
|
||||||
|
row![
|
||||||
|
inspector::view(
|
||||||
|
selected_track_ref,
|
||||||
|
&self.project_config,
|
||||||
|
&self.module_names,
|
||||||
|
self.selected_track,
|
||||||
|
),
|
||||||
|
arrangement
|
||||||
|
]
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
container(arrangement).height(Length::Fill).into()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bottom panel
|
||||||
|
let bottom: Element<_> = if self.show_bottom_panel {
|
||||||
|
let panel_content: Element<_> = match self.bottom_panel_mode {
|
||||||
|
BottomPanelMode::Editor => editor_pane::view(selected_track_ref),
|
||||||
|
BottomPanelMode::Mixer => mixer::view(&self.tracks),
|
||||||
|
BottomPanelMode::Visualizer => {
|
||||||
|
visualizer::spiral::view(self.latest_viz_frame.as_ref(), self.viz_rotation)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
container(panel_content)
|
||||||
|
.height(self.bottom_panel_height)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x1A, 0x1C, 0x1E))),
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
Space::new(0, 0).into()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add track button row
|
||||||
|
let add_track_row: Element<_> = container(
|
||||||
|
row![button("+ Track").on_press(Message::ShowNewTrackWizard)]
|
||||||
|
.spacing(10)
|
||||||
|
.padding(2),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x1E, 0x20, 0x22))),
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let menu = menu_bar::view(&self.menu_state);
|
||||||
|
|
||||||
|
let base_view: Element<_> =
|
||||||
|
column![menu, control_bar, toolbar, add_track_row, middle, bottom]
|
||||||
|
.spacing(0)
|
||||||
|
.into();
|
||||||
|
|
||||||
|
// Menu dropdown overlay
|
||||||
|
let main_view: Element<_> = if let Some(dropdown) = menu_bar::dropdown_view(&self.menu_state)
|
||||||
|
{
|
||||||
|
mouse_area(stack![base_view, dropdown])
|
||||||
|
.on_press(Message::MenuBar(menu_bar::Message::Close))
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
base_view
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal overlay
|
||||||
|
if let Some(modal_state) = &self.modal_state {
|
||||||
|
let modal_content = match modal_state {
|
||||||
|
ModalState::NewTrackWizard(state) => {
|
||||||
|
new_track_wizard::view(state).map(Message::NewTrackWizard)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let background = container(main_view).style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.4))),
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let modal = container(modal_content)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_x(alignment::Horizontal::Center)
|
||||||
|
.align_y(alignment::Vertical::Center);
|
||||||
|
|
||||||
|
mouse_area(stack![background, modal])
|
||||||
|
.on_press(Message::CloseModal)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
main_view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_engine(&self) -> bool {
|
||||||
|
self.engine.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
use std::any::Any;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct BusId(pub u32);
|
||||||
|
|
||||||
|
pub struct AudioBusData {
|
||||||
|
pub samples: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioBusData {
|
||||||
|
pub fn new(buffer_size: usize, channels: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
samples: vec![0.0; buffer_size * channels],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
for s in &mut self.samples {
|
||||||
|
*s = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Bus {
|
||||||
|
pub id: BusId,
|
||||||
|
pub name: String,
|
||||||
|
pub data: Arc<RwLock<dyn Any + Send + Sync>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BusRegistry {
|
||||||
|
buses: HashMap<BusId, Bus>,
|
||||||
|
name_to_id: HashMap<String, BusId>,
|
||||||
|
next_id: u32,
|
||||||
|
buffer_size: usize,
|
||||||
|
channels: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BusRegistry {
|
||||||
|
pub fn new(buffer_size: usize, channels: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
buses: HashMap::new(),
|
||||||
|
name_to_id: HashMap::new(),
|
||||||
|
next_id: 0,
|
||||||
|
buffer_size,
|
||||||
|
channels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn channels(&self) -> usize {
|
||||||
|
self.channels
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_audio_bus(&mut self, name: &str) -> BusId {
|
||||||
|
if let Some(&id) = self.name_to_id.get(name) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = BusId(self.next_id);
|
||||||
|
self.next_id += 1;
|
||||||
|
|
||||||
|
let data = AudioBusData::new(self.buffer_size, self.channels);
|
||||||
|
let bus = Bus {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
data: Arc::new(RwLock::new(data)),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.name_to_id.insert(name.to_string(), id);
|
||||||
|
self.buses.insert(id, bus);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_bus(&mut self, name: &str) -> bool {
|
||||||
|
if let Some(id) = self.name_to_id.remove(name) {
|
||||||
|
self.buses.remove(&id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &BusId) -> Option<&Bus> {
|
||||||
|
self.buses.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_by_name(&self, name: &str) -> Option<&Bus> {
|
||||||
|
self.name_to_id.get(name).and_then(|id| self.buses.get(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id_for_name(&self, name: &str) -> Option<BusId> {
|
||||||
|
self.name_to_id.get(name).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_all_audio(&self) {
|
||||||
|
for bus in self.buses.values() {
|
||||||
|
if let Ok(mut data) = bus.data.write() {
|
||||||
|
if let Some(audio) = data.downcast_mut::<AudioBusData>() {
|
||||||
|
audio.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bus_names(&self) -> Vec<String> {
|
||||||
|
self.name_to_id.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
|
||||||
|
use oxforge::mdk::{GlobalConfig, ToGuiMessage};
|
||||||
|
|
||||||
|
use super::bus::{AudioBusData, BusRegistry};
|
||||||
|
use super::graph::ProcessGraph;
|
||||||
|
use super::host::ModuleHost;
|
||||||
|
use super::param::ParamEngine;
|
||||||
|
use super::schedule::{CycleContext, CycleSchedule};
|
||||||
|
use super::{EngineCommand, EngineEvent, TransportState};
|
||||||
|
use crate::timing::{MusicalTime, TICKS_PER_BEAT};
|
||||||
|
|
||||||
|
pub struct CycleProcessor {
|
||||||
|
pub bus_registry: BusRegistry,
|
||||||
|
pub module_host: ModuleHost,
|
||||||
|
pub graph: ProcessGraph,
|
||||||
|
pub schedule: CycleSchedule,
|
||||||
|
pub param_engine: ParamEngine,
|
||||||
|
pub transport: TransportState,
|
||||||
|
pub tempo: f32,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub sample_pos: u64,
|
||||||
|
pub time_signature_numerator: u8,
|
||||||
|
pub time_signature_denominator: u8,
|
||||||
|
cmd_rx: Receiver<EngineCommand>,
|
||||||
|
evt_tx: Sender<EngineEvent>,
|
||||||
|
gui_tx: Sender<(u32, ToGuiMessage)>,
|
||||||
|
needs_rebuild: bool,
|
||||||
|
armed_buses: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CycleProcessor {
|
||||||
|
pub fn new(
|
||||||
|
sample_rate: u32,
|
||||||
|
buffer_size: u32,
|
||||||
|
cmd_rx: Receiver<EngineCommand>,
|
||||||
|
evt_tx: Sender<EngineEvent>,
|
||||||
|
gui_tx: Sender<(u32, ToGuiMessage)>,
|
||||||
|
) -> Self {
|
||||||
|
let mut bus_registry = BusRegistry::new(buffer_size as usize, 2);
|
||||||
|
bus_registry.create_audio_bus("hw_input");
|
||||||
|
bus_registry.create_audio_bus("hw_output");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
bus_registry,
|
||||||
|
module_host: ModuleHost::new(),
|
||||||
|
graph: ProcessGraph::new(),
|
||||||
|
schedule: CycleSchedule::new(),
|
||||||
|
param_engine: ParamEngine::new(),
|
||||||
|
transport: TransportState::Stopped,
|
||||||
|
tempo: 120.0,
|
||||||
|
sample_rate,
|
||||||
|
sample_pos: 0,
|
||||||
|
time_signature_numerator: 4,
|
||||||
|
time_signature_denominator: 4,
|
||||||
|
cmd_rx,
|
||||||
|
evt_tx,
|
||||||
|
gui_tx,
|
||||||
|
needs_rebuild: false,
|
||||||
|
armed_buses: HashSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_cycle(&mut self, hw_input: &[f32], hw_output: &mut [f32]) {
|
||||||
|
self.drain_commands();
|
||||||
|
self.param_engine.apply_pending(&mut self.module_host);
|
||||||
|
|
||||||
|
if self.needs_rebuild {
|
||||||
|
self.rebuild_schedule();
|
||||||
|
self.needs_rebuild = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.bus_registry.clear_all_audio();
|
||||||
|
|
||||||
|
if let Some(bus) = self.bus_registry.get_by_name("hw_input") {
|
||||||
|
if let Ok(mut data) = bus.data.write() {
|
||||||
|
if let Some(audio) = data.downcast_mut::<AudioBusData>() {
|
||||||
|
let len = hw_input.len().min(audio.samples.len());
|
||||||
|
audio.samples[..len].copy_from_slice(&hw_input[..len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route hw_input into each armed track bus
|
||||||
|
for bus_name in &self.armed_buses {
|
||||||
|
if let Some(bus) = self.bus_registry.get_by_name(bus_name) {
|
||||||
|
if let Ok(mut data) = bus.data.write() {
|
||||||
|
if let Some(audio) = data.downcast_mut::<AudioBusData>() {
|
||||||
|
let len = hw_input.len().min(audio.samples.len());
|
||||||
|
audio.samples[..len].copy_from_slice(&hw_input[..len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx = CycleContext {
|
||||||
|
sample_pos: self.sample_pos,
|
||||||
|
tempo: self.tempo,
|
||||||
|
sample_rate: self.sample_rate,
|
||||||
|
transport: self.transport,
|
||||||
|
time_sig_num: self.time_signature_numerator,
|
||||||
|
time_sig_den: self.time_signature_denominator,
|
||||||
|
};
|
||||||
|
self.schedule.execute(&self.bus_registry, &mut self.module_host, &ctx);
|
||||||
|
|
||||||
|
// Mix armed track buses into hw_output for monitoring
|
||||||
|
for bus_name in &self.armed_buses {
|
||||||
|
if let Some(track_bus) = self.bus_registry.get_by_name(bus_name) {
|
||||||
|
if let Ok(track_data) = track_bus.data.read() {
|
||||||
|
if let Some(track_audio) = track_data.downcast_ref::<AudioBusData>() {
|
||||||
|
if let Some(out_bus) = self.bus_registry.get_by_name("hw_output") {
|
||||||
|
if let Ok(mut out_data) = out_bus.data.write() {
|
||||||
|
if let Some(out_audio) = out_data.downcast_mut::<AudioBusData>() {
|
||||||
|
let len = track_audio.samples.len().min(out_audio.samples.len());
|
||||||
|
for i in 0..len {
|
||||||
|
out_audio.samples[i] += track_audio.samples[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(bus) = self.bus_registry.get_by_name("hw_output") {
|
||||||
|
if let Ok(data) = bus.data.read() {
|
||||||
|
if let Some(audio) = data.downcast_ref::<AudioBusData>() {
|
||||||
|
let len = hw_output.len().min(audio.samples.len());
|
||||||
|
hw_output[..len].copy_from_slice(&audio.samples[..len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.transport == TransportState::Playing {
|
||||||
|
let channels = self.bus_registry.channels();
|
||||||
|
self.sample_pos += (hw_output.len() / channels) as u64;
|
||||||
|
let _ = self.evt_tx.send(EngineEvent::TransportPosition(self.current_musical_time()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_musical_time(&self) -> MusicalTime {
|
||||||
|
let beats_per_second = self.tempo as f64 / 60.0;
|
||||||
|
let samples_per_beat = self.sample_rate as f64 / beats_per_second;
|
||||||
|
let total_beats = self.sample_pos as f64 / samples_per_beat;
|
||||||
|
let total_ticks = (total_beats * TICKS_PER_BEAT as f64) as u64;
|
||||||
|
|
||||||
|
let beats_per_bar = self.time_signature_numerator as u64;
|
||||||
|
let ticks_per_bar = beats_per_bar * TICKS_PER_BEAT as u64;
|
||||||
|
|
||||||
|
let bar = (total_ticks / ticks_per_bar) as u32 + 1;
|
||||||
|
let remaining = total_ticks % ticks_per_bar;
|
||||||
|
let beat = (remaining / TICKS_PER_BEAT as u64) as u32 + 1;
|
||||||
|
let tick = (remaining % TICKS_PER_BEAT as u64) as u32;
|
||||||
|
|
||||||
|
MusicalTime::new(bar, beat, tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_commands(&mut self) {
|
||||||
|
while let Ok(cmd) = self.cmd_rx.try_recv() {
|
||||||
|
match cmd {
|
||||||
|
EngineCommand::Shutdown => {}
|
||||||
|
EngineCommand::SetTransportState(state) => {
|
||||||
|
self.transport = state;
|
||||||
|
if state == TransportState::Stopped {
|
||||||
|
self.sample_pos = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EngineCommand::SetTempo(t) => {
|
||||||
|
self.tempo = t;
|
||||||
|
}
|
||||||
|
EngineCommand::CreateBus { name } => {
|
||||||
|
self.bus_registry.create_audio_bus(&name);
|
||||||
|
let _ = self.evt_tx.send(EngineEvent::BusCreated(name));
|
||||||
|
}
|
||||||
|
EngineCommand::RemoveBus { name } => {
|
||||||
|
self.bus_registry.remove_bus(&name);
|
||||||
|
}
|
||||||
|
EngineCommand::AddModuleToBus { bus_name, module_id } => {
|
||||||
|
self.graph.add_module_to_bus(module_id, &bus_name);
|
||||||
|
self.needs_rebuild = true;
|
||||||
|
}
|
||||||
|
EngineCommand::RemoveModuleFromBus { bus_name, module_id } => {
|
||||||
|
self.graph.remove_module_from_bus(module_id, &bus_name);
|
||||||
|
self.needs_rebuild = true;
|
||||||
|
}
|
||||||
|
EngineCommand::SetParam { module_id, key, value } => {
|
||||||
|
self.param_engine.queue_change(module_id, key, value);
|
||||||
|
}
|
||||||
|
EngineCommand::RebuildGraph => {
|
||||||
|
self.needs_rebuild = true;
|
||||||
|
}
|
||||||
|
EngineCommand::ArmTrack { bus_name } => {
|
||||||
|
self.armed_buses.insert(bus_name);
|
||||||
|
}
|
||||||
|
EngineCommand::DisarmTrack { bus_name } => {
|
||||||
|
self.armed_buses.remove(&bus_name);
|
||||||
|
}
|
||||||
|
EngineCommand::LoadModuleOnBus { bus_name, module_type, chain_position } => {
|
||||||
|
let config = GlobalConfig {
|
||||||
|
instance_id: oxforge::mdk::uuid::Uuid::new_v4(),
|
||||||
|
sample_rate: self.sample_rate as f32,
|
||||||
|
buffer_size: 0, // derived from bus at runtime
|
||||||
|
};
|
||||||
|
if let Some(module_id) = crate::modules::registry::load_builtin(
|
||||||
|
&mut self.module_host, &module_type, &config,
|
||||||
|
) {
|
||||||
|
self.graph.add_module_to_bus(module_id, &bus_name);
|
||||||
|
self.graph.set_chain_position(module_id, &bus_name, chain_position);
|
||||||
|
self.needs_rebuild = true;
|
||||||
|
let _ = self.evt_tx.send(EngineEvent::ModuleLoaded {
|
||||||
|
bus_name,
|
||||||
|
module_id,
|
||||||
|
module_type,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let _ = self.evt_tx.send(EngineEvent::Error(
|
||||||
|
format!("unknown module type: {}", module_type),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EngineCommand::UnloadModule { module_id } => {
|
||||||
|
self.module_host.unload(module_id);
|
||||||
|
self.graph.remove_module(module_id);
|
||||||
|
self.needs_rebuild = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_schedule(&mut self) {
|
||||||
|
match self.graph.resolve_order() {
|
||||||
|
Ok(order) => {
|
||||||
|
let mut schedule = CycleSchedule::from_graph(&order, &self.graph, &self.bus_registry);
|
||||||
|
for routing in schedule.entries_mut() {
|
||||||
|
let module_id = routing.module_id;
|
||||||
|
let tx = self.gui_tx.clone();
|
||||||
|
routing.to_gui = oxforge::mdk::ToGuiQueue::with_callback(move |msg| {
|
||||||
|
let _ = tx.send((module_id, msg));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.schedule = schedule;
|
||||||
|
let _ = self.evt_tx.send(EngineEvent::GraphRebuilt);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = self.evt_tx.send(EngineEvent::Error(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ModuleNode {
|
||||||
|
pub module_id: u32,
|
||||||
|
pub reads: HashSet<String>,
|
||||||
|
pub writes: HashSet<String>,
|
||||||
|
pub chain_position: Option<(String, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProcessGraph {
|
||||||
|
nodes: HashMap<u32, ModuleNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcessGraph {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
nodes: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_module(&mut self, module_id: u32) {
|
||||||
|
self.nodes.entry(module_id).or_insert_with(|| ModuleNode {
|
||||||
|
module_id,
|
||||||
|
reads: HashSet::new(),
|
||||||
|
writes: HashSet::new(),
|
||||||
|
chain_position: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_module(&mut self, module_id: u32) {
|
||||||
|
self.nodes.remove(&module_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_module_to_bus(&mut self, module_id: u32, bus_name: &str) {
|
||||||
|
let node = self.nodes.entry(module_id).or_insert_with(|| ModuleNode {
|
||||||
|
module_id,
|
||||||
|
reads: HashSet::new(),
|
||||||
|
writes: HashSet::new(),
|
||||||
|
chain_position: None,
|
||||||
|
});
|
||||||
|
node.reads.insert(bus_name.to_string());
|
||||||
|
node.writes.insert(bus_name.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_chain_position(&mut self, module_id: u32, bus_name: &str, position: usize) {
|
||||||
|
if let Some(node) = self.nodes.get_mut(&module_id) {
|
||||||
|
node.chain_position = Some((bus_name.to_string(), position));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_module_reads(&mut self, module_id: u32, bus_name: &str) {
|
||||||
|
if let Some(node) = self.nodes.get_mut(&module_id) {
|
||||||
|
node.reads.insert(bus_name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_module_writes(&mut self, module_id: u32, bus_name: &str) {
|
||||||
|
if let Some(node) = self.nodes.get_mut(&module_id) {
|
||||||
|
node.writes.insert(bus_name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_module_from_bus(&mut self, module_id: u32, bus_name: &str) {
|
||||||
|
if let Some(node) = self.nodes.get_mut(&module_id) {
|
||||||
|
node.reads.remove(bus_name);
|
||||||
|
node.writes.remove(bus_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_node(&self, module_id: u32) -> Option<&ModuleNode> {
|
||||||
|
self.nodes.get(&module_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kahn's algorithm: topological sort based on bus read/write dependencies.
|
||||||
|
/// Writers must execute before readers within each cycle.
|
||||||
|
pub fn resolve_order(&self) -> Result<Vec<u32>, String> {
|
||||||
|
if self.nodes.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build adjacency: if module A writes to bus X and module B reads from bus X,
|
||||||
|
// then A must come before B (edge A -> B).
|
||||||
|
let ids: Vec<u32> = self.nodes.keys().copied().collect();
|
||||||
|
let mut in_degree: HashMap<u32, usize> = ids.iter().map(|&id| (id, 0)).collect();
|
||||||
|
let mut adj: HashMap<u32, Vec<u32>> = ids.iter().map(|&id| (id, Vec::new())).collect();
|
||||||
|
|
||||||
|
for &writer_id in &ids {
|
||||||
|
let writer = &self.nodes[&writer_id];
|
||||||
|
for &reader_id in &ids {
|
||||||
|
if writer_id == reader_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let reader = &self.nodes[&reader_id];
|
||||||
|
// If writer writes to a bus that reader reads (and reader doesn't also write it),
|
||||||
|
// writer must come first.
|
||||||
|
for bus in &writer.writes {
|
||||||
|
if reader.reads.contains(bus) && !reader.writes.contains(bus) {
|
||||||
|
adj.get_mut(&writer_id).unwrap().push(reader_id);
|
||||||
|
*in_degree.get_mut(&reader_id).unwrap() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut queue: VecDeque<u32> = in_degree
|
||||||
|
.iter()
|
||||||
|
.filter(|(_id, deg)| **deg == 0)
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Stable ordering: process lower IDs first
|
||||||
|
let mut queue_vec: Vec<u32> = queue.drain(..).collect();
|
||||||
|
queue_vec.sort();
|
||||||
|
queue.extend(queue_vec);
|
||||||
|
|
||||||
|
let mut order = Vec::with_capacity(ids.len());
|
||||||
|
|
||||||
|
while let Some(id) = queue.pop_front() {
|
||||||
|
order.push(id);
|
||||||
|
if let Some(neighbors) = adj.get(&id) {
|
||||||
|
for &next in neighbors {
|
||||||
|
let deg = in_degree.get_mut(&next).unwrap();
|
||||||
|
*deg -= 1;
|
||||||
|
if *deg == 0 {
|
||||||
|
queue.push_back(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.len() != ids.len() {
|
||||||
|
return Err("cycle detected in module graph".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable-sort modules on the same bus by chain_position
|
||||||
|
let nodes = &self.nodes;
|
||||||
|
order.sort_by(|&a, &b| {
|
||||||
|
let a_pos = nodes.get(&a).and_then(|n| n.chain_position.as_ref());
|
||||||
|
let b_pos = nodes.get(&b).and_then(|n| n.chain_position.as_ref());
|
||||||
|
match (a_pos, b_pos) {
|
||||||
|
(Some((bus_a, pos_a)), Some((bus_b, pos_b))) if bus_a == bus_b => {
|
||||||
|
pos_a.cmp(pos_b)
|
||||||
|
}
|
||||||
|
_ => std::cmp::Ordering::Equal,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
use oxforge::mdk::{GlobalConfig, OxideModule, Ports, ProcessContext};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct LoadedModule {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
instance: Box<dyn OxideModule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoadedModule {
|
||||||
|
pub fn process(&mut self, ports: Ports, context: &ProcessContext) {
|
||||||
|
self.instance.process(ports, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ModuleHost {
|
||||||
|
modules: HashMap<u32, LoadedModule>,
|
||||||
|
next_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModuleHost {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
modules: HashMap::new(),
|
||||||
|
next_id: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_builtin<M: OxideModule + 'static>(&mut self, name: &str, config: &GlobalConfig) -> u32 {
|
||||||
|
let id = self.next_id;
|
||||||
|
self.next_id += 1;
|
||||||
|
|
||||||
|
let instance = M::new(config);
|
||||||
|
self.modules.insert(id, LoadedModule {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
instance: Box::new(instance),
|
||||||
|
});
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unload(&mut self, module_id: u32) -> bool {
|
||||||
|
self.modules.remove(&module_id).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mut(&mut self, module_id: u32) -> Option<&mut LoadedModule> {
|
||||||
|
self.modules.get_mut(&module_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, module_id: u32) -> Option<&LoadedModule> {
|
||||||
|
self.modules.get(&module_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn module_ids(&self) -> Vec<u32> {
|
||||||
|
self.modules.keys().copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_param(&mut self, module_id: u32, _key: &str, _value: f32) -> bool {
|
||||||
|
self.modules.contains_key(&module_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use ringbuf::HeapRb;
|
||||||
|
use ringbuf::traits::{Consumer, Producer, Split};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use oxforge::mdk::ToGuiMessage;
|
||||||
|
|
||||||
|
use super::cycle::CycleProcessor;
|
||||||
|
use super::{EngineCommand, EngineEvent};
|
||||||
|
|
||||||
|
pub fn enumerate_devices() -> Vec<String> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let mut names = vec!["Default".to_string()];
|
||||||
|
if let Ok(devices) = host.output_devices() {
|
||||||
|
names.extend(devices.filter_map(|d| d.name().ok()));
|
||||||
|
}
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enumerate_input_devices() -> Vec<String> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let mut names = vec!["Default".to_string()];
|
||||||
|
if let Ok(devices) = host.input_devices() {
|
||||||
|
names.extend(devices.filter_map(|d| d.name().ok()));
|
||||||
|
}
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_input_device(host: &cpal::Host, name: &str) -> Option<cpal::Device> {
|
||||||
|
if name == "Default" || name.is_empty() {
|
||||||
|
host.default_input_device()
|
||||||
|
} else {
|
||||||
|
host.input_devices()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut devs| devs.find(|d| d.name().ok().as_deref() == Some(name)))
|
||||||
|
.or_else(|| host.default_input_device())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_audio(
|
||||||
|
sample_rate: u32,
|
||||||
|
buffer_size: u32,
|
||||||
|
device_name: &str,
|
||||||
|
input_device_name: &str,
|
||||||
|
cmd_rx: Receiver<EngineCommand>,
|
||||||
|
evt_tx: Sender<EngineEvent>,
|
||||||
|
gui_tx: Sender<(u32, ToGuiMessage)>,
|
||||||
|
) {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
|
||||||
|
let device = if device_name == "Default" || device_name.is_empty() {
|
||||||
|
host.default_output_device()
|
||||||
|
} else {
|
||||||
|
host.output_devices()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut devs| devs.find(|d| d.name().ok().as_deref() == Some(device_name)))
|
||||||
|
};
|
||||||
|
|
||||||
|
let device = match device {
|
||||||
|
Some(d) => d,
|
||||||
|
None => {
|
||||||
|
let _ = evt_tx.send(EngineEvent::Error(
|
||||||
|
format!("audio device '{}' not found, falling back to default", device_name),
|
||||||
|
));
|
||||||
|
match host.default_output_device() {
|
||||||
|
Some(d) => d,
|
||||||
|
None => {
|
||||||
|
let _ = evt_tx.send(EngineEvent::Error("no audio output device available".into()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = cpal::StreamConfig {
|
||||||
|
channels: 2,
|
||||||
|
sample_rate: cpal::SampleRate(sample_rate),
|
||||||
|
buffer_size: cpal::BufferSize::Fixed(buffer_size),
|
||||||
|
};
|
||||||
|
|
||||||
|
let processor = Arc::new(Mutex::new(CycleProcessor::new(
|
||||||
|
sample_rate,
|
||||||
|
buffer_size,
|
||||||
|
cmd_rx.clone(),
|
||||||
|
evt_tx.clone(),
|
||||||
|
gui_tx,
|
||||||
|
)));
|
||||||
|
|
||||||
|
// Input capture via ringbuf
|
||||||
|
let ring_size = buffer_size as usize * 2 * 4; // stereo * 4 buffers headroom
|
||||||
|
let rb = HeapRb::<f32>::new(ring_size);
|
||||||
|
let (mut producer, mut consumer) = rb.split();
|
||||||
|
|
||||||
|
let _input_stream = if let Some(input_dev) = resolve_input_device(&host, input_device_name) {
|
||||||
|
let input_config = cpal::StreamConfig {
|
||||||
|
channels: 2,
|
||||||
|
sample_rate: cpal::SampleRate(sample_rate),
|
||||||
|
buffer_size: cpal::BufferSize::Fixed(buffer_size),
|
||||||
|
};
|
||||||
|
|
||||||
|
let input_err_tx = evt_tx.clone();
|
||||||
|
|
||||||
|
match input_dev.build_input_stream(
|
||||||
|
&input_config,
|
||||||
|
move |data: &[f32], _info: &cpal::InputCallbackInfo| {
|
||||||
|
let _ = producer.push_slice(data);
|
||||||
|
},
|
||||||
|
move |err| {
|
||||||
|
let _ = input_err_tx.send(EngineEvent::Error(format!("cpal input error: {}", err)));
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
) {
|
||||||
|
Ok(stream) => {
|
||||||
|
if let Err(e) = stream.play() {
|
||||||
|
let _ = evt_tx.send(EngineEvent::Error(format!("failed to start input: {}", e)));
|
||||||
|
}
|
||||||
|
Some(stream)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = evt_tx.send(EngineEvent::Error(format!("failed to build input stream: {}", e)));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let proc_clone = Arc::clone(&processor);
|
||||||
|
let err_tx = evt_tx.clone();
|
||||||
|
|
||||||
|
let mut input_buf = vec![0.0f32; buffer_size as usize * 2];
|
||||||
|
|
||||||
|
let stream = device.build_output_stream(
|
||||||
|
&config,
|
||||||
|
move |output: &mut [f32], _info: &cpal::OutputCallbackInfo| {
|
||||||
|
for s in input_buf.iter_mut() {
|
||||||
|
*s = 0.0;
|
||||||
|
}
|
||||||
|
consumer.pop_slice(&mut input_buf);
|
||||||
|
|
||||||
|
if let Ok(mut proc) = proc_clone.lock() {
|
||||||
|
proc.process_cycle(&input_buf, output);
|
||||||
|
} else {
|
||||||
|
for sample in output.iter_mut() {
|
||||||
|
*sample = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |err| {
|
||||||
|
let _ = err_tx.send(EngineEvent::Error(format!("cpal stream error: {}", err)));
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
match stream {
|
||||||
|
Ok(stream) => {
|
||||||
|
if let Err(e) = stream.play() {
|
||||||
|
let _ = evt_tx.send(EngineEvent::Error(format!("failed to start stream: {}", e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match cmd_rx.recv() {
|
||||||
|
Ok(EngineCommand::Shutdown) | Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(stream);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = evt_tx.send(EngineEvent::Error(format!("failed to build stream: {}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
pub mod bus;
|
||||||
|
pub mod cycle;
|
||||||
|
pub mod graph;
|
||||||
|
pub mod host;
|
||||||
|
pub mod io;
|
||||||
|
pub mod param;
|
||||||
|
pub mod schedule;
|
||||||
|
|
||||||
|
use crossbeam_channel::{Receiver, Sender, unbounded};
|
||||||
|
use oxforge::mdk::ToGuiMessage;
|
||||||
|
use crate::timing::MusicalTime;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum EngineCommand {
|
||||||
|
Shutdown,
|
||||||
|
SetTransportState(TransportState),
|
||||||
|
SetTempo(f32),
|
||||||
|
CreateBus { name: String },
|
||||||
|
RemoveBus { name: String },
|
||||||
|
AddModuleToBus { bus_name: String, module_id: u32 },
|
||||||
|
RemoveModuleFromBus { bus_name: String, module_id: u32 },
|
||||||
|
SetParam { module_id: u32, key: String, value: f32 },
|
||||||
|
RebuildGraph,
|
||||||
|
ArmTrack { bus_name: String },
|
||||||
|
DisarmTrack { bus_name: String },
|
||||||
|
LoadModuleOnBus { bus_name: String, module_type: String, chain_position: usize },
|
||||||
|
UnloadModule { module_id: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum EngineEvent {
|
||||||
|
TransportPosition(MusicalTime),
|
||||||
|
Error(String),
|
||||||
|
BusCreated(String),
|
||||||
|
GraphRebuilt,
|
||||||
|
ModuleLoaded { bus_name: String, module_id: u32, module_type: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TransportState {
|
||||||
|
Playing,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EngineHandle {
|
||||||
|
cmd_tx: Sender<EngineCommand>,
|
||||||
|
evt_rx: Receiver<EngineEvent>,
|
||||||
|
gui_rx: Receiver<(u32, ToGuiMessage)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EngineHandle {
|
||||||
|
pub fn spawn(sample_rate: u32, buffer_size: u32, device_name: &str, input_device_name: &str) -> Self {
|
||||||
|
let (cmd_tx, cmd_rx) = unbounded();
|
||||||
|
let (evt_tx, evt_rx) = unbounded();
|
||||||
|
let (gui_tx, gui_rx) = unbounded::<(u32, ToGuiMessage)>();
|
||||||
|
|
||||||
|
let device = device_name.to_string();
|
||||||
|
let input_device = input_device_name.to_string();
|
||||||
|
let sr = sample_rate;
|
||||||
|
let bs = buffer_size;
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("audio-engine".into())
|
||||||
|
.spawn(move || {
|
||||||
|
io::run_audio(sr, bs, &device, &input_device, cmd_rx, evt_tx, gui_tx);
|
||||||
|
})
|
||||||
|
.expect("failed to spawn engine thread");
|
||||||
|
|
||||||
|
Self { cmd_tx, evt_rx, gui_rx }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send(&self, cmd: EngineCommand) {
|
||||||
|
let _ = self.cmd_tx.send(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn poll_events(&self) -> Vec<EngineEvent> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
while let Ok(evt) = self.evt_rx.try_recv() {
|
||||||
|
events.push(evt);
|
||||||
|
}
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn poll_gui_messages(&self) -> Vec<(u32, ToGuiMessage)> {
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
while let Ok(msg) = self.gui_rx.try_recv() {
|
||||||
|
messages.push(msg);
|
||||||
|
}
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EngineHandle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.cmd_tx.send(EngineCommand::Shutdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::host::ModuleHost;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ParamChange {
|
||||||
|
pub module_id: u32,
|
||||||
|
pub key: String,
|
||||||
|
pub value: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DataContract {
|
||||||
|
pub provider_module: u32,
|
||||||
|
pub consumer_module: u32,
|
||||||
|
pub bus_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ParamEngine {
|
||||||
|
pending: Vec<ParamChange>,
|
||||||
|
params: HashMap<u32, HashMap<String, f32>>,
|
||||||
|
contracts: Vec<DataContract>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParamEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
pending: Vec::new(),
|
||||||
|
params: HashMap::new(),
|
||||||
|
contracts: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn queue_change(&mut self, module_id: u32, key: String, value: f32) {
|
||||||
|
self.pending.push(ParamChange { module_id, key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_pending(&mut self, host: &mut ModuleHost) {
|
||||||
|
for change in self.pending.drain(..) {
|
||||||
|
self.params
|
||||||
|
.entry(change.module_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(change.key.clone(), change.value);
|
||||||
|
host.set_param(change.module_id, &change.key, change.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_params(&self, module_id: u32) -> Option<&HashMap<String, f32>> {
|
||||||
|
self.params.get(&module_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_contract(&mut self, contract: DataContract) {
|
||||||
|
self.contracts.push(contract);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contracts(&self) -> &[DataContract] {
|
||||||
|
&self.contracts
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_contracts(&mut self) {
|
||||||
|
// Future: validate all contracts still have valid providers/consumers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
use std::any::Any;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use oxforge::mdk::{
|
||||||
|
Buses, ChainInput, ChainOutput, MainAudioInput, MainAudioOutput,
|
||||||
|
MusicalTime as MdkMusicalTime, Ports, ProcessContext, ToGuiQueue,
|
||||||
|
TransportState as MdkTransportState,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::bus::{AudioBusData, BusRegistry};
|
||||||
|
use super::graph::ProcessGraph;
|
||||||
|
use super::host::ModuleHost;
|
||||||
|
use super::TransportState;
|
||||||
|
|
||||||
|
pub struct CycleContext {
|
||||||
|
pub sample_pos: u64,
|
||||||
|
pub tempo: f32,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub transport: TransportState,
|
||||||
|
pub time_sig_num: u8,
|
||||||
|
pub time_sig_den: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ModuleRouting {
|
||||||
|
pub module_id: u32,
|
||||||
|
pub input_bus: Option<String>,
|
||||||
|
pub output_bus: Option<String>,
|
||||||
|
pub bus_name: Option<String>,
|
||||||
|
pub scratch_in: Vec<f32>,
|
||||||
|
pub scratch_out: Vec<f32>,
|
||||||
|
pub to_gui: ToGuiQueue,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CycleSchedule {
|
||||||
|
entries: Vec<ModuleRouting>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CycleSchedule {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries_mut(&mut self) -> &mut Vec<ModuleRouting> {
|
||||||
|
&mut self.entries
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_graph(order: &[u32], graph: &ProcessGraph, bus_registry: &BusRegistry) -> Self {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
for &module_id in order {
|
||||||
|
if let Some(node) = graph.get_node(module_id) {
|
||||||
|
let input_bus = node.reads.iter().next().cloned();
|
||||||
|
let output_bus = node.writes.iter().next().cloned();
|
||||||
|
|
||||||
|
let buf_size = if let Some(ref name) = input_bus {
|
||||||
|
if let Some(bus) = bus_registry.get_by_name(name) {
|
||||||
|
if let Ok(data) = bus.data.read() {
|
||||||
|
data.downcast_ref::<AudioBusData>()
|
||||||
|
.map(|a| a.samples.len())
|
||||||
|
.unwrap_or(512)
|
||||||
|
} else {
|
||||||
|
512
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
512
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
512
|
||||||
|
};
|
||||||
|
|
||||||
|
let bus_name = node.chain_position.as_ref().map(|(b, _)| b.clone())
|
||||||
|
.or_else(|| input_bus.clone());
|
||||||
|
|
||||||
|
entries.push(ModuleRouting {
|
||||||
|
module_id,
|
||||||
|
input_bus,
|
||||||
|
output_bus,
|
||||||
|
bus_name,
|
||||||
|
scratch_in: vec![0.0; buf_size],
|
||||||
|
scratch_out: vec![0.0; buf_size],
|
||||||
|
to_gui: ToGuiQueue::noop(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { entries }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute(
|
||||||
|
&mut self,
|
||||||
|
bus_registry: &BusRegistry,
|
||||||
|
module_host: &mut ModuleHost,
|
||||||
|
ctx: &CycleContext,
|
||||||
|
) {
|
||||||
|
let mdk_transport = match ctx.transport {
|
||||||
|
TransportState::Playing => MdkTransportState::Playing,
|
||||||
|
TransportState::Stopped => MdkTransportState::Stopped,
|
||||||
|
};
|
||||||
|
|
||||||
|
let beats_per_second = ctx.tempo as f64 / 60.0;
|
||||||
|
let beat_pos = ctx.sample_pos as f64 * beats_per_second / ctx.sample_rate as f64;
|
||||||
|
|
||||||
|
let mdk_time = MdkMusicalTime {
|
||||||
|
sample_pos: ctx.sample_pos,
|
||||||
|
beat_pos,
|
||||||
|
tempo: ctx.tempo as f64,
|
||||||
|
time_signature_numerator: ctx.time_sig_num,
|
||||||
|
time_signature_denominator: ctx.time_sig_den,
|
||||||
|
state: mdk_transport,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut prev_chain_data: Option<Box<dyn Any + Send>> = None;
|
||||||
|
let mut prev_bus: Option<String> = None;
|
||||||
|
|
||||||
|
for routing in &mut self.entries {
|
||||||
|
if let Some(ref bus_name) = routing.input_bus {
|
||||||
|
if let Some(bus) = bus_registry.get_by_name(bus_name) {
|
||||||
|
if let Ok(data) = bus.data.read() {
|
||||||
|
if let Some(audio) = data.downcast_ref::<AudioBusData>() {
|
||||||
|
let len = routing.scratch_in.len().min(audio.samples.len());
|
||||||
|
routing.scratch_in[..len].copy_from_slice(&audio.samples[..len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for s in &mut routing.scratch_out {
|
||||||
|
*s = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire chain_in from previous module on same bus
|
||||||
|
let same_bus = routing.bus_name.is_some()
|
||||||
|
&& routing.bus_name == prev_bus
|
||||||
|
&& prev_chain_data.is_some();
|
||||||
|
|
||||||
|
let mut chain_out_box: Box<dyn Any + Send> = Box::new(());
|
||||||
|
|
||||||
|
let chain_in = if same_bus {
|
||||||
|
prev_chain_data.as_deref().map(|data| ChainInput { data })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let chain_out = Some(ChainOutput { data: &mut chain_out_box });
|
||||||
|
|
||||||
|
let ports = Ports {
|
||||||
|
main_audio_in: Some(MainAudioInput {
|
||||||
|
buffer: &routing.scratch_in,
|
||||||
|
}),
|
||||||
|
main_audio_out: Some(MainAudioOutput {
|
||||||
|
buffer: &mut routing.scratch_out,
|
||||||
|
}),
|
||||||
|
chain_in,
|
||||||
|
chain_out,
|
||||||
|
};
|
||||||
|
|
||||||
|
let context = ProcessContext {
|
||||||
|
time: mdk_time,
|
||||||
|
params: HashMap::new(),
|
||||||
|
to_gui: routing.to_gui.clone(),
|
||||||
|
buses: Buses::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(module) = module_host.get_mut(routing.module_id) {
|
||||||
|
module.process(ports, &context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stash chain output if module wrote something (not unit type)
|
||||||
|
if !chain_out_box.is::<()>() {
|
||||||
|
prev_chain_data = Some(chain_out_box);
|
||||||
|
} else if routing.bus_name != prev_bus {
|
||||||
|
prev_chain_data = None;
|
||||||
|
}
|
||||||
|
prev_bus = routing.bus_name.clone();
|
||||||
|
|
||||||
|
if let Some(ref bus_name) = routing.output_bus {
|
||||||
|
if let Some(bus) = bus_registry.get_by_name(bus_name) {
|
||||||
|
if let Ok(mut data) = bus.data.write() {
|
||||||
|
if let Some(audio) = data.downcast_mut::<AudioBusData>() {
|
||||||
|
let len = routing.scratch_out.len().min(audio.samples.len());
|
||||||
|
audio.samples[..len].copy_from_slice(&routing.scratch_out[..len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,704 @@
|
||||||
|
use crate::config::{AudioOxideConfig, ProjectConfig};
|
||||||
|
use crate::first_run::{load_or_initialize_config, save_config};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use iced::widget::{container, mouse_area, stack, text};
|
||||||
|
use iced::{
|
||||||
|
alignment, application, event,
|
||||||
|
keyboard::{self, key, Key},
|
||||||
|
theme, time, Color, Element, Length, Subscription, Task, Theme,
|
||||||
|
};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use crate::{behaviors, editor, gui, triggers};
|
||||||
|
|
||||||
|
pub fn main() -> iced::Result {
|
||||||
|
application("Audio Oxide", update, view)
|
||||||
|
.subscription(subscription)
|
||||||
|
.theme(theme)
|
||||||
|
.run_with(AudioOxide::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme(_app: &AudioOxide) -> Theme {
|
||||||
|
let mut palette = theme::Palette::OXOCARBON;
|
||||||
|
palette.primary = Color::from_rgb8(0x00, 0x7A, 0xFF);
|
||||||
|
palette.background = Color::from_rgb8(0x20, 0x22, 0x24);
|
||||||
|
Theme::custom("Audio Oxide Custom".to_string(), palette)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AudioOxide {
|
||||||
|
config: AudioOxideConfig,
|
||||||
|
state: AppState,
|
||||||
|
settings_state: Option<gui::settings::State>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioOxide {
|
||||||
|
fn new() -> (Self, Task<Message>) {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let mut project_path: Option<PathBuf> = None;
|
||||||
|
|
||||||
|
if let Some(project_arg_index) = args.iter().position(|a| a == "--project") {
|
||||||
|
if let Some(path_str) = args.get(project_arg_index + 1) {
|
||||||
|
project_path = Some(PathBuf::from(path_str));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = load_or_initialize_config();
|
||||||
|
|
||||||
|
if let Some(path) = project_path {
|
||||||
|
let (editor_state, editor_task) = editor::Editor::new(path);
|
||||||
|
let app = Self {
|
||||||
|
config,
|
||||||
|
state: AppState::Editor(Box::new(editor_state)),
|
||||||
|
settings_state: None,
|
||||||
|
};
|
||||||
|
return (app, editor_task.map(Message::Editor));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.first_run {
|
||||||
|
let app = Self {
|
||||||
|
config,
|
||||||
|
state: AppState::FirstRunWizard,
|
||||||
|
settings_state: None,
|
||||||
|
};
|
||||||
|
(app, Task::none())
|
||||||
|
} else {
|
||||||
|
let app = Self {
|
||||||
|
config,
|
||||||
|
state: AppState::Loading,
|
||||||
|
settings_state: None,
|
||||||
|
};
|
||||||
|
let initial_task = Task::perform(
|
||||||
|
load_projects(app.config.project_dir.clone()),
|
||||||
|
Message::ProjectsLoaded,
|
||||||
|
);
|
||||||
|
(app, initial_task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum AppState {
|
||||||
|
FirstRunWizard,
|
||||||
|
Loading,
|
||||||
|
ProjectManager(ProjectManagerState),
|
||||||
|
NewProjectWizard(gui::new_project::State),
|
||||||
|
TimeUtility {
|
||||||
|
wizard_state: Box<gui::new_project::State>,
|
||||||
|
tapper_state: gui::time_utility::State,
|
||||||
|
},
|
||||||
|
Editor(Box<editor::Editor>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProjectManagerState {
|
||||||
|
view: ProjectViewState,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ProjectViewState {
|
||||||
|
Recent { projects: Vec<ProjectInfo> },
|
||||||
|
Find { path_input: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProjectInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub modified: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
EventOccurred(iced::Event),
|
||||||
|
FirstRunProjectDirChanged(String),
|
||||||
|
FirstRunComplete,
|
||||||
|
ViewRecentProjects,
|
||||||
|
ViewFindProject,
|
||||||
|
ViewNewProject,
|
||||||
|
OpenProject(PathBuf),
|
||||||
|
FindPathChanged(String),
|
||||||
|
ProjectsLoaded(Result<Vec<ProjectInfo>, String>),
|
||||||
|
ProjectNameChanged(String),
|
||||||
|
SampleRateSelected(u32),
|
||||||
|
BufferSizeSelected(u32),
|
||||||
|
AudioDeviceSelected(String),
|
||||||
|
TimeSignatureNumeratorChanged(String),
|
||||||
|
TimeSignatureDenominatorChanged(String),
|
||||||
|
TempoChanged(f32),
|
||||||
|
CreateProject,
|
||||||
|
ViewTimeUtility,
|
||||||
|
TimeUtilityTapPressed,
|
||||||
|
TimeUtilityTapReleased,
|
||||||
|
RunTimeUtilityAnalysis,
|
||||||
|
TimeUtilitySet(u32),
|
||||||
|
TimeUtilitySetTimeSignature(String),
|
||||||
|
TimeUtilitySetBoth(u32, String),
|
||||||
|
TimeUtilityCancel,
|
||||||
|
Editor(editor::Message),
|
||||||
|
Settings(gui::settings::Message),
|
||||||
|
OpenSettings,
|
||||||
|
CloseSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(app: &mut AudioOxide, message: Message) -> Task<Message> {
|
||||||
|
// Settings overlay messages
|
||||||
|
match &message {
|
||||||
|
Message::OpenSettings => {
|
||||||
|
app.settings_state = Some(gui::settings::State::new(&app.config));
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
Message::CloseSettings => {
|
||||||
|
app.settings_state = None;
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
Message::Settings(settings_msg) => {
|
||||||
|
if let Some(ref mut state) = app.settings_state {
|
||||||
|
let is_save = matches!(settings_msg, gui::settings::Message::Save);
|
||||||
|
let done = gui::settings::handle_message(state, settings_msg.clone());
|
||||||
|
if done {
|
||||||
|
if is_save {
|
||||||
|
app.config = state.config.clone();
|
||||||
|
save_config(&app.config);
|
||||||
|
}
|
||||||
|
app.settings_state = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If settings overlay is open, block other input except engine tick
|
||||||
|
if app.settings_state.is_some() {
|
||||||
|
if let Message::Editor(editor::Message::EngineTick) = &message {
|
||||||
|
// Allow engine tick through
|
||||||
|
} else if let Message::EventOccurred(event::Event::Keyboard(
|
||||||
|
keyboard::Event::KeyPressed {
|
||||||
|
key: Key::Named(key::Named::Escape),
|
||||||
|
..
|
||||||
|
},
|
||||||
|
)) = &message
|
||||||
|
{
|
||||||
|
app.settings_state = None;
|
||||||
|
return Task::none();
|
||||||
|
} else {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match message {
|
||||||
|
Message::EventOccurred(event) => {
|
||||||
|
if let event::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||||
|
key: Key::Named(key::Named::Escape),
|
||||||
|
..
|
||||||
|
}) = event
|
||||||
|
{
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::EscapePressed)
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let event::Event::Keyboard(keyboard_event) = event {
|
||||||
|
let action = match keyboard_event {
|
||||||
|
keyboard::Event::KeyPressed {
|
||||||
|
key, modifiers, ..
|
||||||
|
} => triggers::map_key_press_to_action(&app.state, key, modifiers),
|
||||||
|
keyboard::Event::KeyReleased {
|
||||||
|
key, modifiers, ..
|
||||||
|
} => triggers::map_key_release_to_action(&app.state, key, modifiers),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(action) = action {
|
||||||
|
return dispatch_action(app, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
Message::Editor(editor_message) => match editor_message {
|
||||||
|
editor::Message::MenuAction(action) => {
|
||||||
|
return dispatch_action(app, action);
|
||||||
|
}
|
||||||
|
msg => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state.update(msg).map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Message::ViewTimeUtility => {
|
||||||
|
if let AppState::NewProjectWizard(wizard_state) =
|
||||||
|
std::mem::replace(&mut app.state, AppState::Loading)
|
||||||
|
{
|
||||||
|
app.state = AppState::TimeUtility {
|
||||||
|
wizard_state: Box::new(wizard_state),
|
||||||
|
tapper_state: gui::time_utility::State::default(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::TimeUtilityTapPressed => {
|
||||||
|
if let AppState::TimeUtility { tapper_state, .. } = &mut app.state {
|
||||||
|
gui::time_utility::handle_tap_pressed(tapper_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::TimeUtilityTapReleased => {
|
||||||
|
if let AppState::TimeUtility { tapper_state, .. } = &mut app.state {
|
||||||
|
return gui::time_utility::handle_tap_released(tapper_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::RunTimeUtilityAnalysis => {
|
||||||
|
if let AppState::TimeUtility { tapper_state, .. } = &mut app.state {
|
||||||
|
if tapper_state.tap_events.len() >= 3 {
|
||||||
|
tapper_state.result =
|
||||||
|
gui::time_utility::run_analysis(&tapper_state.tap_events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::TimeUtilitySet(bpm) => {
|
||||||
|
if let AppState::TimeUtility { wizard_state, .. } =
|
||||||
|
std::mem::replace(&mut app.state, AppState::Loading)
|
||||||
|
{
|
||||||
|
let mut state = *wizard_state;
|
||||||
|
state.config.tempo = bpm as f32;
|
||||||
|
app.state = AppState::NewProjectWizard(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::TimeUtilitySetTimeSignature(sig) => {
|
||||||
|
if let AppState::TimeUtility { wizard_state, .. } =
|
||||||
|
std::mem::replace(&mut app.state, AppState::Loading)
|
||||||
|
{
|
||||||
|
let mut state = *wizard_state;
|
||||||
|
let parts: Vec<&str> = sig.split('/').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
if let (Ok(num), Ok(den)) = (parts[0].parse::<u8>(), parts[1].parse::<u8>())
|
||||||
|
{
|
||||||
|
state.config.time_signature_numerator = num;
|
||||||
|
state.config.time_signature_denominator = den;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.state = AppState::NewProjectWizard(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::TimeUtilitySetBoth(bpm, sig) => {
|
||||||
|
if let AppState::TimeUtility { wizard_state, .. } =
|
||||||
|
std::mem::replace(&mut app.state, AppState::Loading)
|
||||||
|
{
|
||||||
|
let mut state = *wizard_state;
|
||||||
|
state.config.tempo = bpm as f32;
|
||||||
|
let parts: Vec<&str> = sig.split('/').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
if let (Ok(num), Ok(den)) = (parts[0].parse::<u8>(), parts[1].parse::<u8>())
|
||||||
|
{
|
||||||
|
state.config.time_signature_numerator = num;
|
||||||
|
state.config.time_signature_denominator = den;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.state = AppState::NewProjectWizard(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::TimeUtilityCancel => {
|
||||||
|
if let AppState::TimeUtility { wizard_state, .. } =
|
||||||
|
std::mem::replace(&mut app.state, AppState::Loading)
|
||||||
|
{
|
||||||
|
app.state = AppState::NewProjectWizard(*wizard_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::FirstRunProjectDirChanged(new_path) => {
|
||||||
|
if let AppState::FirstRunWizard = &mut app.state {
|
||||||
|
app.config.project_dir = PathBuf::from(new_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::FirstRunComplete => {
|
||||||
|
if let AppState::FirstRunWizard = &app.state {
|
||||||
|
fs::create_dir_all(&app.config.project_dir)
|
||||||
|
.expect("Could not create project directory");
|
||||||
|
app.config.first_run = false;
|
||||||
|
save_config(&app.config);
|
||||||
|
app.state = AppState::ProjectManager(ProjectManagerState {
|
||||||
|
view: ProjectViewState::Recent { projects: vec![] },
|
||||||
|
});
|
||||||
|
return Task::perform(
|
||||||
|
load_projects(app.config.project_dir.clone()),
|
||||||
|
Message::ProjectsLoaded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::ViewRecentProjects => {
|
||||||
|
app.state = AppState::ProjectManager(ProjectManagerState {
|
||||||
|
view: ProjectViewState::Recent { projects: vec![] },
|
||||||
|
});
|
||||||
|
return Task::perform(
|
||||||
|
load_projects(app.config.project_dir.clone()),
|
||||||
|
Message::ProjectsLoaded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Message::ViewFindProject => {
|
||||||
|
if let AppState::ProjectManager(pm) = &mut app.state {
|
||||||
|
pm.view = ProjectViewState::Find {
|
||||||
|
path_input: String::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::ViewNewProject => {
|
||||||
|
app.state = AppState::NewProjectWizard(gui::new_project::State::default());
|
||||||
|
}
|
||||||
|
Message::ProjectsLoaded(Ok(projects)) => {
|
||||||
|
app.state = AppState::ProjectManager(ProjectManagerState {
|
||||||
|
view: ProjectViewState::Recent { projects },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Message::ProjectsLoaded(Err(e)) => {
|
||||||
|
eprintln!("Error loading projects: {}", e);
|
||||||
|
}
|
||||||
|
Message::OpenProject(path) => {
|
||||||
|
let (editor_state, editor_task) = editor::Editor::new(path);
|
||||||
|
app.state = AppState::Editor(Box::new(editor_state));
|
||||||
|
return editor_task.map(Message::Editor);
|
||||||
|
}
|
||||||
|
Message::FindPathChanged(new_path) => {
|
||||||
|
if let AppState::ProjectManager(pm) = &mut app.state {
|
||||||
|
if let ProjectViewState::Find { path_input } = &mut pm.view {
|
||||||
|
*path_input = new_path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::CreateProject => {
|
||||||
|
if let AppState::NewProjectWizard(wizard_state) = &app.state {
|
||||||
|
if let Some(path) = create_project_files(&app.config, &wizard_state.config) {
|
||||||
|
return update(app, Message::OpenProject(path));
|
||||||
|
}
|
||||||
|
app.state = AppState::ProjectManager(ProjectManagerState {
|
||||||
|
view: ProjectViewState::Recent { projects: vec![] },
|
||||||
|
});
|
||||||
|
return Task::perform(
|
||||||
|
load_projects(app.config.project_dir.clone()),
|
||||||
|
Message::ProjectsLoaded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::ProjectNameChanged(name) => {
|
||||||
|
if let AppState::NewProjectWizard(s) = &mut app.state {
|
||||||
|
s.config.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::SampleRateSelected(sr) => {
|
||||||
|
if let AppState::NewProjectWizard(s) = &mut app.state {
|
||||||
|
s.config.sample_rate = sr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::BufferSizeSelected(bs) => {
|
||||||
|
if let AppState::NewProjectWizard(s) = &mut app.state {
|
||||||
|
s.config.buffer_size = bs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::AudioDeviceSelected(dev) => {
|
||||||
|
if let AppState::NewProjectWizard(s) = &mut app.state {
|
||||||
|
s.config.audio_device = dev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::TimeSignatureNumeratorChanged(num_str) => {
|
||||||
|
if let AppState::NewProjectWizard(s) = &mut app.state {
|
||||||
|
if let Ok(num) = num_str.parse::<u8>() {
|
||||||
|
s.config.time_signature_numerator = num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::TimeSignatureDenominatorChanged(den_str) => {
|
||||||
|
if let AppState::NewProjectWizard(s) = &mut app.state {
|
||||||
|
if let Ok(den) = den_str.parse::<u8>() {
|
||||||
|
if den > 0 {
|
||||||
|
s.config.time_signature_denominator = den;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::TempoChanged(tempo) => {
|
||||||
|
if let AppState::NewProjectWizard(s) = &mut app.state {
|
||||||
|
s.config.tempo = tempo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_action(app: &mut AudioOxide, action: behaviors::Action) -> Task<Message> {
|
||||||
|
use behaviors::Action::*;
|
||||||
|
match action {
|
||||||
|
// Time utility
|
||||||
|
TimeUtilityTapPressed => {
|
||||||
|
if let AppState::TimeUtility { tapper_state, .. } = &mut app.state {
|
||||||
|
gui::time_utility::handle_tap_pressed(tapper_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TimeUtilityTapReleased => {
|
||||||
|
if let AppState::TimeUtility { tapper_state, .. } = &mut app.state {
|
||||||
|
return gui::time_utility::handle_tap_released(tapper_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global shortcuts
|
||||||
|
OpenSettings => {
|
||||||
|
app.settings_state = Some(gui::settings::State::new(&app.config));
|
||||||
|
}
|
||||||
|
NewProject => {
|
||||||
|
app.state = AppState::NewProjectWizard(gui::new_project::State::default());
|
||||||
|
}
|
||||||
|
OpenProject => {
|
||||||
|
app.state = AppState::ProjectManager(ProjectManagerState {
|
||||||
|
view: ProjectViewState::Recent { projects: vec![] },
|
||||||
|
});
|
||||||
|
return Task::perform(
|
||||||
|
load_projects(app.config.project_dir.clone()),
|
||||||
|
Message::ProjectsLoaded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File menu (editor context)
|
||||||
|
SaveProject | SaveProjectAs => {
|
||||||
|
// TODO: project serialization
|
||||||
|
}
|
||||||
|
CloseProject => {
|
||||||
|
app.state = AppState::ProjectManager(ProjectManagerState {
|
||||||
|
view: ProjectViewState::Recent { projects: vec![] },
|
||||||
|
});
|
||||||
|
return Task::perform(
|
||||||
|
load_projects(app.config.project_dir.clone()),
|
||||||
|
Message::ProjectsLoaded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit menu (editor context)
|
||||||
|
Undo | Redo | Cut | Copy | Paste | Duplicate | SelectAll | Delete => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::EditAction(action))
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editor transport
|
||||||
|
EditorTogglePlayback => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::PlayPressed)
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditorStop => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::StopPressed)
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditorToggleRecord => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::RecordPressed)
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditorPlayFromBeginning => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::StopPressed)
|
||||||
|
.map(Message::Editor)
|
||||||
|
.chain(Task::done(Message::Editor(editor::Message::PlayPressed)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditorRewind => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::RewindPressed)
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editor view toggles
|
||||||
|
EditorToggleInspector => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::ToggleInspector)
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditorToggleBottomPanel => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::ToggleBottomPanel)
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditorToggleMixer => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::SetBottomPanelMode(
|
||||||
|
editor::BottomPanelMode::Mixer,
|
||||||
|
))
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditorToggleToolbar => {
|
||||||
|
// TODO: toolbar visibility toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editor mode toggles
|
||||||
|
EditorToggleCycle => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::CycleToggled)
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditorToggleMetronome => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::MetronomeToggled)
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ZoomInH => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::ZoomH(1.2))
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ZoomOutH => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::ZoomH(1.0 / 1.2))
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ZoomInV => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::ZoomV(1.2))
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ZoomOutV => {
|
||||||
|
if let AppState::Editor(editor_state) = &mut app.state {
|
||||||
|
return editor_state
|
||||||
|
.update(editor::Message::ZoomV(1.0 / 1.2))
|
||||||
|
.map(Message::Editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view<'a>(app: &'a AudioOxide) -> Element<'a, Message> {
|
||||||
|
let content: Element<'a, Message> = match &app.state {
|
||||||
|
AppState::FirstRunWizard => gui::first_run_wizard::view(&app.config.project_dir),
|
||||||
|
AppState::Loading => text("Loading projects...").into(),
|
||||||
|
AppState::ProjectManager(s) => gui::project_viewer::view(&s.view),
|
||||||
|
AppState::NewProjectWizard(s) => gui::new_project::view(s),
|
||||||
|
AppState::TimeUtility { tapper_state, .. } => gui::time_utility::view(tapper_state),
|
||||||
|
AppState::Editor(editor_state) => editor_state.view().map(Message::Editor),
|
||||||
|
};
|
||||||
|
|
||||||
|
let main = container(content)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill);
|
||||||
|
|
||||||
|
if let Some(ref settings_state) = app.settings_state {
|
||||||
|
let bg = container(main)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(iced::Background::Color(Color::from_rgba(
|
||||||
|
0.0, 0.0, 0.0, 0.5,
|
||||||
|
))),
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let settings_dialog = container(
|
||||||
|
gui::settings::view(settings_state).map(Message::Settings),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_x(alignment::Horizontal::Center)
|
||||||
|
.align_y(alignment::Vertical::Center);
|
||||||
|
|
||||||
|
mouse_area(stack![bg, settings_dialog])
|
||||||
|
.on_press(Message::CloseSettings)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
main.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscription(app: &AudioOxide) -> Subscription<Message> {
|
||||||
|
let keyboard = event::listen().map(Message::EventOccurred);
|
||||||
|
|
||||||
|
if let AppState::Editor(editor_state) = &app.state {
|
||||||
|
if editor_state.has_engine() {
|
||||||
|
let tick = time::every(std::time::Duration::from_millis(16))
|
||||||
|
.map(|_| Message::Editor(editor::Message::EngineTick));
|
||||||
|
return Subscription::batch([keyboard, tick]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboard
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_project_files(
|
||||||
|
config: &AudioOxideConfig,
|
||||||
|
project_config: &ProjectConfig,
|
||||||
|
) -> Option<PathBuf> {
|
||||||
|
let project_dir = config
|
||||||
|
.project_dir
|
||||||
|
.join(format!("{}.xtc", project_config.name));
|
||||||
|
if project_dir.exists() {
|
||||||
|
eprintln!("Error: Project directory already exists.");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Err(e) = fs::create_dir_all(&project_dir) {
|
||||||
|
eprintln!("Failed to create project directory: {}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let toml_path = project_dir.join("project.toml");
|
||||||
|
match toml::to_string_pretty(project_config) {
|
||||||
|
Ok(toml_content) => {
|
||||||
|
if let Err(e) = fs::write(&toml_path, toml_content) {
|
||||||
|
eprintln!("Failed to write project.toml: {}", e);
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(project_dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to serialize project config: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_projects(project_dir: PathBuf) -> Result<Vec<ProjectInfo>, String> {
|
||||||
|
let mut projects = Vec::new();
|
||||||
|
let entries = fs::read_dir(project_dir).map_err(|e| e.to_string())?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| e.to_string())?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() && path.extension().and_then(|s| s.to_str()) == Some("xtc") {
|
||||||
|
let metadata = entry.metadata().map_err(|e| e.to_string())?;
|
||||||
|
let modified_sys: SystemTime = metadata.modified().map_err(|e| e.to_string())?;
|
||||||
|
let name = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("Untitled")
|
||||||
|
.to_string();
|
||||||
|
projects.push(ProjectInfo {
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
modified: modified_sys.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projects.sort_by(|a, b| b.modified.cmp(&a.modified));
|
||||||
|
Ok(projects)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
use crate::config::AudioOxideConfig;
|
||||||
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
|
/// Returns the path to the config file: ~/.oxide-audio/config.toml
|
||||||
|
fn get_config_path() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.expect("Could not find the home directory")
|
||||||
|
.join(".oxide-audio/config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the config from disk, or creates a default one if it doesn't exist.
|
||||||
|
pub fn load_or_initialize_config() -> AudioOxideConfig {
|
||||||
|
let config_path = get_config_path();
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
println!("Config file not found. Creating default at: {:?}", config_path);
|
||||||
|
let config = AudioOxideConfig::default();
|
||||||
|
|
||||||
|
// Ensure the parent directory ~/.oxide-audio/ exists
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("Could not create config directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the new default config file
|
||||||
|
let toml_string = toml::to_string_pretty(&config).expect("Could not serialize config to TOML");
|
||||||
|
fs::write(&config_path, toml_string).expect("Could not write new config file");
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the file exists, load and parse it.
|
||||||
|
let toml_string = fs::read_to_string(&config_path).expect("Could not read config file");
|
||||||
|
toml::from_str(&toml_string).expect("Could not parse config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the provided configuration state to the disk.
|
||||||
|
pub fn save_config(config: &AudioOxideConfig) {
|
||||||
|
let config_path = get_config_path();
|
||||||
|
let toml_string = toml::to_string_pretty(config).expect("Could not serialize config to TOML");
|
||||||
|
fs::write(&config_path, toml_string).expect("Could not write updated config file");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
use crate::editor::{BottomPanelMode, Message};
|
||||||
|
use crate::engine::TransportState;
|
||||||
|
use crate::gui::icon_button::{button_group, IconButton};
|
||||||
|
use crate::gui::icons::{Icon, IconSet};
|
||||||
|
use crate::timing::MusicalTime;
|
||||||
|
use iced::widget::{container, row, text, Space};
|
||||||
|
use iced::{Alignment, Background, Color, Element, Length, Theme};
|
||||||
|
|
||||||
|
pub fn view<'a>(
|
||||||
|
transport: &TransportState,
|
||||||
|
position: &MusicalTime,
|
||||||
|
tempo: f32,
|
||||||
|
ts_num: u8,
|
||||||
|
ts_den: u8,
|
||||||
|
cycle_enabled: bool,
|
||||||
|
metronome_enabled: bool,
|
||||||
|
count_in_enabled: bool,
|
||||||
|
show_inspector: bool,
|
||||||
|
show_bottom_panel: bool,
|
||||||
|
bottom_panel_mode: &BottomPanelMode,
|
||||||
|
icons: &'a IconSet,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let is_playing = *transport == TransportState::Playing;
|
||||||
|
|
||||||
|
// LCD position display
|
||||||
|
let pos_text = format!(
|
||||||
|
"{:03}.{}.{:03}",
|
||||||
|
position.bar, position.beat, position.tick
|
||||||
|
);
|
||||||
|
let lcd = container(text(pos_text).size(28).font(iced::Font::MONOSPACE))
|
||||||
|
.padding([4, 12])
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x10, 0x12, 0x14))),
|
||||||
|
border: iced::Border {
|
||||||
|
radius: 3.0.into(),
|
||||||
|
color: Color::from_rgb8(0x30, 0x32, 0x34),
|
||||||
|
width: 1.0,
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transport buttons
|
||||||
|
let (rw_u, rw_f) = icons.get(Icon::Rewind);
|
||||||
|
let (stop_u, stop_f) = icons.get(Icon::Stop);
|
||||||
|
let (play_u, play_f) = icons.get(Icon::Play);
|
||||||
|
let (rec_u, rec_f) = icons.get(Icon::Record);
|
||||||
|
|
||||||
|
let transport_controls = button_group(vec![
|
||||||
|
IconButton::new(rw_u, rw_f, Message::RewindPressed)
|
||||||
|
.size(32.0)
|
||||||
|
.hint("Rewind")
|
||||||
|
.into(),
|
||||||
|
IconButton::new(stop_u, stop_f, Message::StopPressed)
|
||||||
|
.size(32.0)
|
||||||
|
.hint("Stop")
|
||||||
|
.into(),
|
||||||
|
IconButton::new(play_u, play_f, Message::PlayPressed)
|
||||||
|
.size(32.0)
|
||||||
|
.toggled(is_playing)
|
||||||
|
.hint("Play")
|
||||||
|
.into(),
|
||||||
|
IconButton::new(rec_u, rec_f, Message::RecordPressed)
|
||||||
|
.size(32.0)
|
||||||
|
.active_tint(Color::from_rgb8(0xCC, 0x33, 0x33))
|
||||||
|
.hint("Record")
|
||||||
|
.into(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Tempo + time sig
|
||||||
|
let tempo_display = text(format!("{:.1} BPM", tempo)).size(16);
|
||||||
|
let time_sig_display = text(format!("{}/{}", ts_num, ts_den)).size(16);
|
||||||
|
|
||||||
|
// Mode toggles
|
||||||
|
let (cyc_u, cyc_f) = icons.get(Icon::Cycle);
|
||||||
|
let (met_u, met_f) = icons.get(Icon::Metronome);
|
||||||
|
let (cnt_u, cnt_f) = icons.get(Icon::CountIn);
|
||||||
|
|
||||||
|
let mode_toggles = button_group(vec![
|
||||||
|
IconButton::new(cyc_u, cyc_f, Message::CycleToggled)
|
||||||
|
.size(32.0)
|
||||||
|
.toggled(cycle_enabled)
|
||||||
|
.active_tint(Color::from_rgb8(0xFF, 0xA5, 0x00))
|
||||||
|
.hint("Cycle")
|
||||||
|
.into(),
|
||||||
|
IconButton::new(met_u, met_f, Message::MetronomeToggled)
|
||||||
|
.size(32.0)
|
||||||
|
.toggled(metronome_enabled)
|
||||||
|
.hint("Metronome")
|
||||||
|
.into(),
|
||||||
|
IconButton::new(cnt_u, cnt_f, Message::CountInToggled)
|
||||||
|
.size(32.0)
|
||||||
|
.toggled(count_in_enabled)
|
||||||
|
.hint("Count In")
|
||||||
|
.into(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// View toggles
|
||||||
|
let (insp_u, insp_f) = icons.get(Icon::ViewInspector);
|
||||||
|
let (edit_u, edit_f) = icons.get(Icon::ViewEditor);
|
||||||
|
let (mix_u, mix_f) = icons.get(Icon::ViewMixer);
|
||||||
|
|
||||||
|
let view_toggles = button_group(vec![
|
||||||
|
IconButton::new(insp_u, insp_f, Message::ToggleInspector)
|
||||||
|
.size(32.0)
|
||||||
|
.toggled(show_inspector)
|
||||||
|
.active_tint(Color::from_rgb8(0x55, 0x55, 0x55))
|
||||||
|
.hint("Inspector")
|
||||||
|
.into(),
|
||||||
|
IconButton::new(
|
||||||
|
edit_u,
|
||||||
|
edit_f,
|
||||||
|
Message::SetBottomPanelMode(BottomPanelMode::Editor),
|
||||||
|
)
|
||||||
|
.size(32.0)
|
||||||
|
.toggled(show_bottom_panel && *bottom_panel_mode == BottomPanelMode::Editor)
|
||||||
|
.active_tint(Color::from_rgb8(0x55, 0x55, 0x55))
|
||||||
|
.hint("Editor")
|
||||||
|
.into(),
|
||||||
|
IconButton::new(
|
||||||
|
mix_u,
|
||||||
|
mix_f,
|
||||||
|
Message::SetBottomPanelMode(BottomPanelMode::Mixer),
|
||||||
|
)
|
||||||
|
.size(32.0)
|
||||||
|
.toggled(show_bottom_panel && *bottom_panel_mode == BottomPanelMode::Mixer)
|
||||||
|
.active_tint(Color::from_rgb8(0x55, 0x55, 0x55))
|
||||||
|
.hint("Mixer")
|
||||||
|
.into(),
|
||||||
|
IconButton::new(
|
||||||
|
mix_u,
|
||||||
|
mix_f,
|
||||||
|
Message::SetBottomPanelMode(BottomPanelMode::Visualizer),
|
||||||
|
)
|
||||||
|
.size(32.0)
|
||||||
|
.toggled(show_bottom_panel && *bottom_panel_mode == BottomPanelMode::Visualizer)
|
||||||
|
.active_tint(Color::from_rgb8(0x44, 0x77, 0xAA))
|
||||||
|
.hint("Visualizer")
|
||||||
|
.into(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let left = Space::new(Length::Fill, 0);
|
||||||
|
let right = Space::new(Length::Fill, 0);
|
||||||
|
|
||||||
|
let bar = row![
|
||||||
|
view_toggles,
|
||||||
|
left,
|
||||||
|
lcd,
|
||||||
|
transport_controls,
|
||||||
|
tempo_display,
|
||||||
|
time_sig_display,
|
||||||
|
mode_toggles,
|
||||||
|
right,
|
||||||
|
]
|
||||||
|
.spacing(12)
|
||||||
|
.padding([8, 14])
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
|
container(bar)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x1A, 0x1C, 0x1E))),
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
use crate::editor::Message;
|
||||||
|
use crate::track::Track;
|
||||||
|
use iced::widget::{column, container, horizontal_rule, text};
|
||||||
|
use iced::{Background, Color, Element, Length, Theme};
|
||||||
|
|
||||||
|
pub fn view<'a>(selected_track: Option<&'a Track>) -> Element<'a, Message> {
|
||||||
|
let header = text("Editor").size(14).color(Color::from_rgb8(0x88, 0x88, 0x88));
|
||||||
|
|
||||||
|
let content = if let Some(track) = selected_track {
|
||||||
|
let type_label = match track.track_type {
|
||||||
|
crate::track::TrackType::Audio => "Waveform Editor",
|
||||||
|
crate::track::TrackType::Midi => "Piano Roll",
|
||||||
|
};
|
||||||
|
column![
|
||||||
|
header,
|
||||||
|
horizontal_rule(1),
|
||||||
|
text(format!("{} - {}", track.name, type_label)).size(12),
|
||||||
|
text("(editor canvas placeholder)").size(10).color(Color::from_rgb8(0x55, 0x55, 0x55)),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.padding(8)
|
||||||
|
} else {
|
||||||
|
column![
|
||||||
|
header,
|
||||||
|
horizontal_rule(1),
|
||||||
|
text("Select a track to edit").size(12).color(Color::from_rgb8(0x55, 0x55, 0x55)),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.padding(8)
|
||||||
|
};
|
||||||
|
|
||||||
|
container(content)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x1A, 0x1C, 0x1E))),
|
||||||
|
border: iced::Border {
|
||||||
|
color: Color::from_rgb8(0x30, 0x32, 0x34),
|
||||||
|
width: 1.0,
|
||||||
|
..iced::Border::default()
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
use crate::config::ProjectConfig;
|
||||||
|
use crate::editor::Message;
|
||||||
|
use crate::modules::registry::BUILTIN_MODULES;
|
||||||
|
use crate::track::Track;
|
||||||
|
use iced::widget::{button, column, container, horizontal_rule, row, text, vertical_rule, Column};
|
||||||
|
use iced::{Background, Color, Element, Length, Theme};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub fn view<'a>(
|
||||||
|
selected_track: Option<&'a Track>,
|
||||||
|
project_config: &'a ProjectConfig,
|
||||||
|
module_names: &'a HashMap<u32, String>,
|
||||||
|
track_index: Option<usize>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let header = text("Inspector").size(14).color(Color::from_rgb8(0x88, 0x88, 0x88));
|
||||||
|
|
||||||
|
let content = if let Some(track) = selected_track {
|
||||||
|
let color_swatch = container(text("").width(12).height(12))
|
||||||
|
.style(move |_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(
|
||||||
|
track.color.r,
|
||||||
|
track.color.g,
|
||||||
|
track.color.b,
|
||||||
|
))),
|
||||||
|
border: iced::Border {
|
||||||
|
radius: 2.0.into(),
|
||||||
|
..iced::Border::default()
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Module chain display
|
||||||
|
let mut modules_col = Column::new().spacing(2);
|
||||||
|
for &module_id in &track.module_chain {
|
||||||
|
let name = module_names
|
||||||
|
.get(&module_id)
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("Unknown");
|
||||||
|
let track_idx = track_index.unwrap_or(0);
|
||||||
|
let remove_btn = button(text("×").size(10))
|
||||||
|
.on_press(Message::RemoveModuleFromTrack(track_idx, module_id))
|
||||||
|
.padding([1, 4])
|
||||||
|
.style(|_theme: &Theme, _status| button::Style {
|
||||||
|
background: Some(Background::Color(Color::TRANSPARENT)),
|
||||||
|
text_color: Color::from_rgb8(0xAA, 0x44, 0x44),
|
||||||
|
..button::Style::default()
|
||||||
|
});
|
||||||
|
modules_col = modules_col.push(
|
||||||
|
row![text(name).size(10), remove_btn]
|
||||||
|
.spacing(4)
|
||||||
|
.align_y(iced::Alignment::Center),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add module buttons
|
||||||
|
let mut add_col = Column::new().spacing(2);
|
||||||
|
if let Some(idx) = track_index {
|
||||||
|
for desc in BUILTIN_MODULES {
|
||||||
|
add_col = add_col.push(
|
||||||
|
button(text(desc.display_name).size(10))
|
||||||
|
.on_press(Message::AddModuleToTrack(idx, desc.type_name.to_string()))
|
||||||
|
.padding([2, 6])
|
||||||
|
.style(|_theme: &Theme, status| {
|
||||||
|
let bg = match status {
|
||||||
|
button::Status::Hovered => Color::from_rgb8(0x33, 0x55, 0x77),
|
||||||
|
_ => Color::from_rgb8(0x28, 0x2A, 0x2C),
|
||||||
|
};
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(bg)),
|
||||||
|
text_color: Color::from_rgb8(0xCC, 0xCC, 0xCC),
|
||||||
|
border: iced::Border {
|
||||||
|
radius: 3.0.into(),
|
||||||
|
..iced::Border::default()
|
||||||
|
},
|
||||||
|
..button::Style::default()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
column![
|
||||||
|
header,
|
||||||
|
horizontal_rule(1),
|
||||||
|
row![color_swatch, text(&track.name).size(14)].spacing(6),
|
||||||
|
text(format!("Type: {}", track.track_type)).size(12),
|
||||||
|
horizontal_rule(1),
|
||||||
|
text("Volume").size(10).color(Color::from_rgb8(0x77, 0x77, 0x77)),
|
||||||
|
text(format!("{:.0}%", track.volume * 100.0)).size(12),
|
||||||
|
text("Pan").size(10).color(Color::from_rgb8(0x77, 0x77, 0x77)),
|
||||||
|
text(format!("{:+.0}", track.pan * 100.0)).size(12),
|
||||||
|
horizontal_rule(1),
|
||||||
|
text("Modules").size(10).color(Color::from_rgb8(0x77, 0x77, 0x77)),
|
||||||
|
modules_col,
|
||||||
|
text("Add Module").size(10).color(Color::from_rgb8(0x77, 0x77, 0x77)),
|
||||||
|
add_col,
|
||||||
|
horizontal_rule(1),
|
||||||
|
text("Bus").size(10).color(Color::from_rgb8(0x77, 0x77, 0x77)),
|
||||||
|
text(&track.bus_name).size(10),
|
||||||
|
text(format!("Regions: {}", track.regions.len())).size(10),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.padding(8)
|
||||||
|
} else {
|
||||||
|
column![
|
||||||
|
header,
|
||||||
|
horizontal_rule(1),
|
||||||
|
text(&project_config.name).size(14),
|
||||||
|
text(format!("{}Hz / {} buf", project_config.sample_rate, project_config.buffer_size)).size(10),
|
||||||
|
text(format!("{:.1} BPM", project_config.tempo)).size(10),
|
||||||
|
text(format!("{}/{}", project_config.time_signature_numerator, project_config.time_signature_denominator)).size(10),
|
||||||
|
text(format!("Device: {}", project_config.audio_device)).size(10),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.padding(8)
|
||||||
|
};
|
||||||
|
|
||||||
|
row![
|
||||||
|
container(content)
|
||||||
|
.width(200)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x1E, 0x20, 0x22))),
|
||||||
|
..container::Style::default()
|
||||||
|
}),
|
||||||
|
vertical_rule(1),
|
||||||
|
]
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,395 @@
|
||||||
|
use crate::behaviors;
|
||||||
|
use crate::editor::Message as EditorMessage;
|
||||||
|
use iced::widget::{button, container, row, text, Column, Space};
|
||||||
|
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme};
|
||||||
|
|
||||||
|
pub const BAR_HEIGHT: f32 = 24.0;
|
||||||
|
const TITLE_WIDTH: f32 = 75.0;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MenuId {
|
||||||
|
File,
|
||||||
|
Edit,
|
||||||
|
Transport,
|
||||||
|
View,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MenuId {
|
||||||
|
const ALL: [MenuId; 4] = [MenuId::File, MenuId::Edit, MenuId::Transport, MenuId::View];
|
||||||
|
|
||||||
|
fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
MenuId::File => "File",
|
||||||
|
MenuId::Edit => "Edit",
|
||||||
|
MenuId::Transport => "Transport",
|
||||||
|
MenuId::View => "View",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct State {
|
||||||
|
pub open: Option<MenuId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { open: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
Open(MenuId),
|
||||||
|
Close,
|
||||||
|
Action(behaviors::Action),
|
||||||
|
ShowNewTrackWizard,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MenuItem {
|
||||||
|
label: &'static str,
|
||||||
|
shortcut: &'static str,
|
||||||
|
message: Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MenuEntry {
|
||||||
|
Item(MenuItem),
|
||||||
|
Separator,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_entries() -> Vec<MenuEntry> {
|
||||||
|
use behaviors::Action::*;
|
||||||
|
vec![
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "New Project",
|
||||||
|
shortcut: "\u{2318}N",
|
||||||
|
message: Message::Action(NewProject),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Open\u{2026}",
|
||||||
|
shortcut: "\u{2318}O",
|
||||||
|
message: Message::Action(OpenProject),
|
||||||
|
}),
|
||||||
|
MenuEntry::Separator,
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Save",
|
||||||
|
shortcut: "\u{2318}S",
|
||||||
|
message: Message::Action(SaveProject),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Save As\u{2026}",
|
||||||
|
shortcut: "\u{21E7}\u{2318}S",
|
||||||
|
message: Message::Action(SaveProjectAs),
|
||||||
|
}),
|
||||||
|
MenuEntry::Separator,
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Close",
|
||||||
|
shortcut: "\u{2318}W",
|
||||||
|
message: Message::Action(CloseProject),
|
||||||
|
}),
|
||||||
|
MenuEntry::Separator,
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Settings",
|
||||||
|
shortcut: "\u{2318},",
|
||||||
|
message: Message::Action(OpenSettings),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_entries() -> Vec<MenuEntry> {
|
||||||
|
use behaviors::Action::*;
|
||||||
|
vec![
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Undo",
|
||||||
|
shortcut: "\u{2318}Z",
|
||||||
|
message: Message::Action(Undo),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Redo",
|
||||||
|
shortcut: "\u{21E7}\u{2318}Z",
|
||||||
|
message: Message::Action(Redo),
|
||||||
|
}),
|
||||||
|
MenuEntry::Separator,
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Cut",
|
||||||
|
shortcut: "\u{2318}X",
|
||||||
|
message: Message::Action(Cut),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Copy",
|
||||||
|
shortcut: "\u{2318}C",
|
||||||
|
message: Message::Action(Copy),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Paste",
|
||||||
|
shortcut: "\u{2318}V",
|
||||||
|
message: Message::Action(Paste),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Duplicate",
|
||||||
|
shortcut: "\u{2318}D",
|
||||||
|
message: Message::Action(Duplicate),
|
||||||
|
}),
|
||||||
|
MenuEntry::Separator,
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Select All",
|
||||||
|
shortcut: "\u{2318}A",
|
||||||
|
message: Message::Action(SelectAll),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Delete",
|
||||||
|
shortcut: "\u{232B}",
|
||||||
|
message: Message::Action(Delete),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transport_entries() -> Vec<MenuEntry> {
|
||||||
|
use behaviors::Action::*;
|
||||||
|
vec![
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Play/Pause",
|
||||||
|
shortcut: "Space",
|
||||||
|
message: Message::Action(EditorTogglePlayback),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Stop",
|
||||||
|
shortcut: "",
|
||||||
|
message: Message::Action(EditorStop),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Record",
|
||||||
|
shortcut: "R",
|
||||||
|
message: Message::Action(EditorToggleRecord),
|
||||||
|
}),
|
||||||
|
MenuEntry::Separator,
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "From Start",
|
||||||
|
shortcut: "\u{21B5}",
|
||||||
|
message: Message::Action(EditorPlayFromBeginning),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Rewind",
|
||||||
|
shortcut: ",",
|
||||||
|
message: Message::Action(EditorRewind),
|
||||||
|
}),
|
||||||
|
MenuEntry::Separator,
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "New Track\u{2026}",
|
||||||
|
shortcut: "",
|
||||||
|
message: Message::ShowNewTrackWizard,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_entries() -> Vec<MenuEntry> {
|
||||||
|
use behaviors::Action::*;
|
||||||
|
vec![
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Inspector",
|
||||||
|
shortcut: "I",
|
||||||
|
message: Message::Action(EditorToggleInspector),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Bottom Panel",
|
||||||
|
shortcut: "E",
|
||||||
|
message: Message::Action(EditorToggleBottomPanel),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Mixer",
|
||||||
|
shortcut: "X",
|
||||||
|
message: Message::Action(EditorToggleMixer),
|
||||||
|
}),
|
||||||
|
MenuEntry::Separator,
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Cycle",
|
||||||
|
shortcut: "C",
|
||||||
|
message: Message::Action(EditorToggleCycle),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Metronome",
|
||||||
|
shortcut: "K",
|
||||||
|
message: Message::Action(EditorToggleMetronome),
|
||||||
|
}),
|
||||||
|
MenuEntry::Separator,
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Zoom In H",
|
||||||
|
shortcut: "\u{2318}\u{2192}",
|
||||||
|
message: Message::Action(ZoomInH),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Zoom Out H",
|
||||||
|
shortcut: "\u{2318}\u{2190}",
|
||||||
|
message: Message::Action(ZoomOutH),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Zoom In V",
|
||||||
|
shortcut: "\u{2318}\u{2191}",
|
||||||
|
message: Message::Action(ZoomInV),
|
||||||
|
}),
|
||||||
|
MenuEntry::Item(MenuItem {
|
||||||
|
label: "Zoom Out V",
|
||||||
|
shortcut: "\u{2318}\u{2193}",
|
||||||
|
message: Message::Action(ZoomOutV),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entries_for(id: MenuId) -> Vec<MenuEntry> {
|
||||||
|
match id {
|
||||||
|
MenuId::File => file_entries(),
|
||||||
|
MenuId::Edit => edit_entries(),
|
||||||
|
MenuId::Transport => transport_entries(),
|
||||||
|
MenuId::View => view_entries(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dropdown_x_offset(id: MenuId) -> f32 {
|
||||||
|
match id {
|
||||||
|
MenuId::File => 0.0,
|
||||||
|
MenuId::Edit => TITLE_WIDTH,
|
||||||
|
MenuId::Transport => TITLE_WIDTH * 2.0,
|
||||||
|
MenuId::View => TITLE_WIDTH * 3.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view(state: &State) -> Element<'_, EditorMessage> {
|
||||||
|
let titles: Vec<Element<'_, EditorMessage>> = MenuId::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|&id| {
|
||||||
|
let is_open = state.open == Some(id);
|
||||||
|
let label_color = if is_open {
|
||||||
|
Color::WHITE
|
||||||
|
} else {
|
||||||
|
Color::from_rgb8(0xAA, 0xAA, 0xAA)
|
||||||
|
};
|
||||||
|
|
||||||
|
button(text(id.label()).size(12).color(label_color))
|
||||||
|
.on_press(EditorMessage::MenuBar(Message::Open(id)))
|
||||||
|
.padding([4, 12])
|
||||||
|
.width(TITLE_WIDTH)
|
||||||
|
.style(move |_theme: &Theme, status| {
|
||||||
|
let bg = match status {
|
||||||
|
button::Status::Hovered | button::Status::Pressed => {
|
||||||
|
Color::from_rgb8(0x2A, 0x2C, 0x2E)
|
||||||
|
}
|
||||||
|
_ if is_open => Color::from_rgb8(0x2A, 0x2C, 0x2E),
|
||||||
|
_ => Color::TRANSPARENT,
|
||||||
|
};
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(bg)),
|
||||||
|
text_color: label_color,
|
||||||
|
border: Border::default(),
|
||||||
|
..button::Style::default()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let bar = row(titles).spacing(0).align_y(Alignment::Center);
|
||||||
|
|
||||||
|
container(bar)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(BAR_HEIGHT)
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x1A, 0x1C, 0x1E))),
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dropdown_view(state: &State) -> Option<Element<'_, EditorMessage>> {
|
||||||
|
let menu_id = state.open?;
|
||||||
|
let entries = entries_for(menu_id);
|
||||||
|
|
||||||
|
let mut items: Column<'_, EditorMessage> = Column::new().spacing(0).width(200);
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
match entry {
|
||||||
|
MenuEntry::Item(item) => {
|
||||||
|
let msg = EditorMessage::MenuBar(item.message);
|
||||||
|
let shortcut_el: Element<'_, EditorMessage> = if item.shortcut.is_empty() {
|
||||||
|
Space::new(0, 0).into()
|
||||||
|
} else {
|
||||||
|
text(item.shortcut)
|
||||||
|
.size(11)
|
||||||
|
.color(Color::from_rgb8(0x66, 0x66, 0x66))
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let item_row = row![
|
||||||
|
text(item.label).size(12),
|
||||||
|
Space::with_width(Length::Fill),
|
||||||
|
shortcut_el,
|
||||||
|
]
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.spacing(8);
|
||||||
|
|
||||||
|
let item_btn = button(item_row)
|
||||||
|
.on_press(msg)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding([5, 12])
|
||||||
|
.style(|_theme: &Theme, status| {
|
||||||
|
let bg = match status {
|
||||||
|
button::Status::Hovered => Color::from_rgb8(0x00, 0x7A, 0xFF),
|
||||||
|
button::Status::Pressed => Color::from_rgb8(0x00, 0x6A, 0xDD),
|
||||||
|
_ => Color::TRANSPARENT,
|
||||||
|
};
|
||||||
|
let text_color = match status {
|
||||||
|
button::Status::Hovered | button::Status::Pressed => Color::WHITE,
|
||||||
|
_ => Color::from_rgb8(0xCC, 0xCC, 0xCC),
|
||||||
|
};
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(bg)),
|
||||||
|
text_color,
|
||||||
|
border: Border::default(),
|
||||||
|
..button::Style::default()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
items = items.push(item_btn);
|
||||||
|
}
|
||||||
|
MenuEntry::Separator => {
|
||||||
|
let line = container(Space::new(0, 0))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(1)
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x30, 0x32, 0x34))),
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
items = items.push(container(line).padding([4, 8]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dropdown = container(items)
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x22, 0x24, 0x26))),
|
||||||
|
border: Border {
|
||||||
|
color: Color::from_rgb8(0x35, 0x37, 0x39),
|
||||||
|
width: 1.0,
|
||||||
|
radius: 4.0.into(),
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.padding(4);
|
||||||
|
|
||||||
|
let offset = dropdown_x_offset(menu_id);
|
||||||
|
|
||||||
|
let positioned: Element<'_, EditorMessage> = container(row![
|
||||||
|
Space::new(offset, 0),
|
||||||
|
dropdown,
|
||||||
|
Space::with_width(Length::Fill),
|
||||||
|
])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.padding(Padding {
|
||||||
|
top: BAR_HEIGHT,
|
||||||
|
right: 0.0,
|
||||||
|
bottom: 0.0,
|
||||||
|
left: 0.0,
|
||||||
|
})
|
||||||
|
.into();
|
||||||
|
|
||||||
|
Some(positioned)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
use crate::editor::Message;
|
||||||
|
use crate::gui::editor::track_header;
|
||||||
|
use crate::track::Track;
|
||||||
|
use iced::widget::{column, container, horizontal_rule, scrollable, slider, text, Row};
|
||||||
|
use iced::{Alignment, Background, Color, Element, Length, Theme};
|
||||||
|
|
||||||
|
pub fn view<'a>(tracks: &'a [Track]) -> Element<'a, Message> {
|
||||||
|
let header = container(
|
||||||
|
text("Mixer").size(14).color(Color::from_rgb8(0x88, 0x88, 0x88)),
|
||||||
|
)
|
||||||
|
.padding([4, 8]);
|
||||||
|
|
||||||
|
let strips: Vec<Element<_>> = tracks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, track)| {
|
||||||
|
let color_bar = container(text("").width(Length::Fill).height(3))
|
||||||
|
.style(move |_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(
|
||||||
|
track.color.r,
|
||||||
|
track.color.g,
|
||||||
|
track.color.b,
|
||||||
|
))),
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let name = text(&track.name).size(10);
|
||||||
|
let vol_slider = slider(0.0..=1.0, track.volume, move |v| {
|
||||||
|
Message::TrackHeader(i, track_header::Message::VolumeChanged(v))
|
||||||
|
})
|
||||||
|
.step(0.01)
|
||||||
|
.width(50);
|
||||||
|
let vol_label =
|
||||||
|
text(format!("{:.0}", track.volume * 100.0)).size(9);
|
||||||
|
let pan_slider = slider(-1.0..=1.0, track.pan, move |p| {
|
||||||
|
Message::TrackHeader(i, track_header::Message::PanChanged(p))
|
||||||
|
})
|
||||||
|
.step(0.01)
|
||||||
|
.width(50);
|
||||||
|
|
||||||
|
container(
|
||||||
|
column![color_bar, name, vol_slider, vol_label, pan_slider]
|
||||||
|
.spacing(4)
|
||||||
|
.align_x(Alignment::Center),
|
||||||
|
)
|
||||||
|
.width(64)
|
||||||
|
.padding([4, 2])
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x22, 0x24, 0x26))),
|
||||||
|
border: iced::Border {
|
||||||
|
color: Color::from_rgb8(0x30, 0x32, 0x34),
|
||||||
|
width: 1.0,
|
||||||
|
radius: 2.0.into(),
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let strip_row = scrollable(Row::with_children(strips).spacing(2).padding(4))
|
||||||
|
.direction(scrollable::Direction::Horizontal(
|
||||||
|
scrollable::Scrollbar::default(),
|
||||||
|
))
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
container(
|
||||||
|
column![header, horizontal_rule(1), strip_row]
|
||||||
|
.spacing(0)
|
||||||
|
.width(Length::Fill),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x1A, 0x1C, 0x1E))),
|
||||||
|
border: iced::Border {
|
||||||
|
color: Color::from_rgb8(0x30, 0x32, 0x34),
|
||||||
|
width: 1.0,
|
||||||
|
..iced::Border::default()
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
pub mod control_bar;
|
||||||
|
pub mod editor_pane;
|
||||||
|
pub mod inspector;
|
||||||
|
pub mod menu_bar;
|
||||||
|
pub mod mixer;
|
||||||
|
pub mod new_track_wizard;
|
||||||
|
pub mod timeline;
|
||||||
|
pub mod toolbar;
|
||||||
|
pub mod track_header;
|
||||||
|
pub mod visualizer;
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
// File: audio-oxide/src/gui/editor/new_track_wizard.rs
|
||||||
|
|
||||||
|
use crate::track::{TrackConfig, TrackType};
|
||||||
|
use iced::widget::{button, column, container, pick_list, row, text, text_input};
|
||||||
|
use iced::{Alignment, Background, Border, Element, Length, Theme};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct State {
|
||||||
|
pub config: TrackConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for State {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
config: TrackConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
NameChanged(String),
|
||||||
|
TrackTypeSelected(TrackType),
|
||||||
|
Cancel,
|
||||||
|
Create,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view(state: &State) -> Element<'_, Message> {
|
||||||
|
let title = text("Create New Track").size(24);
|
||||||
|
|
||||||
|
let name_input = text_input("Track Name", &state.config.name)
|
||||||
|
.on_input(Message::NameChanged);
|
||||||
|
|
||||||
|
let type_picker = pick_list(
|
||||||
|
&TrackType::ALL[..],
|
||||||
|
Some(state.config.track_type),
|
||||||
|
Message::TrackTypeSelected,
|
||||||
|
);
|
||||||
|
|
||||||
|
let controls = column![
|
||||||
|
row![text("Name:").width(80), name_input].spacing(10).align_y(Alignment::Center),
|
||||||
|
row![text("Type:").width(80), type_picker].spacing(10).align_y(Alignment::Center),
|
||||||
|
]
|
||||||
|
.spacing(15);
|
||||||
|
|
||||||
|
let action_buttons = row![
|
||||||
|
button("Cancel").on_press(Message::Cancel),
|
||||||
|
button("Create").on_press(Message::Create),
|
||||||
|
]
|
||||||
|
.spacing(10);
|
||||||
|
|
||||||
|
let content = column![title, controls, action_buttons]
|
||||||
|
.spacing(20)
|
||||||
|
.padding(20)
|
||||||
|
.align_x(Alignment::Center);
|
||||||
|
|
||||||
|
container(content)
|
||||||
|
.max_width(400)
|
||||||
|
.height(Length::Shrink) // This tells the container to wrap its content.
|
||||||
|
.style(|theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::from(
|
||||||
|
theme.extended_palette().background.weak.color,
|
||||||
|
)),
|
||||||
|
border: Border {
|
||||||
|
radius: 4.0.into(),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,314 @@
|
||||||
|
use crate::config::ProjectConfig;
|
||||||
|
use crate::editor::Tool;
|
||||||
|
use crate::timing::{MusicalTime, TICKS_PER_BEAT};
|
||||||
|
use crate::track::{Track, TRACK_HEIGHT};
|
||||||
|
use iced::widget::canvas::{self, Path, Stroke, Text};
|
||||||
|
use iced::widget::Canvas;
|
||||||
|
use iced::{alignment, mouse, Color, Element, Length, Point, Rectangle, Renderer, Size, Theme};
|
||||||
|
|
||||||
|
const RULER_HEIGHT: f32 = 30.0;
|
||||||
|
const ZOOM_SENSITIVITY: f32 = 0.005;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Message {
|
||||||
|
ClickAt(Point),
|
||||||
|
ZoomChanged(f32, f32),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view<'a>(
|
||||||
|
project_config: &'a ProjectConfig,
|
||||||
|
tracks: &'a [Track],
|
||||||
|
playhead_position: MusicalTime,
|
||||||
|
active_tool: Tool,
|
||||||
|
h_zoom: f32,
|
||||||
|
v_zoom: f32,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let effective_track_height = TRACK_HEIGHT * v_zoom;
|
||||||
|
let timeline_height = tracks.len() as f32 * effective_track_height;
|
||||||
|
|
||||||
|
Canvas::new(Timeline {
|
||||||
|
config: project_config,
|
||||||
|
tracks,
|
||||||
|
playhead_position,
|
||||||
|
_active_tool: active_tool,
|
||||||
|
h_zoom,
|
||||||
|
v_zoom,
|
||||||
|
})
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(timeline_height + RULER_HEIGHT)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Timeline<'a> {
|
||||||
|
config: &'a ProjectConfig,
|
||||||
|
tracks: &'a [Track],
|
||||||
|
playhead_position: MusicalTime,
|
||||||
|
_active_tool: Tool,
|
||||||
|
h_zoom: f32,
|
||||||
|
v_zoom: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TimelineState {
|
||||||
|
right_drag_start: Option<Point>,
|
||||||
|
right_drag_zoom_start: (f32, f32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TimelineState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
right_drag_start: None,
|
||||||
|
right_drag_zoom_start: (100.0, 1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> canvas::Program<Message> for Timeline<'a> {
|
||||||
|
type State = TimelineState;
|
||||||
|
|
||||||
|
fn update(
|
||||||
|
&self,
|
||||||
|
state: &mut Self::State,
|
||||||
|
event: canvas::Event,
|
||||||
|
bounds: Rectangle,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
) -> (canvas::event::Status, Option<Message>) {
|
||||||
|
match event {
|
||||||
|
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
|
||||||
|
if let Some(pos) = cursor.position_in(bounds) {
|
||||||
|
state.right_drag_start = Some(pos);
|
||||||
|
state.right_drag_zoom_start = (self.h_zoom, self.v_zoom);
|
||||||
|
return (canvas::event::Status::Captured, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||||
|
if let (Some(start), Some(pos)) = (state.right_drag_start, cursor.position_in(bounds)) {
|
||||||
|
let dx = pos.x - start.x;
|
||||||
|
let dy = start.y - pos.y;
|
||||||
|
let (start_h, start_v) = state.right_drag_zoom_start;
|
||||||
|
let new_h = (start_h * (1.0 + dx * ZOOM_SENSITIVITY)).clamp(10.0, 1000.0);
|
||||||
|
let new_v = (start_v * (1.0 + dy * ZOOM_SENSITIVITY)).clamp(0.3, 5.0);
|
||||||
|
return (canvas::event::Status::Captured, Some(Message::ZoomChanged(new_h, new_v)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) => {
|
||||||
|
if state.right_drag_start.is_some() {
|
||||||
|
state.right_drag_start = None;
|
||||||
|
return (canvas::event::Status::Captured, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
(canvas::event::Status::Ignored, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
_state: &Self::State,
|
||||||
|
renderer: &Renderer,
|
||||||
|
theme: &Theme,
|
||||||
|
bounds: Rectangle,
|
||||||
|
_cursor: mouse::Cursor,
|
||||||
|
) -> Vec<canvas::Geometry> {
|
||||||
|
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
||||||
|
let palette = theme.palette();
|
||||||
|
|
||||||
|
self.draw_track_backgrounds(&mut frame, bounds, &palette);
|
||||||
|
self.draw_grid(&mut frame, bounds);
|
||||||
|
self.draw_regions(&mut frame, bounds);
|
||||||
|
self.draw_ruler(&mut frame, bounds, &palette);
|
||||||
|
self.draw_playhead(&mut frame, bounds);
|
||||||
|
|
||||||
|
vec![frame.into_geometry()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Timeline<'a> {
|
||||||
|
fn beats_per_bar(&self) -> f32 {
|
||||||
|
self.config.time_signature_numerator as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_track_height(&self) -> f32 {
|
||||||
|
TRACK_HEIGHT * self.v_zoom
|
||||||
|
}
|
||||||
|
|
||||||
|
fn time_to_x(&self, time: &MusicalTime) -> f32 {
|
||||||
|
let total_beats = (time.bar as f32 - 1.0) * self.beats_per_bar()
|
||||||
|
+ (time.beat as f32 - 1.0)
|
||||||
|
+ time.tick as f32 / TICKS_PER_BEAT as f32;
|
||||||
|
total_beats * self.h_zoom
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_track_backgrounds(
|
||||||
|
&self,
|
||||||
|
frame: &mut canvas::Frame,
|
||||||
|
bounds: Rectangle,
|
||||||
|
palette: &iced::theme::Palette,
|
||||||
|
) {
|
||||||
|
let th = self.effective_track_height();
|
||||||
|
for (i, track) in self.tracks.iter().enumerate() {
|
||||||
|
let y = RULER_HEIGHT + (i as f32 * th);
|
||||||
|
let track_bounds =
|
||||||
|
Rectangle::new(Point::new(0.0, y), Size::new(bounds.width, th));
|
||||||
|
|
||||||
|
let bg_color = if i % 2 == 1 {
|
||||||
|
Color::from_rgb8(0x25, 0x28, 0x2A)
|
||||||
|
} else {
|
||||||
|
Color::from_rgb8(0x20, 0x22, 0x24)
|
||||||
|
};
|
||||||
|
frame.fill(
|
||||||
|
&Path::rectangle(track_bounds.position(), track_bounds.size()),
|
||||||
|
bg_color,
|
||||||
|
);
|
||||||
|
|
||||||
|
if track.selected {
|
||||||
|
frame.stroke(
|
||||||
|
&Path::rectangle(track_bounds.position(), track_bounds.size()),
|
||||||
|
Stroke::default()
|
||||||
|
.with_width(2.0)
|
||||||
|
.with_color(Color::from_rgb8(0x00, 0x7A, 0xFF)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let line_path = Path::line(
|
||||||
|
Point::new(0.0, y + th),
|
||||||
|
Point::new(bounds.width, y + th),
|
||||||
|
);
|
||||||
|
frame.stroke(
|
||||||
|
&line_path,
|
||||||
|
Stroke::default()
|
||||||
|
.with_width(1.0)
|
||||||
|
.with_color(palette.background),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_grid(&self, frame: &mut canvas::Frame, bounds: Rectangle) {
|
||||||
|
let ppb = self.h_zoom;
|
||||||
|
let beats_per_bar = self.beats_per_bar();
|
||||||
|
|
||||||
|
let show_beats = ppb >= 20.0;
|
||||||
|
let show_8th = (ppb / 2.0) >= 20.0;
|
||||||
|
let show_16th = (ppb / 4.0) >= 20.0;
|
||||||
|
|
||||||
|
let subdiv: f32 = if show_16th {
|
||||||
|
4.0
|
||||||
|
} else if show_8th {
|
||||||
|
2.0
|
||||||
|
} else if show_beats {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
beats_per_bar
|
||||||
|
};
|
||||||
|
|
||||||
|
let step = ppb / subdiv;
|
||||||
|
let count = (bounds.width / step).ceil() as i32;
|
||||||
|
|
||||||
|
for i in 0..=count {
|
||||||
|
let x = i as f32 * step;
|
||||||
|
let beat_index = i as f32 / subdiv;
|
||||||
|
let is_bar = beat_index > 0.0 && beat_index % beats_per_bar == 0.0;
|
||||||
|
let is_beat = beat_index.fract() == 0.0;
|
||||||
|
|
||||||
|
let (width, alpha) = if is_bar {
|
||||||
|
(1.5, 0.4)
|
||||||
|
} else if is_beat {
|
||||||
|
(1.0, 0.2)
|
||||||
|
} else {
|
||||||
|
(0.5, 0.1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = Path::line(Point::new(x, RULER_HEIGHT), Point::new(x, bounds.height));
|
||||||
|
frame.stroke(
|
||||||
|
&line,
|
||||||
|
Stroke::default()
|
||||||
|
.with_width(width)
|
||||||
|
.with_color(Color::from_rgba(0.0, 0.0, 0.0, alpha)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_ruler(
|
||||||
|
&self,
|
||||||
|
frame: &mut canvas::Frame,
|
||||||
|
bounds: Rectangle,
|
||||||
|
palette: &iced::theme::Palette,
|
||||||
|
) {
|
||||||
|
let ruler_bg = Path::rectangle(Point::new(0.0, 0.0), Size::new(bounds.width, RULER_HEIGHT));
|
||||||
|
frame.fill(&ruler_bg, Color::from_rgb8(0x1E, 0x1E, 0x1E));
|
||||||
|
let beats_per_bar = self.config.time_signature_numerator as u32;
|
||||||
|
let last_bar =
|
||||||
|
((bounds.width / self.h_zoom) / beats_per_bar as f32).ceil() as u32 + 1;
|
||||||
|
|
||||||
|
for bar in 1..=last_bar {
|
||||||
|
let x = (bar - 1) as f32 * beats_per_bar as f32 * self.h_zoom;
|
||||||
|
if x > bounds.width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let label = Text {
|
||||||
|
content: bar.to_string(),
|
||||||
|
position: Point::new(x, 5.0),
|
||||||
|
color: palette.text,
|
||||||
|
horizontal_alignment: alignment::Horizontal::Center,
|
||||||
|
..Text::default()
|
||||||
|
};
|
||||||
|
frame.fill_text(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_regions(&self, frame: &mut canvas::Frame, _bounds: Rectangle) {
|
||||||
|
let th = self.effective_track_height();
|
||||||
|
for (i, track) in self.tracks.iter().enumerate() {
|
||||||
|
let track_y = RULER_HEIGHT + (i as f32 * th);
|
||||||
|
let region_color = Color::from_rgba8(
|
||||||
|
track.color.r,
|
||||||
|
track.color.g,
|
||||||
|
track.color.b,
|
||||||
|
0.7,
|
||||||
|
);
|
||||||
|
|
||||||
|
for region in &track.regions {
|
||||||
|
let x = self.time_to_x(®ion.start_time);
|
||||||
|
let w = self.time_to_x(®ion.duration);
|
||||||
|
let y = track_y + 4.0;
|
||||||
|
let h = th - 8.0;
|
||||||
|
|
||||||
|
frame.fill(
|
||||||
|
&Path::rectangle(Point::new(x, y), Size::new(w.max(4.0), h)),
|
||||||
|
region_color,
|
||||||
|
);
|
||||||
|
|
||||||
|
if region.selected {
|
||||||
|
frame.stroke(
|
||||||
|
&Path::rectangle(Point::new(x, y), Size::new(w.max(4.0), h)),
|
||||||
|
Stroke::default()
|
||||||
|
.with_width(2.0)
|
||||||
|
.with_color(Color::WHITE),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_playhead(&self, frame: &mut canvas::Frame, bounds: Rectangle) {
|
||||||
|
let x = self.time_to_x(&self.playhead_position);
|
||||||
|
if x < 0.0 || x > bounds.width {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let line = Path::line(Point::new(x, 0.0), Point::new(x, bounds.height));
|
||||||
|
frame.stroke(
|
||||||
|
&line,
|
||||||
|
Stroke::default()
|
||||||
|
.with_width(2.0)
|
||||||
|
.with_color(Color::from_rgb8(0xFF, 0x30, 0x30)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tri = Path::new(|b| {
|
||||||
|
b.move_to(Point::new(x - 5.0, 0.0));
|
||||||
|
b.line_to(Point::new(x + 5.0, 0.0));
|
||||||
|
b.line_to(Point::new(x, 8.0));
|
||||||
|
b.close();
|
||||||
|
});
|
||||||
|
frame.fill(&tri, Color::from_rgb8(0xFF, 0x30, 0x30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
use crate::editor::{Message, Tool};
|
||||||
|
use crate::gui::icon_button::{button_group, IconButton};
|
||||||
|
use crate::gui::icons::{Icon, IconSet};
|
||||||
|
use iced::widget::container;
|
||||||
|
use iced::{Background, Color, Element, Length, Theme};
|
||||||
|
|
||||||
|
fn tool_icon(tool: &Tool) -> Icon {
|
||||||
|
match tool {
|
||||||
|
Tool::Pointer => Icon::ToolPointer,
|
||||||
|
Tool::Pencil => Icon::ToolPencil,
|
||||||
|
Tool::Eraser => Icon::ToolEraser,
|
||||||
|
Tool::Scissors => Icon::ToolScissors,
|
||||||
|
Tool::Glue => Icon::ToolGlue,
|
||||||
|
Tool::Zoom => Icon::ToolZoom,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view<'a>(active_tool: &Tool, icons: &'a IconSet) -> Element<'a, Message> {
|
||||||
|
let buttons: Vec<Element<'a, Message>> = Tool::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|tool| {
|
||||||
|
let icon = tool_icon(tool);
|
||||||
|
let (u, f) = icons.get(icon);
|
||||||
|
IconButton::new(u, f, Message::ToolSelected(*tool))
|
||||||
|
.size(32.0)
|
||||||
|
.toggled(tool == active_tool)
|
||||||
|
.hint(tool.hint())
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let tool_group = button_group(buttons);
|
||||||
|
|
||||||
|
container(tool_group)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding([6, 14])
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x22, 0x24, 0x26))),
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
use crate::gui::icon_button::IconButton;
|
||||||
|
use crate::gui::icons::{Icon, IconSet};
|
||||||
|
use crate::track::Track;
|
||||||
|
use iced::widget::{button, column, container, row, slider, text};
|
||||||
|
use iced::{Alignment, Background, Color, Element, Length, Theme};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Message {
|
||||||
|
MuteToggled,
|
||||||
|
SoloToggled,
|
||||||
|
RecordArmToggled,
|
||||||
|
VolumeChanged(f32),
|
||||||
|
PanChanged(f32),
|
||||||
|
Delete,
|
||||||
|
Select,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view<'a>(track: &'a Track, icons: &'a IconSet, height: f32) -> Element<'a, Message> {
|
||||||
|
let color_strip = container(text("").width(3).height(Length::Fill))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(move |_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(
|
||||||
|
track.color.r,
|
||||||
|
track.color.g,
|
||||||
|
track.color.b,
|
||||||
|
))),
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let track_name = text(&track.name).size(15).width(Length::Fill);
|
||||||
|
let track_type_label = text(format!("{}", track.track_type))
|
||||||
|
.size(9)
|
||||||
|
.color(Color::from_rgb8(0x77, 0x77, 0x77));
|
||||||
|
|
||||||
|
let (mute_u, mute_f) = icons.get(Icon::Mute);
|
||||||
|
let (solo_u, solo_f) = icons.get(Icon::Solo);
|
||||||
|
let (rec_u, rec_f) = icons.get(Icon::RecordArm);
|
||||||
|
|
||||||
|
let mute_btn: Element<'a, Message> = IconButton::new(mute_u, mute_f, Message::MuteToggled)
|
||||||
|
.size(28.0)
|
||||||
|
.toggled(track.muted)
|
||||||
|
.active_tint(Color::from_rgb8(0xCC, 0xA7, 0x00))
|
||||||
|
.hint("Mute")
|
||||||
|
.into();
|
||||||
|
let solo_btn: Element<'a, Message> = IconButton::new(solo_u, solo_f, Message::SoloToggled)
|
||||||
|
.size(28.0)
|
||||||
|
.toggled(track.soloed)
|
||||||
|
.active_tint(Color::from_rgb8(0x00, 0x7A, 0xFF))
|
||||||
|
.hint("Solo")
|
||||||
|
.into();
|
||||||
|
let rec_btn: Element<'a, Message> =
|
||||||
|
IconButton::new(rec_u, rec_f, Message::RecordArmToggled)
|
||||||
|
.size(28.0)
|
||||||
|
.toggled(track.record_armed)
|
||||||
|
.active_tint(Color::from_rgb8(0xCC, 0x33, 0x33))
|
||||||
|
.hint("Record Arm")
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let controls = row![mute_btn, solo_btn, rec_btn].spacing(2);
|
||||||
|
|
||||||
|
let volume_slider = slider(0.0..=1.0, track.volume, Message::VolumeChanged)
|
||||||
|
.step(0.01)
|
||||||
|
.width(Length::Fill);
|
||||||
|
let pan_slider = slider(-1.0..=1.0, track.pan, Message::PanChanged)
|
||||||
|
.step(0.01)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
let del_btn = button(text("x").size(9))
|
||||||
|
.on_press(Message::Delete)
|
||||||
|
.padding([1, 4])
|
||||||
|
.style(|_theme: &Theme, _status| button::Style {
|
||||||
|
background: Some(Background::Color(Color::TRANSPARENT)),
|
||||||
|
text_color: Color::from_rgb8(0x66, 0x66, 0x66),
|
||||||
|
..button::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let info = column![
|
||||||
|
row![track_name, del_btn].align_y(Alignment::Center),
|
||||||
|
row![track_type_label, controls]
|
||||||
|
.spacing(6)
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
row![text("Vol").size(9), volume_slider]
|
||||||
|
.spacing(4)
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
row![text("Pan").size(9), pan_slider]
|
||||||
|
.spacing(4)
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.padding([6, 8])
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
let inner = row![color_strip, info];
|
||||||
|
|
||||||
|
let content = button(inner)
|
||||||
|
.on_press(Message::Select)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(|_theme: &Theme, _status: button::Status| button::Style {
|
||||||
|
background: Some(Background::Color(Color::TRANSPARENT)),
|
||||||
|
text_color: Color::from_rgb8(0xCC, 0xCC, 0xCC),
|
||||||
|
..button::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
container(content)
|
||||||
|
.style(move |theme: &Theme| {
|
||||||
|
if track.selected {
|
||||||
|
container::Style {
|
||||||
|
border: iced::Border {
|
||||||
|
color: theme.extended_palette().primary.strong.color,
|
||||||
|
width: 2.0,
|
||||||
|
radius: 2.0.into(),
|
||||||
|
},
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x22, 0x28, 0x30))),
|
||||||
|
..container::Style::default()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
container::Style {
|
||||||
|
border: iced::Border {
|
||||||
|
color: Color::from_rgb8(0x2A, 0x2C, 0x2E),
|
||||||
|
width: 1.0,
|
||||||
|
radius: 0.0.into(),
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(height)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub mod spiral;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VisualizerKind {
|
||||||
|
Spiral,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
struct Uniforms {
|
||||||
|
mvp: mat4x4<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
|
||||||
|
|
||||||
|
struct VertexInput {
|
||||||
|
@location(0) position: vec3<f32>,
|
||||||
|
@location(1) color: vec4<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct VertexOutput {
|
||||||
|
@builtin(position) clip_position: vec4<f32>,
|
||||||
|
@location(0) frag_color: vec4<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(in: VertexInput) -> VertexOutput {
|
||||||
|
var out: VertexOutput;
|
||||||
|
out.clip_position = uniforms.mvp * vec4<f32>(in.position, 1.0);
|
||||||
|
out.frag_color = in.color;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
return in.frag_color;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,420 @@
|
||||||
|
use iced::widget::shader;
|
||||||
|
use iced::widget::shader::wgpu;
|
||||||
|
use iced::{mouse, Element, Length, Rectangle};
|
||||||
|
|
||||||
|
use crate::editor::Message as EditorMessage;
|
||||||
|
use crate::modules::{PhasePoint, VisualizationFrame};
|
||||||
|
|
||||||
|
// Vertex: [x, y, z, r, g, b, a] = 7 floats = 28 bytes
|
||||||
|
const VERTEX_SIZE: u64 = 28;
|
||||||
|
const MAX_VERTICES: u64 = 8192;
|
||||||
|
|
||||||
|
pub struct SpiralProgram {
|
||||||
|
frame: Option<VisualizationFrame>,
|
||||||
|
rotation: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpiralProgram {
|
||||||
|
pub fn new(frame: Option<&VisualizationFrame>, rotation: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
frame: frame.cloned(),
|
||||||
|
rotation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SpiralPrimitive {
|
||||||
|
vertices_l: Vec<[f32; 7]>,
|
||||||
|
vertices_r: Vec<[f32; 7]>,
|
||||||
|
rotation: f32,
|
||||||
|
aspect: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SpiralState {
|
||||||
|
drag_start: Option<(f32, f32)>,
|
||||||
|
rotation: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl shader::Program<EditorMessage> for SpiralProgram {
|
||||||
|
type State = SpiralState;
|
||||||
|
type Primitive = SpiralPrimitive;
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
state: &Self::State,
|
||||||
|
_cursor: mouse::Cursor,
|
||||||
|
bounds: Rectangle,
|
||||||
|
) -> Self::Primitive {
|
||||||
|
let aspect = if bounds.height > 0.0 {
|
||||||
|
bounds.width / bounds.height
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let (verts_l, verts_r) = match &self.frame {
|
||||||
|
Some(frame) => (
|
||||||
|
points_to_vertices(&frame.left, &GRADIENT_BLUE),
|
||||||
|
points_to_vertices(&frame.right, &GRADIENT_PINK),
|
||||||
|
),
|
||||||
|
None => (Vec::new(), Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
SpiralPrimitive {
|
||||||
|
vertices_l: verts_l,
|
||||||
|
vertices_r: verts_r,
|
||||||
|
rotation: self.rotation + state.rotation,
|
||||||
|
aspect,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(
|
||||||
|
&self,
|
||||||
|
state: &mut Self::State,
|
||||||
|
event: shader::Event,
|
||||||
|
bounds: Rectangle,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
_shell: &mut iced::advanced::Shell<'_, EditorMessage>,
|
||||||
|
) -> (iced::event::Status, Option<EditorMessage>) {
|
||||||
|
match event {
|
||||||
|
shader::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||||
|
if let Some(pos) = cursor.position_in(bounds) {
|
||||||
|
state.drag_start = Some((pos.x, pos.y));
|
||||||
|
return (iced::event::Status::Captured, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shader::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||||
|
if let Some((start_x, _)) = state.drag_start {
|
||||||
|
let dx = position.x - bounds.x - start_x;
|
||||||
|
state.rotation = dx * 0.01;
|
||||||
|
return (iced::event::Status::Captured, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shader::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||||
|
state.drag_start = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
(iced::event::Status::Ignored, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_interaction(
|
||||||
|
&self,
|
||||||
|
state: &Self::State,
|
||||||
|
_bounds: Rectangle,
|
||||||
|
_cursor: mouse::Cursor,
|
||||||
|
) -> mouse::Interaction {
|
||||||
|
if state.drag_start.is_some() {
|
||||||
|
mouse::Interaction::Grabbing
|
||||||
|
} else {
|
||||||
|
mouse::Interaction::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SpiralPipeline {
|
||||||
|
pipeline: wgpu::RenderPipeline,
|
||||||
|
vertex_buffer: wgpu::Buffer,
|
||||||
|
uniform_buffer: wgpu::Buffer,
|
||||||
|
bind_group: wgpu::BindGroup,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl shader::Primitive for SpiralPrimitive {
|
||||||
|
fn prepare(
|
||||||
|
&self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
format: wgpu::TextureFormat,
|
||||||
|
storage: &mut shader::Storage,
|
||||||
|
_bounds: &Rectangle,
|
||||||
|
_viewport: &shader::Viewport,
|
||||||
|
) {
|
||||||
|
if !storage.has::<SpiralPipeline>() {
|
||||||
|
let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("spiral_shader"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/spiral.wgsl").into()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let bind_group_layout =
|
||||||
|
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("spiral_bind_group_layout"),
|
||||||
|
entries: &[wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let pipeline_layout =
|
||||||
|
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("spiral_pipeline_layout"),
|
||||||
|
bind_group_layouts: &[&bind_group_layout],
|
||||||
|
push_constant_ranges: &[],
|
||||||
|
});
|
||||||
|
|
||||||
|
let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("spiral_vertex_buffer"),
|
||||||
|
size: MAX_VERTICES * VERTEX_SIZE,
|
||||||
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("spiral_uniform_buffer"),
|
||||||
|
size: 64, // mat4x4<f32>
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("spiral_bind_group"),
|
||||||
|
layout: &bind_group_layout,
|
||||||
|
entries: &[wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: uniform_buffer.as_entire_binding(),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("spiral_pipeline"),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader_module,
|
||||||
|
entry_point: "vs_main",
|
||||||
|
buffers: &[wgpu::VertexBufferLayout {
|
||||||
|
array_stride: VERTEX_SIZE,
|
||||||
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
|
attributes: &[
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: 0,
|
||||||
|
shader_location: 0,
|
||||||
|
format: wgpu::VertexFormat::Float32x3,
|
||||||
|
},
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: 12,
|
||||||
|
shader_location: 1,
|
||||||
|
format: wgpu::VertexFormat::Float32x4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::LineStrip,
|
||||||
|
strip_index_format: None,
|
||||||
|
front_face: wgpu::FrontFace::Ccw,
|
||||||
|
cull_mode: None,
|
||||||
|
unclipped_depth: false,
|
||||||
|
polygon_mode: wgpu::PolygonMode::Fill,
|
||||||
|
conservative: false,
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState::default(),
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader_module,
|
||||||
|
entry_point: "fs_main",
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format,
|
||||||
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
multiview: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
storage.store(SpiralPipeline {
|
||||||
|
pipeline,
|
||||||
|
vertex_buffer,
|
||||||
|
uniform_buffer,
|
||||||
|
bind_group,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let pipeline_data = storage.get_mut::<SpiralPipeline>().unwrap();
|
||||||
|
|
||||||
|
// Update uniform (MVP matrix)
|
||||||
|
let mvp = build_mvp(self.rotation, self.aspect);
|
||||||
|
queue.write_buffer(&pipeline_data.uniform_buffer, 0, cast_mat4(&mvp));
|
||||||
|
|
||||||
|
// Upload vertices
|
||||||
|
let mut all_verts: Vec<[f32; 7]> = Vec::new();
|
||||||
|
all_verts.extend_from_slice(&self.vertices_l);
|
||||||
|
// Separator (degenerate) between L and R channels
|
||||||
|
if !self.vertices_l.is_empty() && !self.vertices_r.is_empty() {
|
||||||
|
let last_l = *self.vertices_l.last().unwrap();
|
||||||
|
let first_r = self.vertices_r[0];
|
||||||
|
// Degenerate vertices with zero alpha for line strip break
|
||||||
|
all_verts.push([last_l[0], last_l[1], last_l[2], 0.0, 0.0, 0.0, 0.0]);
|
||||||
|
all_verts.push([first_r[0], first_r[1], first_r[2], 0.0, 0.0, 0.0, 0.0]);
|
||||||
|
}
|
||||||
|
all_verts.extend_from_slice(&self.vertices_r);
|
||||||
|
|
||||||
|
let vert_count = all_verts.len().min(MAX_VERTICES as usize);
|
||||||
|
if vert_count > 0 {
|
||||||
|
let byte_data = cast_vertex_slice(&all_verts[..vert_count]);
|
||||||
|
queue.write_buffer(&pipeline_data.vertex_buffer, 0, byte_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
&self,
|
||||||
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
|
storage: &shader::Storage,
|
||||||
|
target: &wgpu::TextureView,
|
||||||
|
clip_bounds: &Rectangle<u32>,
|
||||||
|
) {
|
||||||
|
let pipeline_data = storage.get::<SpiralPipeline>().unwrap();
|
||||||
|
|
||||||
|
let total_verts = self.vertices_l.len() + self.vertices_r.len()
|
||||||
|
+ if !self.vertices_l.is_empty() && !self.vertices_r.is_empty() { 2 } else { 0 };
|
||||||
|
let vert_count = total_verts.min(MAX_VERTICES as usize) as u32;
|
||||||
|
if vert_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("spiral_render_pass"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: target,
|
||||||
|
resolve_target: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Load,
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
timestamp_writes: None,
|
||||||
|
occlusion_query_set: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
pass.set_scissor_rect(
|
||||||
|
clip_bounds.x,
|
||||||
|
clip_bounds.y,
|
||||||
|
clip_bounds.width,
|
||||||
|
clip_bounds.height,
|
||||||
|
);
|
||||||
|
pass.set_pipeline(&pipeline_data.pipeline);
|
||||||
|
pass.set_bind_group(0, &pipeline_data.bind_group, &[]);
|
||||||
|
pass.set_vertex_buffer(0, pipeline_data.vertex_buffer.slice(..));
|
||||||
|
pass.draw(0..vert_count, 0..1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amplitude-mapped color gradient: purple -> blue -> green -> yellow -> red
|
||||||
|
const GRADIENT_BLUE: [(f32, f32, f32); 5] = [
|
||||||
|
(0.5, 0.0, 1.0), // purple
|
||||||
|
(0.0, 0.3, 1.0), // blue
|
||||||
|
(0.0, 1.0, 0.3), // green
|
||||||
|
(1.0, 1.0, 0.0), // yellow
|
||||||
|
(1.0, 0.0, 0.0), // red
|
||||||
|
];
|
||||||
|
|
||||||
|
const GRADIENT_PINK: [(f32, f32, f32); 5] = [
|
||||||
|
(0.6, 0.0, 0.8),
|
||||||
|
(0.8, 0.2, 0.6),
|
||||||
|
(1.0, 0.4, 0.4),
|
||||||
|
(1.0, 0.7, 0.2),
|
||||||
|
(1.0, 0.2, 0.2),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn amplitude_color(amp: f32, gradient: &[(f32, f32, f32); 5]) -> (f32, f32, f32, f32) {
|
||||||
|
let t = amp.clamp(0.0, 1.0) * 4.0;
|
||||||
|
let idx = (t as usize).min(3);
|
||||||
|
let frac = t - idx as f32;
|
||||||
|
let (r0, g0, b0) = gradient[idx];
|
||||||
|
let (r1, g1, b1) = gradient[idx + 1];
|
||||||
|
(
|
||||||
|
r0 + (r1 - r0) * frac,
|
||||||
|
g0 + (g1 - g0) * frac,
|
||||||
|
b0 + (b1 - b0) * frac,
|
||||||
|
0.8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn points_to_vertices(points: &[PhasePoint], gradient: &[(f32, f32, f32); 5]) -> Vec<[f32; 7]> {
|
||||||
|
let total = points.len().max(1) as f32;
|
||||||
|
points
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, p)| {
|
||||||
|
let z = (i as f32 / total) * 2.0 - 1.0; // map to [-1, 1]
|
||||||
|
let (r, g, b, a) = amplitude_color(p.amplitude, gradient);
|
||||||
|
[p.x, p.y, z, r, g, b, a]
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_mvp(rotation_y: f32, aspect: f32) -> [f32; 16] {
|
||||||
|
let fov = std::f32::consts::FRAC_PI_4;
|
||||||
|
let near = 0.1;
|
||||||
|
let far = 100.0;
|
||||||
|
let f = 1.0 / (fov / 2.0).tan();
|
||||||
|
|
||||||
|
// Perspective projection
|
||||||
|
let proj = [
|
||||||
|
f / aspect, 0.0, 0.0, 0.0,
|
||||||
|
0.0, f, 0.0, 0.0,
|
||||||
|
0.0, 0.0, (far + near) / (near - far), -1.0,
|
||||||
|
0.0, 0.0, (2.0 * far * near) / (near - far), 0.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
// View: translate back
|
||||||
|
let view = [
|
||||||
|
1.0, 0.0, 0.0, 0.0,
|
||||||
|
0.0, 1.0, 0.0, 0.0,
|
||||||
|
0.0, 0.0, 1.0, 0.0,
|
||||||
|
0.0, 0.0, -3.0, 1.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Y-axis rotation
|
||||||
|
let cos = rotation_y.cos();
|
||||||
|
let sin = rotation_y.sin();
|
||||||
|
let rot = [
|
||||||
|
cos, 0.0, sin, 0.0,
|
||||||
|
0.0, 1.0, 0.0, 0.0,
|
||||||
|
-sin, 0.0, cos, 0.0,
|
||||||
|
0.0, 0.0, 0.0, 1.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
// MVP = proj * view * rot
|
||||||
|
let vr = mat4_mul(&view, &rot);
|
||||||
|
mat4_mul(&proj, &vr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
|
||||||
|
let mut out = [0.0f32; 16];
|
||||||
|
for row in 0..4 {
|
||||||
|
for col in 0..4 {
|
||||||
|
let mut sum = 0.0;
|
||||||
|
for k in 0..4 {
|
||||||
|
sum += a[row + k * 4] * b[k + col * 4];
|
||||||
|
}
|
||||||
|
out[row + col * 4] = sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cast_vertex_slice(data: &[[f32; 7]]) -> &[u8] {
|
||||||
|
unsafe {
|
||||||
|
std::slice::from_raw_parts(data.as_ptr() as *const u8, data.len() * std::mem::size_of::<[f32; 7]>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cast_mat4(data: &[f32; 16]) -> &[u8] {
|
||||||
|
unsafe {
|
||||||
|
std::slice::from_raw_parts(data.as_ptr() as *const u8, std::mem::size_of::<[f32; 16]>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view(frame: Option<&VisualizationFrame>, rotation: f32) -> Element<'_, EditorMessage> {
|
||||||
|
shader::Shader::new(SpiralProgram::new(frame, rotation))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
use crate::entry::Message;
|
||||||
|
use iced::widget::{button, column, text, text_input};
|
||||||
|
use iced::{Alignment, Element};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn view(project_dir: &PathBuf) -> Element<'static, Message> {
|
||||||
|
let current_path_str = project_dir.to_str().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
let content = column![
|
||||||
|
text("Welcome to Audio Oxide").size(30),
|
||||||
|
text("Please choose a directory to store your projects."),
|
||||||
|
text_input("Project Directory", ¤t_path_str)
|
||||||
|
.on_input(Message::FirstRunProjectDirChanged),
|
||||||
|
button("Continue").on_press(Message::FirstRunComplete),
|
||||||
|
]
|
||||||
|
.spacing(15)
|
||||||
|
.align_x(Alignment::Center);
|
||||||
|
|
||||||
|
content.into()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,436 @@
|
||||||
|
use iced::advanced::layout::{self, Layout};
|
||||||
|
use iced::advanced::overlay;
|
||||||
|
use iced::advanced::renderer::{self, Quad};
|
||||||
|
use iced::advanced::text::Renderer as TextRenderer;
|
||||||
|
use iced::advanced::widget::tree::{self, Tree};
|
||||||
|
use iced::advanced::widget::Widget;
|
||||||
|
use iced::advanced::Renderer as _;
|
||||||
|
use iced::advanced::{Clipboard, Shell, Text as AdvText};
|
||||||
|
use iced::event::{self, Event};
|
||||||
|
use iced::keyboard::{self, Key};
|
||||||
|
use iced::mouse;
|
||||||
|
use iced::widget::svg;
|
||||||
|
use iced::{
|
||||||
|
Border, Color, Element, Font, Length, Point, Radians, Rectangle, Shadow, Size, Theme, Vector,
|
||||||
|
};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const FLASH_DURATION: Duration = Duration::from_millis(100);
|
||||||
|
const CANCEL_MARGIN: f32 = 0.2;
|
||||||
|
const TOOLTIP_DELAY: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
pub struct IconButton<'a, Message> {
|
||||||
|
unfilled: &'a svg::Handle,
|
||||||
|
filled: &'a svg::Handle,
|
||||||
|
on_press: Message,
|
||||||
|
tint: Color,
|
||||||
|
active_tint: Color,
|
||||||
|
size: f32,
|
||||||
|
toggled: bool,
|
||||||
|
hint: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message: Clone> IconButton<'a, Message> {
|
||||||
|
pub fn new(
|
||||||
|
unfilled: &'a svg::Handle,
|
||||||
|
filled: &'a svg::Handle,
|
||||||
|
on_press: Message,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
unfilled,
|
||||||
|
filled,
|
||||||
|
on_press,
|
||||||
|
tint: Color::from_rgb8(0xCC, 0xCC, 0xCC),
|
||||||
|
active_tint: Color::from_rgb8(0x00, 0x7A, 0xFF),
|
||||||
|
size: 34.0,
|
||||||
|
toggled: false,
|
||||||
|
hint: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tint(mut self, color: Color) -> Self {
|
||||||
|
self.tint = color;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_tint(mut self, color: Color) -> Self {
|
||||||
|
self.active_tint = color;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(mut self, size: f32) -> Self {
|
||||||
|
self.size = size;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggled(mut self, toggled: bool) -> Self {
|
||||||
|
self.toggled = toggled;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hint(mut self, hint: &'a str) -> Self {
|
||||||
|
self.hint = Some(hint);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct State {
|
||||||
|
is_pressed: bool,
|
||||||
|
cancelled: bool,
|
||||||
|
flash_until: Option<Instant>,
|
||||||
|
hover_start: Option<Instant>,
|
||||||
|
show_help: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for State {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
is_pressed: false,
|
||||||
|
cancelled: false,
|
||||||
|
flash_until: None,
|
||||||
|
hover_start: None,
|
||||||
|
show_help: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message> Widget<Message, Theme, iced::Renderer> for IconButton<'a, Message>
|
||||||
|
where
|
||||||
|
Message: Clone,
|
||||||
|
{
|
||||||
|
fn tag(&self) -> tree::Tag {
|
||||||
|
tree::Tag::of::<State>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(&self) -> tree::State {
|
||||||
|
tree::State::new(State::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(&self) -> Size<Length> {
|
||||||
|
Size::new(Length::Fixed(self.size), Length::Fixed(self.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(
|
||||||
|
&self,
|
||||||
|
_tree: &mut Tree,
|
||||||
|
_renderer: &iced::Renderer,
|
||||||
|
_limits: &layout::Limits,
|
||||||
|
) -> layout::Node {
|
||||||
|
layout::Node::new(Size::new(self.size, self.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
tree: &Tree,
|
||||||
|
renderer: &mut iced::Renderer,
|
||||||
|
_theme: &Theme,
|
||||||
|
_style: &renderer::Style,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
_cursor: mouse::Cursor,
|
||||||
|
_viewport: &Rectangle,
|
||||||
|
) {
|
||||||
|
let state = tree.state.downcast_ref::<State>();
|
||||||
|
let bounds = layout.bounds();
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let flashing = state.flash_until.is_some_and(|t| now < t);
|
||||||
|
let pressed = state.is_pressed && !state.cancelled;
|
||||||
|
|
||||||
|
let bg_color = if pressed {
|
||||||
|
Color::from_rgba8(0xFF, 0xFF, 0xFF, 0.1)
|
||||||
|
} else if flashing {
|
||||||
|
Color {
|
||||||
|
a: 0.3,
|
||||||
|
..self.active_tint
|
||||||
|
}
|
||||||
|
} else if self.toggled {
|
||||||
|
self.active_tint
|
||||||
|
} else {
|
||||||
|
Color::TRANSPARENT
|
||||||
|
};
|
||||||
|
|
||||||
|
renderer.fill_quad(
|
||||||
|
Quad {
|
||||||
|
bounds,
|
||||||
|
border: Border {
|
||||||
|
radius: 4.0.into(),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
shadow: Shadow::default(),
|
||||||
|
},
|
||||||
|
bg_color,
|
||||||
|
);
|
||||||
|
|
||||||
|
let show_filled = pressed || self.toggled;
|
||||||
|
let handle = if show_filled {
|
||||||
|
self.filled
|
||||||
|
} else {
|
||||||
|
self.unfilled
|
||||||
|
};
|
||||||
|
|
||||||
|
let icon_size = self.size - 4.0;
|
||||||
|
let icon_bounds = Rectangle {
|
||||||
|
x: bounds.x + (bounds.width - icon_size) / 2.0,
|
||||||
|
y: bounds.y + (bounds.height - icon_size) / 2.0,
|
||||||
|
width: icon_size,
|
||||||
|
height: icon_size,
|
||||||
|
};
|
||||||
|
|
||||||
|
let tint = if self.toggled && !pressed {
|
||||||
|
Color::WHITE
|
||||||
|
} else {
|
||||||
|
self.tint
|
||||||
|
};
|
||||||
|
|
||||||
|
use iced::advanced::svg::Renderer as _;
|
||||||
|
renderer.draw_svg(
|
||||||
|
iced::advanced::svg::Svg {
|
||||||
|
handle: handle.clone(),
|
||||||
|
color: Some(tint),
|
||||||
|
rotation: Radians(0.0),
|
||||||
|
opacity: 1.0,
|
||||||
|
},
|
||||||
|
icon_bounds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_event(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut Tree,
|
||||||
|
event: Event,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
_renderer: &iced::Renderer,
|
||||||
|
_clipboard: &mut dyn Clipboard,
|
||||||
|
shell: &mut Shell<'_, Message>,
|
||||||
|
_viewport: &Rectangle,
|
||||||
|
) -> event::Status {
|
||||||
|
let state = tree.state.downcast_mut::<State>();
|
||||||
|
let bounds = layout.bounds();
|
||||||
|
let over = cursor.is_over(bounds);
|
||||||
|
|
||||||
|
// Hover tracking
|
||||||
|
match &event {
|
||||||
|
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||||
|
if over {
|
||||||
|
if state.hover_start.is_none() {
|
||||||
|
state.hover_start = Some(Instant::now());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.hover_start = None;
|
||||||
|
state.show_help = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Mouse(mouse::Event::CursorLeft) => {
|
||||||
|
state.hover_start = None;
|
||||||
|
state.show_help = false;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? key toggles help
|
||||||
|
if over {
|
||||||
|
if let Event::Keyboard(keyboard::Event::KeyPressed {
|
||||||
|
key: Key::Character(ref c),
|
||||||
|
..
|
||||||
|
}) = event
|
||||||
|
{
|
||||||
|
if c.as_str() == "?" {
|
||||||
|
state.show_help = !state.show_help;
|
||||||
|
return event::Status::Captured;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match event {
|
||||||
|
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||||
|
if over {
|
||||||
|
state.is_pressed = true;
|
||||||
|
state.cancelled = false;
|
||||||
|
return event::Status::Captured;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||||
|
if state.is_pressed && !state.cancelled {
|
||||||
|
let margin = bounds.width * CANCEL_MARGIN;
|
||||||
|
let expanded = Rectangle {
|
||||||
|
x: bounds.x - margin,
|
||||||
|
y: bounds.y - margin,
|
||||||
|
width: bounds.width + margin * 2.0,
|
||||||
|
height: bounds.height + margin * 2.0,
|
||||||
|
};
|
||||||
|
if !cursor.is_over(expanded) {
|
||||||
|
state.cancelled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||||
|
if state.is_pressed {
|
||||||
|
if !state.cancelled {
|
||||||
|
shell.publish(self.on_press.clone());
|
||||||
|
if !self.toggled {
|
||||||
|
state.flash_until = Some(Instant::now() + FLASH_DURATION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.is_pressed = false;
|
||||||
|
state.cancelled = false;
|
||||||
|
return event::Status::Captured;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
event::Status::Ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_interaction(
|
||||||
|
&self,
|
||||||
|
_tree: &Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
_viewport: &Rectangle,
|
||||||
|
_renderer: &iced::Renderer,
|
||||||
|
) -> mouse::Interaction {
|
||||||
|
if cursor.is_over(layout.bounds()) {
|
||||||
|
mouse::Interaction::Pointer
|
||||||
|
} else {
|
||||||
|
mouse::Interaction::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay<'b>(
|
||||||
|
&'b mut self,
|
||||||
|
tree: &'b mut Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
_renderer: &iced::Renderer,
|
||||||
|
translation: Vector,
|
||||||
|
) -> Option<overlay::Element<'b, Message, Theme, iced::Renderer>> {
|
||||||
|
let hint = self.hint?;
|
||||||
|
let state = tree.state.downcast_ref::<State>();
|
||||||
|
|
||||||
|
let show_tooltip = state.show_help
|
||||||
|
|| state
|
||||||
|
.hover_start
|
||||||
|
.is_some_and(|t| t.elapsed() >= TOOLTIP_DELAY);
|
||||||
|
|
||||||
|
if !show_tooltip {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bounds = layout.bounds();
|
||||||
|
let anchor = Point::new(
|
||||||
|
bounds.x + bounds.width / 2.0 + translation.x,
|
||||||
|
bounds.y + bounds.height + translation.y + 4.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(overlay::Element::new(Box::new(TooltipOverlay {
|
||||||
|
text: hint,
|
||||||
|
anchor,
|
||||||
|
is_help: state.show_help,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message: Clone + 'a> From<IconButton<'a, Message>> for Element<'a, Message> {
|
||||||
|
fn from(btn: IconButton<'a, Message>) -> Self {
|
||||||
|
Element::new(btn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TooltipOverlay<'a> {
|
||||||
|
text: &'a str,
|
||||||
|
anchor: Point,
|
||||||
|
is_help: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message> overlay::Overlay<Message, Theme, iced::Renderer> for TooltipOverlay<'a> {
|
||||||
|
fn layout(&mut self, _renderer: &iced::Renderer, bounds: Size) -> layout::Node {
|
||||||
|
let padding = if self.is_help { 8.0 } else { 6.0 };
|
||||||
|
let font_size = if self.is_help { 13.0 } else { 11.0 };
|
||||||
|
|
||||||
|
let char_width = font_size * 0.6;
|
||||||
|
let w = self.text.len() as f32 * char_width + padding * 2.0;
|
||||||
|
let h = font_size + padding * 2.0;
|
||||||
|
|
||||||
|
let x = (self.anchor.x - w / 2.0).clamp(0.0, (bounds.width - w).max(0.0));
|
||||||
|
let y = self.anchor.y.min((bounds.height - h).max(0.0));
|
||||||
|
|
||||||
|
layout::Node::new(Size::new(w, h)).move_to(Point::new(x, y))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
renderer: &mut iced::Renderer,
|
||||||
|
_theme: &Theme,
|
||||||
|
_style: &renderer::Style,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
_cursor: mouse::Cursor,
|
||||||
|
) {
|
||||||
|
let bounds = layout.bounds();
|
||||||
|
let font_size = if self.is_help { 13.0 } else { 11.0 };
|
||||||
|
|
||||||
|
renderer.fill_quad(
|
||||||
|
Quad {
|
||||||
|
bounds,
|
||||||
|
border: Border {
|
||||||
|
radius: 4.0.into(),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
shadow: Shadow::default(),
|
||||||
|
},
|
||||||
|
Color::from_rgba8(0x1A, 0x1A, 0x1A, 0.95),
|
||||||
|
);
|
||||||
|
|
||||||
|
let padding = if self.is_help { 8.0 } else { 6.0 };
|
||||||
|
renderer.fill_text(
|
||||||
|
AdvText {
|
||||||
|
content: self.text.to_string(),
|
||||||
|
bounds: Size::new(bounds.width - padding * 2.0, bounds.height - padding * 2.0),
|
||||||
|
size: iced::Pixels(font_size),
|
||||||
|
line_height: iced::advanced::text::LineHeight::default(),
|
||||||
|
font: Font::DEFAULT,
|
||||||
|
horizontal_alignment: iced::alignment::Horizontal::Left,
|
||||||
|
vertical_alignment: iced::alignment::Vertical::Top,
|
||||||
|
shaping: iced::advanced::text::Shaping::Basic,
|
||||||
|
wrapping: iced::advanced::text::Wrapping::None,
|
||||||
|
},
|
||||||
|
Point::new(bounds.x + padding, bounds.y + padding),
|
||||||
|
Color::from_rgb8(0xDD, 0xDD, 0xDD),
|
||||||
|
bounds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_over(
|
||||||
|
&self,
|
||||||
|
_layout: Layout<'_>,
|
||||||
|
_renderer: &iced::Renderer,
|
||||||
|
_cursor_position: Point,
|
||||||
|
) -> bool {
|
||||||
|
self.is_help
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn button_group<'a, Message: Clone + 'a>(
|
||||||
|
buttons: Vec<Element<'a, Message>>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
use iced::widget::{container, Row};
|
||||||
|
use iced::Alignment;
|
||||||
|
|
||||||
|
let row = buttons
|
||||||
|
.into_iter()
|
||||||
|
.fold(Row::new().spacing(3).align_y(Alignment::Center), |r, btn| {
|
||||||
|
r.push(btn)
|
||||||
|
});
|
||||||
|
|
||||||
|
container(row)
|
||||||
|
.padding([3, 5])
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
border: Border {
|
||||||
|
color: Color::from_rgb8(0x3A, 0x3C, 0x3E),
|
||||||
|
width: 1.0,
|
||||||
|
radius: 8.0.into(),
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
use iced::widget::svg;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Icon {
|
||||||
|
// Transport
|
||||||
|
Play,
|
||||||
|
Stop,
|
||||||
|
Record,
|
||||||
|
Rewind,
|
||||||
|
FastForward,
|
||||||
|
Pause,
|
||||||
|
ReturnToZero,
|
||||||
|
// Mode toggles
|
||||||
|
Cycle,
|
||||||
|
Metronome,
|
||||||
|
CountIn,
|
||||||
|
// Tools
|
||||||
|
ToolPointer,
|
||||||
|
ToolPencil,
|
||||||
|
ToolEraser,
|
||||||
|
ToolScissors,
|
||||||
|
ToolGlue,
|
||||||
|
ToolZoom,
|
||||||
|
// Track controls
|
||||||
|
Mute,
|
||||||
|
Solo,
|
||||||
|
RecordArm,
|
||||||
|
InputMonitor,
|
||||||
|
Freeze,
|
||||||
|
Lock,
|
||||||
|
// Track types
|
||||||
|
TrackAudio,
|
||||||
|
TrackMidi,
|
||||||
|
TrackAux,
|
||||||
|
TrackBus,
|
||||||
|
// View toggles
|
||||||
|
ViewInspector,
|
||||||
|
ViewEditor,
|
||||||
|
ViewMixer,
|
||||||
|
ViewLibrary,
|
||||||
|
ViewToolbar,
|
||||||
|
ViewNotepad,
|
||||||
|
// Mixer
|
||||||
|
Eq,
|
||||||
|
Send,
|
||||||
|
Insert,
|
||||||
|
Io,
|
||||||
|
Automation,
|
||||||
|
Pan,
|
||||||
|
// General
|
||||||
|
Add,
|
||||||
|
Remove,
|
||||||
|
Close,
|
||||||
|
Settings,
|
||||||
|
Search,
|
||||||
|
Undo,
|
||||||
|
Redo,
|
||||||
|
Cut,
|
||||||
|
Copy,
|
||||||
|
Paste,
|
||||||
|
Folder,
|
||||||
|
Save,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct IconSet {
|
||||||
|
unfilled: HashMap<Icon, svg::Handle>,
|
||||||
|
filled: HashMap<Icon, svg::Handle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_filled(bytes: &[u8]) -> Vec<u8> {
|
||||||
|
let s = String::from_utf8_lossy(bytes);
|
||||||
|
s.replace(
|
||||||
|
r#"class="fillable" fill="none""#,
|
||||||
|
r#"class="fillable" fill="currentColor""#,
|
||||||
|
)
|
||||||
|
.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IconSet {
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let entries: &[(Icon, &[u8])] = &[
|
||||||
|
// Transport
|
||||||
|
(Icon::Play, include_bytes!("../../assets/icons/play.svg")),
|
||||||
|
(Icon::Stop, include_bytes!("../../assets/icons/stop.svg")),
|
||||||
|
(Icon::Record, include_bytes!("../../assets/icons/record.svg")),
|
||||||
|
(Icon::Rewind, include_bytes!("../../assets/icons/rewind.svg")),
|
||||||
|
(
|
||||||
|
Icon::FastForward,
|
||||||
|
include_bytes!("../../assets/icons/fast-forward.svg"),
|
||||||
|
),
|
||||||
|
(Icon::Pause, include_bytes!("../../assets/icons/pause.svg")),
|
||||||
|
(
|
||||||
|
Icon::ReturnToZero,
|
||||||
|
include_bytes!("../../assets/icons/rtz.svg"),
|
||||||
|
),
|
||||||
|
// Mode toggles
|
||||||
|
(Icon::Cycle, include_bytes!("../../assets/icons/cycle.svg")),
|
||||||
|
(
|
||||||
|
Icon::Metronome,
|
||||||
|
include_bytes!("../../assets/icons/metronome.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::CountIn,
|
||||||
|
include_bytes!("../../assets/icons/count-in.svg"),
|
||||||
|
),
|
||||||
|
// Tools
|
||||||
|
(
|
||||||
|
Icon::ToolPointer,
|
||||||
|
include_bytes!("../../assets/icons/tool-pointer.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::ToolPencil,
|
||||||
|
include_bytes!("../../assets/icons/tool-pencil.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::ToolEraser,
|
||||||
|
include_bytes!("../../assets/icons/tool-eraser.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::ToolScissors,
|
||||||
|
include_bytes!("../../assets/icons/tool-scissors.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::ToolGlue,
|
||||||
|
include_bytes!("../../assets/icons/tool-glue.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::ToolZoom,
|
||||||
|
include_bytes!("../../assets/icons/tool-zoom.svg"),
|
||||||
|
),
|
||||||
|
// Track controls
|
||||||
|
(Icon::Mute, include_bytes!("../../assets/icons/mute.svg")),
|
||||||
|
(Icon::Solo, include_bytes!("../../assets/icons/solo.svg")),
|
||||||
|
(
|
||||||
|
Icon::RecordArm,
|
||||||
|
include_bytes!("../../assets/icons/record-arm.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::InputMonitor,
|
||||||
|
include_bytes!("../../assets/icons/input-monitor.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::Freeze,
|
||||||
|
include_bytes!("../../assets/icons/freeze.svg"),
|
||||||
|
),
|
||||||
|
(Icon::Lock, include_bytes!("../../assets/icons/lock.svg")),
|
||||||
|
// Track types
|
||||||
|
(
|
||||||
|
Icon::TrackAudio,
|
||||||
|
include_bytes!("../../assets/icons/track-audio.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::TrackMidi,
|
||||||
|
include_bytes!("../../assets/icons/track-midi.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::TrackAux,
|
||||||
|
include_bytes!("../../assets/icons/track-aux.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::TrackBus,
|
||||||
|
include_bytes!("../../assets/icons/track-bus.svg"),
|
||||||
|
),
|
||||||
|
// View toggles
|
||||||
|
(
|
||||||
|
Icon::ViewInspector,
|
||||||
|
include_bytes!("../../assets/icons/view-inspector.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::ViewEditor,
|
||||||
|
include_bytes!("../../assets/icons/view-editor.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::ViewMixer,
|
||||||
|
include_bytes!("../../assets/icons/view-mixer.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::ViewLibrary,
|
||||||
|
include_bytes!("../../assets/icons/view-library.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::ViewToolbar,
|
||||||
|
include_bytes!("../../assets/icons/view-toolbar.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::ViewNotepad,
|
||||||
|
include_bytes!("../../assets/icons/view-notepad.svg"),
|
||||||
|
),
|
||||||
|
// Mixer
|
||||||
|
(Icon::Eq, include_bytes!("../../assets/icons/eq.svg")),
|
||||||
|
(Icon::Send, include_bytes!("../../assets/icons/send.svg")),
|
||||||
|
(
|
||||||
|
Icon::Insert,
|
||||||
|
include_bytes!("../../assets/icons/insert.svg"),
|
||||||
|
),
|
||||||
|
(Icon::Io, include_bytes!("../../assets/icons/io.svg")),
|
||||||
|
(
|
||||||
|
Icon::Automation,
|
||||||
|
include_bytes!("../../assets/icons/automation.svg"),
|
||||||
|
),
|
||||||
|
(Icon::Pan, include_bytes!("../../assets/icons/pan.svg")),
|
||||||
|
// General
|
||||||
|
(Icon::Add, include_bytes!("../../assets/icons/add.svg")),
|
||||||
|
(
|
||||||
|
Icon::Remove,
|
||||||
|
include_bytes!("../../assets/icons/remove.svg"),
|
||||||
|
),
|
||||||
|
(Icon::Close, include_bytes!("../../assets/icons/close.svg")),
|
||||||
|
(
|
||||||
|
Icon::Settings,
|
||||||
|
include_bytes!("../../assets/icons/settings.svg"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Icon::Search,
|
||||||
|
include_bytes!("../../assets/icons/search.svg"),
|
||||||
|
),
|
||||||
|
(Icon::Undo, include_bytes!("../../assets/icons/undo.svg")),
|
||||||
|
(Icon::Redo, include_bytes!("../../assets/icons/redo.svg")),
|
||||||
|
(Icon::Cut, include_bytes!("../../assets/icons/cut.svg")),
|
||||||
|
(Icon::Copy, include_bytes!("../../assets/icons/copy.svg")),
|
||||||
|
(Icon::Paste, include_bytes!("../../assets/icons/paste.svg")),
|
||||||
|
(
|
||||||
|
Icon::Folder,
|
||||||
|
include_bytes!("../../assets/icons/folder.svg"),
|
||||||
|
),
|
||||||
|
(Icon::Save, include_bytes!("../../assets/icons/save.svg")),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut unfilled = HashMap::with_capacity(entries.len());
|
||||||
|
let mut filled = HashMap::with_capacity(entries.len());
|
||||||
|
|
||||||
|
for &(icon, bytes) in entries {
|
||||||
|
unfilled.insert(icon, svg::Handle::from_memory(bytes));
|
||||||
|
filled.insert(icon, svg::Handle::from_memory(make_filled(bytes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { unfilled, filled }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, icon: Icon) -> (&svg::Handle, &svg::Handle) {
|
||||||
|
(&self.unfilled[&icon], &self.filled[&icon])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
pub mod editor;
|
||||||
|
pub mod first_run_wizard;
|
||||||
|
pub mod icon_button;
|
||||||
|
pub mod icons;
|
||||||
|
pub mod new_project;
|
||||||
|
pub mod project_viewer;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod time_utility;
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
use crate::config::ProjectConfig;
|
||||||
|
use crate::entry::Message;
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
use iced::widget::{button, column, container, pick_list, row, slider, text, text_input};
|
||||||
|
use iced::{Alignment, Element};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct State {
|
||||||
|
pub config: ProjectConfig,
|
||||||
|
pub available_devices: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for State {
|
||||||
|
fn default() -> Self {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let devices = host
|
||||||
|
.output_devices()
|
||||||
|
.ok()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|d| d.name().ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config: ProjectConfig {
|
||||||
|
name: "New Project".to_string(),
|
||||||
|
sample_rate: 48000,
|
||||||
|
buffer_size: 512,
|
||||||
|
audio_device: "Default".to_string(),
|
||||||
|
audio_input_device: "Default".to_string(),
|
||||||
|
tempo: 120.0,
|
||||||
|
time_signature_numerator: 4,
|
||||||
|
time_signature_denominator: 4,
|
||||||
|
},
|
||||||
|
available_devices: devices,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view(state: &State) -> Element<'static, Message> {
|
||||||
|
let config = &state.config;
|
||||||
|
|
||||||
|
let sample_rates = vec![44100, 48000, 96000];
|
||||||
|
let buffer_sizes = vec![256, 512, 1024];
|
||||||
|
let available_devices: Vec<String> = state.available_devices.iter().cloned().collect();
|
||||||
|
|
||||||
|
let controls = column![
|
||||||
|
row![
|
||||||
|
text("Project Name:").width(150),
|
||||||
|
text_input("My Awesome Track", &config.name).on_input(Message::ProjectNameChanged)
|
||||||
|
]
|
||||||
|
.spacing(10),
|
||||||
|
row![
|
||||||
|
text("Sample Rate:").width(150),
|
||||||
|
pick_list(
|
||||||
|
sample_rates,
|
||||||
|
Some(config.sample_rate),
|
||||||
|
Message::SampleRateSelected
|
||||||
|
)
|
||||||
|
]
|
||||||
|
.spacing(10),
|
||||||
|
row![
|
||||||
|
text("Buffer Size:").width(150),
|
||||||
|
pick_list(
|
||||||
|
buffer_sizes,
|
||||||
|
Some(config.buffer_size),
|
||||||
|
Message::BufferSizeSelected
|
||||||
|
)
|
||||||
|
]
|
||||||
|
.spacing(10),
|
||||||
|
row![
|
||||||
|
text("Audio Device:").width(150),
|
||||||
|
pick_list(
|
||||||
|
available_devices,
|
||||||
|
Some(config.audio_device.clone()),
|
||||||
|
Message::AudioDeviceSelected
|
||||||
|
)
|
||||||
|
]
|
||||||
|
.spacing(10),
|
||||||
|
row![
|
||||||
|
text("Time Signature:").width(150),
|
||||||
|
text_input("4", &config.time_signature_numerator.to_string())
|
||||||
|
.on_input(Message::TimeSignatureNumeratorChanged)
|
||||||
|
.width(50),
|
||||||
|
text("/").width(20).align_x(Alignment::Center),
|
||||||
|
text_input("4", &config.time_signature_denominator.to_string())
|
||||||
|
.on_input(Message::TimeSignatureDenominatorChanged)
|
||||||
|
.width(50),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
row![
|
||||||
|
text("Tempo (BPM):").width(150),
|
||||||
|
slider(40.0..=240.0, config.tempo, Message::TempoChanged).step(0.1),
|
||||||
|
text(format!("{:.1}", config.tempo))
|
||||||
|
]
|
||||||
|
.spacing(10),
|
||||||
|
// --- New "Time Utility" button ---
|
||||||
|
container(row![
|
||||||
|
button("Time Utility").on_press(Message::ViewTimeUtility)
|
||||||
|
])
|
||||||
|
.align_x(Alignment::End),
|
||||||
|
]
|
||||||
|
.spacing(10);
|
||||||
|
|
||||||
|
column![
|
||||||
|
text("Create New Project").size(30),
|
||||||
|
controls,
|
||||||
|
row![
|
||||||
|
button("Create Project").on_press(Message::CreateProject),
|
||||||
|
button("Cancel").on_press(Message::ViewRecentProjects)
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
]
|
||||||
|
.spacing(20)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
// File: audio-oxide/src/gui/project_viewer.rs
|
||||||
|
|
||||||
|
use crate::entry::{Message, ProjectInfo, ProjectViewState};
|
||||||
|
use iced::widget::{button, column, container, row, scrollable, stack, text, text_input};
|
||||||
|
use iced::{alignment, Alignment, Background, Border, Color, Element, Length, Padding, Theme};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
// Helper function for the main navigation button style
|
||||||
|
fn nav_button_style(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
|
let mut style = button::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgba(0.1, 0.1, 0.1, 0.5))),
|
||||||
|
border: Border {
|
||||||
|
radius: 8.0.into(),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
text_color: theme.palette().text,
|
||||||
|
..button::Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let button::Status::Hovered = status {
|
||||||
|
style.background = Some(Background::Color(
|
||||||
|
theme.extended_palette().primary.weak.color,
|
||||||
|
));
|
||||||
|
style.text_color = theme.extended_palette().primary.weak.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
style
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for the project list item style
|
||||||
|
fn project_list_item_style(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
|
let mut style = button::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgba(1.0, 1.0, 1.0, 0.05))),
|
||||||
|
border: Border {
|
||||||
|
radius: 4.0.into(),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
..button::Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let button::Status::Hovered = status {
|
||||||
|
style.background = Some(Background::Color(
|
||||||
|
theme.extended_palette().primary.weak.color,
|
||||||
|
));
|
||||||
|
style.text_color = theme.extended_palette().primary.weak.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
style
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view<'a>(state: &'a ProjectViewState) -> Element<'a, Message> {
|
||||||
|
let main_content = container(match state {
|
||||||
|
ProjectViewState::Recent { projects } => view_recent_projects(projects),
|
||||||
|
ProjectViewState::Find { path_input } => view_find_project(path_input),
|
||||||
|
})
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.padding(Padding {
|
||||||
|
top: 30.0,
|
||||||
|
right: 30.0,
|
||||||
|
bottom: 30.0,
|
||||||
|
left: 300.0, // This padding pushes the content to the right.
|
||||||
|
});
|
||||||
|
|
||||||
|
let nav_panel = {
|
||||||
|
let recent_button = button(text("Recent Projects").size(24))
|
||||||
|
.on_press(Message::ViewRecentProjects)
|
||||||
|
.style(nav_button_style)
|
||||||
|
.padding(20);
|
||||||
|
|
||||||
|
let find_button = button(text("Find Project").size(24))
|
||||||
|
.on_press(Message::ViewFindProject)
|
||||||
|
.style(nav_button_style)
|
||||||
|
.padding(20);
|
||||||
|
|
||||||
|
let new_button = button(text("New Project...").size(24))
|
||||||
|
.on_press(Message::ViewNewProject)
|
||||||
|
.style(nav_button_style)
|
||||||
|
.padding(20);
|
||||||
|
|
||||||
|
container(
|
||||||
|
column![
|
||||||
|
container(recent_button).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 0.0 }),
|
||||||
|
container(find_button).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 40.0 }),
|
||||||
|
container(new_button).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 80.0 }),
|
||||||
|
]
|
||||||
|
.spacing(15)
|
||||||
|
.align_x(Alignment::Start),
|
||||||
|
)
|
||||||
|
.padding(80)
|
||||||
|
};
|
||||||
|
|
||||||
|
stack![main_content, nav_panel].into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_recent_projects<'a>(projects: &'a [ProjectInfo]) -> Element<'a, Message> {
|
||||||
|
let title = text("Recent Projects").size(40);
|
||||||
|
|
||||||
|
if projects.is_empty() {
|
||||||
|
return column![
|
||||||
|
title,
|
||||||
|
text("No projects found. Create one to get started!")
|
||||||
|
]
|
||||||
|
.spacing(20)
|
||||||
|
.padding(20)
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_list = projects.iter().fold(column!().spacing(10), |col, p| {
|
||||||
|
let project_entry = button(
|
||||||
|
row![
|
||||||
|
text(&p.name).size(20).width(Length::Fill),
|
||||||
|
text(format!("Modified: {}", p.modified.format("%Y-%m-%d %H:%M"))),
|
||||||
|
]
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.padding(15),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_press(Message::OpenProject(p.path.clone()))
|
||||||
|
.style(project_list_item_style);
|
||||||
|
|
||||||
|
col.push(project_entry)
|
||||||
|
});
|
||||||
|
|
||||||
|
column![title, scrollable(project_list)]
|
||||||
|
.spacing(30)
|
||||||
|
.padding(20)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_find_project<'a>(path_input: &'a str) -> Element<'a, Message> {
|
||||||
|
let title = text("Find Project").size(40);
|
||||||
|
let path_input_field = text_input("Enter path to project .xtc directory...", path_input)
|
||||||
|
.on_input(Message::FindPathChanged)
|
||||||
|
.padding(10)
|
||||||
|
.size(20);
|
||||||
|
|
||||||
|
let open_button = button("Open Project")
|
||||||
|
.on_press(Message::OpenProject(PathBuf::from(path_input.to_string())))
|
||||||
|
.padding(10);
|
||||||
|
|
||||||
|
column![
|
||||||
|
title,
|
||||||
|
text("Enter the full path to a project directory to open it."),
|
||||||
|
path_input_field,
|
||||||
|
container(open_button)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.align_x(alignment::Horizontal::Right),
|
||||||
|
]
|
||||||
|
.spacing(20)
|
||||||
|
.padding(20)
|
||||||
|
.max_width(600)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
use crate::config::{AudioOxideConfig, RecordingFormat};
|
||||||
|
use iced::widget::{
|
||||||
|
button, column, container, horizontal_rule, pick_list, row, text, text_input, toggler, Space,
|
||||||
|
};
|
||||||
|
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SettingsTab {
|
||||||
|
General,
|
||||||
|
Audio,
|
||||||
|
Display,
|
||||||
|
Advanced,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsTab {
|
||||||
|
pub const ALL: [SettingsTab; 4] = [
|
||||||
|
SettingsTab::General,
|
||||||
|
SettingsTab::Audio,
|
||||||
|
SettingsTab::Display,
|
||||||
|
SettingsTab::Advanced,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SettingsTab::General => "General",
|
||||||
|
SettingsTab::Audio => "Audio",
|
||||||
|
SettingsTab::Display => "Display",
|
||||||
|
SettingsTab::Advanced => "Advanced",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct State {
|
||||||
|
pub config: AudioOxideConfig,
|
||||||
|
pub active_tab: SettingsTab,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub fn new(config: &AudioOxideConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config: config.clone(),
|
||||||
|
active_tab: SettingsTab::General,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
TabSelected(SettingsTab),
|
||||||
|
// General
|
||||||
|
ProjectDirChanged(String),
|
||||||
|
AutoSaveToggled(bool),
|
||||||
|
AutoSaveIntervalChanged(String),
|
||||||
|
AskToSaveToggled(bool),
|
||||||
|
// Audio
|
||||||
|
DefaultSampleRateSelected(u32),
|
||||||
|
DefaultBufferSizeSelected(u32),
|
||||||
|
DefaultAudioDeviceChanged(String),
|
||||||
|
RecordingFormatSelected(RecordingFormat),
|
||||||
|
RecordingBitDepthSelected(u16),
|
||||||
|
// Display
|
||||||
|
DefaultTrackHeightChanged(String),
|
||||||
|
ShowToolbarOnOpenToggled(bool),
|
||||||
|
ShowInspectorOnOpenToggled(bool),
|
||||||
|
// Actions
|
||||||
|
Save,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn section_header(label: &str) -> Element<'_, Message> {
|
||||||
|
column![
|
||||||
|
text(label)
|
||||||
|
.size(13)
|
||||||
|
.color(Color::from_rgb8(0x88, 0x88, 0x88)),
|
||||||
|
horizontal_rule(1),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setting_row<'a>(
|
||||||
|
label: &'a str,
|
||||||
|
control: Element<'a, Message>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
row![
|
||||||
|
text(label).size(13).width(200),
|
||||||
|
control,
|
||||||
|
]
|
||||||
|
.spacing(12)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tab_button<'a>(tab: SettingsTab, active: SettingsTab) -> Element<'a, Message> {
|
||||||
|
let is_active = tab == active;
|
||||||
|
let btn = button(text(tab.label()).size(13))
|
||||||
|
.on_press(Message::TabSelected(tab))
|
||||||
|
.padding([6, 16]);
|
||||||
|
if is_active {
|
||||||
|
btn.style(|_theme: &Theme, _status| button::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x00, 0x7A, 0xFF))),
|
||||||
|
text_color: Color::WHITE,
|
||||||
|
border: Border {
|
||||||
|
radius: 4.0.into(),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
..button::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
btn.style(|_theme: &Theme, _status| button::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x30, 0x32, 0x34))),
|
||||||
|
text_color: Color::from_rgb8(0xAA, 0xAA, 0xAA),
|
||||||
|
border: Border {
|
||||||
|
radius: 4.0.into(),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
..button::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_general(config: &AudioOxideConfig) -> Element<'_, Message> {
|
||||||
|
let project_dir_input = text_input(
|
||||||
|
"Project directory",
|
||||||
|
config.project_dir.to_str().unwrap_or(""),
|
||||||
|
)
|
||||||
|
.on_input(Message::ProjectDirChanged)
|
||||||
|
.width(300);
|
||||||
|
|
||||||
|
let auto_save_toggle = toggler(config.auto_save)
|
||||||
|
.on_toggle(Message::AutoSaveToggled)
|
||||||
|
.width(Length::Shrink);
|
||||||
|
|
||||||
|
let auto_save_interval = text_input(
|
||||||
|
"seconds",
|
||||||
|
&config.auto_save_interval_secs.to_string(),
|
||||||
|
)
|
||||||
|
.on_input(Message::AutoSaveIntervalChanged)
|
||||||
|
.width(80);
|
||||||
|
|
||||||
|
let ask_save_toggle = toggler(config.ask_to_save_on_close)
|
||||||
|
.on_toggle(Message::AskToSaveToggled)
|
||||||
|
.width(Length::Shrink);
|
||||||
|
|
||||||
|
column![
|
||||||
|
section_header("Project Handling"),
|
||||||
|
setting_row("Default project directory", project_dir_input.into()),
|
||||||
|
setting_row("Auto-save", auto_save_toggle.into()),
|
||||||
|
setting_row("Auto-save interval (sec)", auto_save_interval.into()),
|
||||||
|
setting_row("Ask to save on close", ask_save_toggle.into()),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.padding(16)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_audio(config: &AudioOxideConfig) -> Element<'_, Message> {
|
||||||
|
let sample_rates: Vec<u32> = vec![22050, 44100, 48000, 88200, 96000, 176400, 192000];
|
||||||
|
let sr_picker = pick_list(
|
||||||
|
sample_rates,
|
||||||
|
Some(config.default_sample_rate),
|
||||||
|
Message::DefaultSampleRateSelected,
|
||||||
|
)
|
||||||
|
.width(120);
|
||||||
|
|
||||||
|
let buffer_sizes: Vec<u32> = vec![32, 64, 128, 256, 512, 1024, 2048, 4096];
|
||||||
|
let bs_picker = pick_list(
|
||||||
|
buffer_sizes,
|
||||||
|
Some(config.default_buffer_size),
|
||||||
|
Message::DefaultBufferSizeSelected,
|
||||||
|
)
|
||||||
|
.width(120);
|
||||||
|
|
||||||
|
let device_input = text_input("Audio device", &config.default_audio_device)
|
||||||
|
.on_input(Message::DefaultAudioDeviceChanged)
|
||||||
|
.width(200);
|
||||||
|
|
||||||
|
let format_picker = pick_list(
|
||||||
|
&RecordingFormat::ALL[..],
|
||||||
|
Some(config.recording_format),
|
||||||
|
Message::RecordingFormatSelected,
|
||||||
|
)
|
||||||
|
.width(120);
|
||||||
|
|
||||||
|
let bit_depths: Vec<u16> = vec![16, 24, 32];
|
||||||
|
let bd_picker = pick_list(
|
||||||
|
bit_depths,
|
||||||
|
Some(config.recording_bit_depth),
|
||||||
|
Message::RecordingBitDepthSelected,
|
||||||
|
)
|
||||||
|
.width(120);
|
||||||
|
|
||||||
|
column![
|
||||||
|
section_header("Devices"),
|
||||||
|
setting_row("Default audio device", device_input.into()),
|
||||||
|
setting_row("Default sample rate", sr_picker.into()),
|
||||||
|
setting_row("Default buffer size", bs_picker.into()),
|
||||||
|
section_header("Recording"),
|
||||||
|
setting_row("File format", format_picker.into()),
|
||||||
|
setting_row("Bit depth", bd_picker.into()),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.padding(16)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_display(config: &AudioOxideConfig) -> Element<'_, Message> {
|
||||||
|
let track_height_input = text_input("pixels", &format!("{:.0}", config.default_track_height))
|
||||||
|
.on_input(Message::DefaultTrackHeightChanged)
|
||||||
|
.width(80);
|
||||||
|
|
||||||
|
let toolbar_toggle = toggler(config.show_toolbar_on_open)
|
||||||
|
.on_toggle(Message::ShowToolbarOnOpenToggled)
|
||||||
|
.width(Length::Shrink);
|
||||||
|
|
||||||
|
let inspector_toggle = toggler(config.show_inspector_on_open)
|
||||||
|
.on_toggle(Message::ShowInspectorOnOpenToggled)
|
||||||
|
.width(Length::Shrink);
|
||||||
|
|
||||||
|
column![
|
||||||
|
section_header("Layout"),
|
||||||
|
setting_row("Default track height (px)", track_height_input.into()),
|
||||||
|
setting_row("Show toolbar on open", toolbar_toggle.into()),
|
||||||
|
setting_row("Show inspector on open", inspector_toggle.into()),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.padding(16)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_advanced(_config: &AudioOxideConfig) -> Element<'_, Message> {
|
||||||
|
column![
|
||||||
|
section_header("Engine"),
|
||||||
|
text("Engine configuration options will appear here.")
|
||||||
|
.size(12)
|
||||||
|
.color(Color::from_rgb8(0x66, 0x66, 0x66)),
|
||||||
|
section_header("Plugins"),
|
||||||
|
text("Module/plugin scan paths and loading options will appear here.")
|
||||||
|
.size(12)
|
||||||
|
.color(Color::from_rgb8(0x66, 0x66, 0x66)),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.padding(16)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view(state: &State) -> Element<'_, Message> {
|
||||||
|
let title = text("Settings").size(20);
|
||||||
|
|
||||||
|
// Tab bar
|
||||||
|
let tabs = SettingsTab::ALL.iter().fold(
|
||||||
|
row![].spacing(4),
|
||||||
|
|r, tab| r.push(tab_button(*tab, state.active_tab)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tab content
|
||||||
|
let content: Element<_> = match state.active_tab {
|
||||||
|
SettingsTab::General => view_general(&state.config),
|
||||||
|
SettingsTab::Audio => view_audio(&state.config),
|
||||||
|
SettingsTab::Display => view_display(&state.config),
|
||||||
|
SettingsTab::Advanced => view_advanced(&state.config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_area = container(content)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x22, 0x24, 0x26))),
|
||||||
|
border: Border {
|
||||||
|
radius: 4.0.into(),
|
||||||
|
color: Color::from_rgb8(0x35, 0x37, 0x39),
|
||||||
|
width: 1.0,
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
let cancel_btn = button(text("Cancel").size(13)).on_press(Message::Cancel);
|
||||||
|
let save_btn = button(text("Save").size(13))
|
||||||
|
.on_press(Message::Save)
|
||||||
|
.style(|_theme: &Theme, _status| button::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb8(0x00, 0x7A, 0xFF))),
|
||||||
|
text_color: Color::WHITE,
|
||||||
|
border: Border {
|
||||||
|
radius: 4.0.into(),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
..button::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let actions = row![Space::new(Length::Fill, 0), cancel_btn, save_btn]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
|
let dialog = column![title, tabs, content_area, actions]
|
||||||
|
.spacing(12)
|
||||||
|
.padding(20);
|
||||||
|
|
||||||
|
container(dialog)
|
||||||
|
.max_width(600)
|
||||||
|
.max_height(500)
|
||||||
|
.style(|theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(
|
||||||
|
theme.extended_palette().background.weak.color,
|
||||||
|
)),
|
||||||
|
border: Border {
|
||||||
|
radius: 8.0.into(),
|
||||||
|
color: Color::from_rgb8(0x40, 0x42, 0x44),
|
||||||
|
width: 1.0,
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_message(state: &mut State, message: Message) -> bool {
|
||||||
|
match message {
|
||||||
|
Message::TabSelected(tab) => state.active_tab = tab,
|
||||||
|
Message::ProjectDirChanged(p) => state.config.project_dir = p.into(),
|
||||||
|
Message::AutoSaveToggled(v) => state.config.auto_save = v,
|
||||||
|
Message::AutoSaveIntervalChanged(s) => {
|
||||||
|
if let Ok(v) = s.parse::<u32>() {
|
||||||
|
state.config.auto_save_interval_secs = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::AskToSaveToggled(v) => state.config.ask_to_save_on_close = v,
|
||||||
|
Message::DefaultSampleRateSelected(sr) => state.config.default_sample_rate = sr,
|
||||||
|
Message::DefaultBufferSizeSelected(bs) => state.config.default_buffer_size = bs,
|
||||||
|
Message::DefaultAudioDeviceChanged(d) => state.config.default_audio_device = d,
|
||||||
|
Message::RecordingFormatSelected(f) => state.config.recording_format = f,
|
||||||
|
Message::RecordingBitDepthSelected(bd) => state.config.recording_bit_depth = bd,
|
||||||
|
Message::DefaultTrackHeightChanged(s) => {
|
||||||
|
if let Ok(v) = s.parse::<f32>() {
|
||||||
|
if v > 0.0 {
|
||||||
|
state.config.default_track_height = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::ShowToolbarOnOpenToggled(v) => state.config.show_toolbar_on_open = v,
|
||||||
|
Message::ShowInspectorOnOpenToggled(v) => state.config.show_inspector_on_open = v,
|
||||||
|
Message::Save => return true,
|
||||||
|
Message::Cancel => return true,
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,365 @@
|
||||||
|
use crate::entry::Message;
|
||||||
|
use iced::widget::{button, column, mouse_area, row, text};
|
||||||
|
use iced::{Alignment, Element, Length, Task};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
// --- State for the Time Utility ---
|
||||||
|
|
||||||
|
/// Represents a single tap event, capturing its start and end time.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct TapEvent {
|
||||||
|
pub start_time: Instant,
|
||||||
|
pub end_time: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the results of the tempo analysis.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AnalysisResult {
|
||||||
|
pub bpm: u32,
|
||||||
|
pub grid_name: String,
|
||||||
|
pub grid_fraction: String,
|
||||||
|
pub suggested_time_signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contains all state related to the tempo tapping feature.
|
||||||
|
#[derive(Default, Debug, Clone)]
|
||||||
|
pub struct State {
|
||||||
|
pub tap_events: Vec<TapEvent>,
|
||||||
|
pub tap_start_time: Option<Instant>,
|
||||||
|
pub result: Option<AnalysisResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the primary time signature from a suggestion string like "6/8 or 3/4".
|
||||||
|
fn get_primary_time_signature(suggestion: &str) -> String {
|
||||||
|
suggestion.split_whitespace().next().unwrap_or("").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- View for the Time Utility ---
|
||||||
|
|
||||||
|
pub fn view(state: &State) -> Element<'static, Message> {
|
||||||
|
let tapper_button = mouse_area(
|
||||||
|
button(
|
||||||
|
text("Tap and Hold Rhythm Here")
|
||||||
|
.width(Length::Fill)
|
||||||
|
.align_x(Alignment::Center),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding(15),
|
||||||
|
)
|
||||||
|
.on_press(Message::TimeUtilityTapPressed)
|
||||||
|
.on_release(Message::TimeUtilityTapReleased);
|
||||||
|
|
||||||
|
let (result_display, action_buttons) = if let Some(result) = &state.result {
|
||||||
|
let display: Element<_> = column![
|
||||||
|
text(format!("Detected Tempo: {} BPM", result.bpm)).size(24),
|
||||||
|
text(format!(
|
||||||
|
"Rhythmic Grid: {} ({})",
|
||||||
|
result.grid_name, result.grid_fraction
|
||||||
|
))
|
||||||
|
.size(16),
|
||||||
|
text(format!(
|
||||||
|
"Suggested Time Signature: {}",
|
||||||
|
result.suggested_time_signature
|
||||||
|
))
|
||||||
|
.size(16),
|
||||||
|
]
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.spacing(10)
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let primary_sig = get_primary_time_signature(&result.suggested_time_signature);
|
||||||
|
|
||||||
|
let set_tempo_button = button("Set Tempo").on_press(Message::TimeUtilitySet(result.bpm));
|
||||||
|
let set_sig_button = button("Set Time Sig")
|
||||||
|
.on_press(Message::TimeUtilitySetTimeSignature(primary_sig.clone()));
|
||||||
|
let set_both_button =
|
||||||
|
button("Set Both").on_press(Message::TimeUtilitySetBoth(result.bpm, primary_sig));
|
||||||
|
let cancel_button = button("Cancel").on_press(Message::TimeUtilityCancel);
|
||||||
|
|
||||||
|
let buttons: Element<_> = row![
|
||||||
|
set_tempo_button,
|
||||||
|
set_sig_button,
|
||||||
|
set_both_button,
|
||||||
|
cancel_button
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.into();
|
||||||
|
|
||||||
|
(display, buttons)
|
||||||
|
} else {
|
||||||
|
let message = {
|
||||||
|
let tap_count = state.tap_events.len();
|
||||||
|
if tap_count < 3 {
|
||||||
|
format!("Tap and hold at least {} more time(s)...", 3 - tap_count)
|
||||||
|
} else {
|
||||||
|
"Tap again to refine...".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let display: Element<_> = column![text(message).size(16)]
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.spacing(10)
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let cancel_button = button("Cancel").on_press(Message::TimeUtilityCancel);
|
||||||
|
let buttons: Element<_> = row![cancel_button].spacing(10).into();
|
||||||
|
|
||||||
|
(display, buttons)
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = column![
|
||||||
|
text("Time Utility").size(40),
|
||||||
|
text("Tap a rhythm to detect its tempo and feel.").size(20),
|
||||||
|
tapper_button,
|
||||||
|
result_display,
|
||||||
|
action_buttons,
|
||||||
|
]
|
||||||
|
.spacing(25)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.max_width(500);
|
||||||
|
|
||||||
|
content.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Time Utility Logic & Event Handlers ---
|
||||||
|
|
||||||
|
/// Helper function to handle the logic for a tap press.
|
||||||
|
pub fn handle_tap_pressed(tapper_state: &mut State) {
|
||||||
|
if tapper_state.tap_start_time.is_none() {
|
||||||
|
tapper_state.tap_start_time = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to handle the logic for a tap release.
|
||||||
|
pub fn handle_tap_released(tapper_state: &mut State) -> Task<Message> {
|
||||||
|
if let Some(start_time) = tapper_state.tap_start_time.take() {
|
||||||
|
let event = TapEvent {
|
||||||
|
start_time,
|
||||||
|
end_time: Instant::now(),
|
||||||
|
};
|
||||||
|
tapper_state.tap_events.push(event);
|
||||||
|
return Task::perform(sleep(Duration::from_millis(350)), |_| {
|
||||||
|
Message::RunTimeUtilityAnalysis
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Full, Correct Tempo Analysis Logic ---
|
||||||
|
|
||||||
|
const MIN_TAPS_FOR_ANALYSIS: usize = 3;
|
||||||
|
const TOLERANCE_FACTOR: f64 = 0.15;
|
||||||
|
const MIN_TOLERANCE_MS: f64 = 20.0;
|
||||||
|
const MAX_TOLERANCE_MS: f64 = 75.0;
|
||||||
|
const OFF_GRID_WEIGHT: f64 = 10.0;
|
||||||
|
const RESOLUTION_WEIGHT: f64 = 0.08;
|
||||||
|
const MAX_NUMERATOR: u32 = 64;
|
||||||
|
const MAX_DENOMINATOR: u32 = 256;
|
||||||
|
|
||||||
|
pub fn run_analysis(events: &[TapEvent]) -> Option<AnalysisResult> {
|
||||||
|
if events.len() < MIN_TAPS_FOR_ANALYSIS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tempo = calculate_tempo(events)?;
|
||||||
|
let durations_ms = get_durations_for_analysis(events);
|
||||||
|
if durations_ms.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (num, den, name) = determine_base_subdivision(&durations_ms, tempo as f64)?;
|
||||||
|
|
||||||
|
let suggested_sig = suggest_time_signature(num, den);
|
||||||
|
|
||||||
|
Some(AnalysisResult {
|
||||||
|
bpm: tempo,
|
||||||
|
grid_name: name,
|
||||||
|
grid_fraction: format!("{}/{}", num, den),
|
||||||
|
suggested_time_signature: suggested_sig,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_tempo(events: &[TapEvent]) -> Option<u32> {
|
||||||
|
if events.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut intervals: Vec<f64> = events
|
||||||
|
.windows(2)
|
||||||
|
.map(|w| w[1].start_time.duration_since(w[0].start_time).as_millis() as f64)
|
||||||
|
.filter(|&i| i > 60.0 && i < 3500.0)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if intervals.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
intervals.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
let median_interval = intervals[intervals.len() / 2];
|
||||||
|
|
||||||
|
if median_interval <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((60_000.0 / median_interval).round() as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_durations_for_analysis(events: &[TapEvent]) -> Vec<f64> {
|
||||||
|
let mut durations = Vec::new();
|
||||||
|
for event in events {
|
||||||
|
let hold_duration = event.end_time.duration_since(event.start_time).as_millis() as f64;
|
||||||
|
if hold_duration > 15.0 {
|
||||||
|
durations.push(hold_duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for window in events.windows(2) {
|
||||||
|
let rest_duration = window[1]
|
||||||
|
.start_time
|
||||||
|
.duration_since(window[0].end_time)
|
||||||
|
.as_millis() as f64;
|
||||||
|
if rest_duration > 15.0 {
|
||||||
|
durations.push(rest_duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
durations
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gcd(a: u32, b: u32) -> u32 {
|
||||||
|
if b == 0 {
|
||||||
|
a
|
||||||
|
} else {
|
||||||
|
gcd(b, a % b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_subdivision_name(num: u32, den: u32) -> String {
|
||||||
|
match (num, den) {
|
||||||
|
(1, 1) => "Quarter Note".to_string(),
|
||||||
|
(1, 2) => "8th Note".to_string(),
|
||||||
|
(1, 3) => "Triplet Quarter".to_string(),
|
||||||
|
(1, 4) => "16th Note".to_string(),
|
||||||
|
(1, 5) => "Quintuplet 8th".to_string(),
|
||||||
|
(1, 6) => "Triplet 8th".to_string(),
|
||||||
|
(1, 7) => "Septuplet 8th".to_string(),
|
||||||
|
(1, 8) => "32nd Note".to_string(),
|
||||||
|
(1, 12) => "Triplet 16th".to_string(),
|
||||||
|
(1, 16) => "64th Note".to_string(),
|
||||||
|
(3, 2) => "Dotted Half".to_string(),
|
||||||
|
(3, 4) => "Dotted Quarter".to_string(),
|
||||||
|
(3, 8) => "Dotted 8th".to_string(),
|
||||||
|
(3, 16) => "Dotted 16th".to_string(),
|
||||||
|
_ => format!("{}/{} QN", num, den),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn suggest_time_signature(_numerator: u32, denominator: u32) -> String {
|
||||||
|
if denominator % 3 == 0 || denominator % 6 == 0 || denominator % 12 == 0 {
|
||||||
|
"6/8 or 3/4".to_string()
|
||||||
|
} else if denominator % 5 == 0 {
|
||||||
|
"5/4 or 5/8".to_string()
|
||||||
|
} else if denominator % 7 == 0 {
|
||||||
|
"7/4 or 7/8".to_string()
|
||||||
|
} else {
|
||||||
|
"4/4".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn determine_base_subdivision(
|
||||||
|
all_durations_ms: &[f64],
|
||||||
|
bpm: f64,
|
||||||
|
) -> Option<(u32, u32, String)> {
|
||||||
|
if all_durations_ms.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let min_duration_ms = all_durations_ms
|
||||||
|
.iter()
|
||||||
|
.filter(|&&d| d > 0.0)
|
||||||
|
.min_by(|a, b| a.partial_cmp(b).unwrap())
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(f64::MAX);
|
||||||
|
|
||||||
|
if min_duration_ms == f64::MAX {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let beat_ms = 60000.0 / bpm;
|
||||||
|
let mut best_score = f64::INFINITY;
|
||||||
|
let mut best_candidate = None;
|
||||||
|
|
||||||
|
for denominator in 1..=MAX_DENOMINATOR {
|
||||||
|
let step = if denominator > 128 {
|
||||||
|
8
|
||||||
|
} else if denominator > 64 {
|
||||||
|
4
|
||||||
|
} else if denominator > 32 {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
|
||||||
|
let common_denominators = [1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 24];
|
||||||
|
if denominator % step != 0 && !common_denominators.contains(&denominator) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for numerator in 1..=MAX_NUMERATOR {
|
||||||
|
let common_divisor = gcd(numerator, denominator);
|
||||||
|
let (simple_num, simple_den) = (numerator / common_divisor, denominator / common_divisor);
|
||||||
|
let qn_val = simple_num as f64 / simple_den as f64;
|
||||||
|
let base_note_ms = beat_ms * qn_val;
|
||||||
|
|
||||||
|
if base_note_ms <= 5.0 || !base_note_ms.is_finite() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tolerance = (base_note_ms * TOLERANCE_FACTOR)
|
||||||
|
.max(MIN_TOLERANCE_MS)
|
||||||
|
.min(MAX_TOLERANCE_MS);
|
||||||
|
|
||||||
|
if base_note_ms > min_duration_ms * 2.0 + tolerance {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if base_note_ms < tolerance / 2.0 && beat_ms < 400.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut off_grid = 0.0;
|
||||||
|
let mut total_error = 0.0;
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
for &d in all_durations_ms {
|
||||||
|
if d <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
let num_units = (d / base_note_ms).round();
|
||||||
|
if num_units < 1.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let quantized_duration = num_units * base_note_ms;
|
||||||
|
let error = (d - quantized_duration).abs();
|
||||||
|
if error > tolerance {
|
||||||
|
off_grid += 1.0;
|
||||||
|
}
|
||||||
|
total_error += error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let avg_error = total_error / count as f64;
|
||||||
|
let complexity_penalty = (1.0 / base_note_ms) * (simple_den as f64 / 8.0);
|
||||||
|
let score =
|
||||||
|
avg_error + (off_grid * OFF_GRID_WEIGHT) + (complexity_penalty * RESOLUTION_WEIGHT);
|
||||||
|
|
||||||
|
if score < best_score {
|
||||||
|
best_score = score;
|
||||||
|
best_candidate = Some((
|
||||||
|
simple_num,
|
||||||
|
simple_den,
|
||||||
|
get_subdivision_name(simple_num, simple_den),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best_candidate
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
// File: audio-oxide/src/main.rs
|
||||||
|
|
||||||
|
mod behaviors;
|
||||||
|
mod config;
|
||||||
|
mod editor;
|
||||||
|
mod engine;
|
||||||
|
mod entry;
|
||||||
|
mod first_run;
|
||||||
|
mod gui;
|
||||||
|
mod modules;
|
||||||
|
mod region;
|
||||||
|
mod timing;
|
||||||
|
mod track;
|
||||||
|
mod triggers;
|
||||||
|
|
||||||
|
pub fn main() -> iced::Result {
|
||||||
|
entry::main()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use oxforge::mdk::{GlobalConfig, OxideModule, Ports, ProcessContext};
|
||||||
|
use rustfft::num_complex::Complex;
|
||||||
|
use rustfft::{Fft, FftPlanner};
|
||||||
|
|
||||||
|
use super::AnalyticSignal;
|
||||||
|
|
||||||
|
const FFT_SIZE: usize = 2048;
|
||||||
|
|
||||||
|
pub struct ComplexStream {
|
||||||
|
fft_size: usize,
|
||||||
|
hop_size: usize,
|
||||||
|
history_l: Vec<f32>,
|
||||||
|
history_r: Vec<f32>,
|
||||||
|
forward: Arc<dyn Fft<f32>>,
|
||||||
|
inverse: Arc<dyn Fft<f32>>,
|
||||||
|
// Scratch buffers to avoid per-call allocation
|
||||||
|
fft_buf: Vec<Complex<f32>>,
|
||||||
|
ifft_buf: Vec<Complex<f32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OxideModule for ComplexStream {
|
||||||
|
fn new(_config: &GlobalConfig) -> Self {
|
||||||
|
let mut planner = FftPlanner::new();
|
||||||
|
let forward = planner.plan_fft_forward(FFT_SIZE);
|
||||||
|
let inverse = planner.plan_fft_inverse(FFT_SIZE);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
fft_size: FFT_SIZE,
|
||||||
|
hop_size: 0,
|
||||||
|
history_l: vec![0.0; FFT_SIZE],
|
||||||
|
history_r: vec![0.0; FFT_SIZE],
|
||||||
|
forward,
|
||||||
|
inverse,
|
||||||
|
fft_buf: vec![Complex::new(0.0, 0.0); FFT_SIZE],
|
||||||
|
ifft_buf: vec![Complex::new(0.0, 0.0); FFT_SIZE],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process(&mut self, ports: Ports, _context: &ProcessContext) {
|
||||||
|
let input = match ports.main_audio_in {
|
||||||
|
Some(ref ai) => ai.buffer(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deinterleave stereo into L/R
|
||||||
|
let frame_count = input.len() / 2;
|
||||||
|
if frame_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.hop_size == 0 {
|
||||||
|
self.hop_size = frame_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut left_hop = Vec::with_capacity(frame_count);
|
||||||
|
let mut right_hop = Vec::with_capacity(frame_count);
|
||||||
|
for i in 0..frame_count {
|
||||||
|
left_hop.push(input[i * 2]);
|
||||||
|
right_hop.push(input[i * 2 + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result_l = self.hilbert_channel(&left_hop, true);
|
||||||
|
let result_r = self.hilbert_channel(&right_hop, false);
|
||||||
|
|
||||||
|
let signal = AnalyticSignal {
|
||||||
|
left: result_l,
|
||||||
|
right: result_r,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send analytic signal via chain output
|
||||||
|
if let Some(mut chain_out) = ports.chain_out {
|
||||||
|
chain_out.send(signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passthrough audio
|
||||||
|
if let Some(mut audio_out) = ports.main_audio_out {
|
||||||
|
let out = audio_out.buffer_mut();
|
||||||
|
let len = out.len().min(input.len());
|
||||||
|
out[..len].copy_from_slice(&input[..len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComplexStream {
|
||||||
|
fn hilbert_channel(&mut self, hop: &[f32], is_left: bool) -> Vec<(f32, f32)> {
|
||||||
|
let history = if is_left {
|
||||||
|
&mut self.history_l
|
||||||
|
} else {
|
||||||
|
&mut self.history_r
|
||||||
|
};
|
||||||
|
let hop_size = hop.len();
|
||||||
|
|
||||||
|
// Shift history and append new samples
|
||||||
|
history.copy_within(hop_size.., 0);
|
||||||
|
history[self.fft_size - hop_size..].copy_from_slice(hop);
|
||||||
|
|
||||||
|
// Forward FFT: real input -> complex
|
||||||
|
for (i, &s) in history.iter().enumerate() {
|
||||||
|
self.fft_buf[i] = Complex::new(s, 0.0);
|
||||||
|
}
|
||||||
|
self.forward.process(&mut self.fft_buf);
|
||||||
|
|
||||||
|
// Hilbert filter in frequency domain
|
||||||
|
let n = self.fft_size;
|
||||||
|
let nyquist = n / 2;
|
||||||
|
// DC bin: multiply by 1 (unchanged)
|
||||||
|
// Positive frequencies (1..nyquist): multiply by 2
|
||||||
|
for i in 1..nyquist {
|
||||||
|
self.fft_buf[i] *= 2.0;
|
||||||
|
}
|
||||||
|
// Nyquist bin: multiply by 1 (unchanged)
|
||||||
|
// Negative frequencies: zero out
|
||||||
|
for i in (nyquist + 1)..n {
|
||||||
|
self.fft_buf[i] = Complex::new(0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inverse FFT
|
||||||
|
self.ifft_buf.copy_from_slice(&self.fft_buf);
|
||||||
|
self.inverse.process(&mut self.ifft_buf);
|
||||||
|
|
||||||
|
// Normalize and extract last hop_size samples
|
||||||
|
let norm = 1.0 / n as f32;
|
||||||
|
let offset = n - hop_size;
|
||||||
|
let mut result = Vec::with_capacity(hop_size);
|
||||||
|
for i in 0..hop_size {
|
||||||
|
let c = self.ifft_buf[offset + i];
|
||||||
|
result.push((c.re * norm, c.im * norm));
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
pub mod complex_stream;
|
||||||
|
pub mod registry;
|
||||||
|
pub mod spiral_visualizer;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AnalyticSignal {
|
||||||
|
pub left: Vec<(f32, f32)>,
|
||||||
|
pub right: Vec<(f32, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct VisualizationFrame {
|
||||||
|
pub left: Vec<PhasePoint>,
|
||||||
|
pub right: Vec<PhasePoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct PhasePoint {
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub amplitude: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VisualizationFrame {
|
||||||
|
pub fn serialize(&self) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
serialize_points(&self.left, &mut buf);
|
||||||
|
serialize_points(&self.right, &mut buf);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize(data: &[u8]) -> Option<Self> {
|
||||||
|
let mut offset = 0;
|
||||||
|
let left = deserialize_points(data, &mut offset)?;
|
||||||
|
let right = deserialize_points(data, &mut offset)?;
|
||||||
|
Some(Self { left, right })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_points(points: &[PhasePoint], buf: &mut Vec<u8>) {
|
||||||
|
buf.extend_from_slice(&(points.len() as u32).to_le_bytes());
|
||||||
|
for p in points {
|
||||||
|
buf.extend_from_slice(&p.x.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&p.y.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&p.amplitude.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_points(data: &[u8], offset: &mut usize) -> Option<Vec<PhasePoint>> {
|
||||||
|
if *offset + 4 > data.len() { return None; }
|
||||||
|
let count = u32::from_le_bytes(data[*offset..*offset + 4].try_into().ok()?) as usize;
|
||||||
|
*offset += 4;
|
||||||
|
let needed = count * 12;
|
||||||
|
if *offset + needed > data.len() { return None; }
|
||||||
|
let mut points = Vec::with_capacity(count);
|
||||||
|
for _ in 0..count {
|
||||||
|
let x = f32::from_le_bytes(data[*offset..*offset + 4].try_into().ok()?);
|
||||||
|
let y = f32::from_le_bytes(data[*offset + 4..*offset + 8].try_into().ok()?);
|
||||||
|
let amplitude = f32::from_le_bytes(data[*offset + 8..*offset + 12].try_into().ok()?);
|
||||||
|
*offset += 12;
|
||||||
|
points.push(PhasePoint { x, y, amplitude });
|
||||||
|
}
|
||||||
|
Some(points)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
use oxforge::mdk::GlobalConfig;
|
||||||
|
|
||||||
|
use crate::engine::host::ModuleHost;
|
||||||
|
|
||||||
|
use super::complex_stream::ComplexStream;
|
||||||
|
use super::spiral_visualizer::SpiralVisualizer;
|
||||||
|
|
||||||
|
pub struct ModuleDescriptor {
|
||||||
|
pub type_name: &'static str,
|
||||||
|
pub display_name: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub requires: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const BUILTIN_MODULES: &[ModuleDescriptor] = &[
|
||||||
|
ModuleDescriptor {
|
||||||
|
type_name: "complex_stream",
|
||||||
|
display_name: "Complex Stream",
|
||||||
|
description: "Streaming Hilbert transform producing analytic signal",
|
||||||
|
requires: None,
|
||||||
|
},
|
||||||
|
ModuleDescriptor {
|
||||||
|
type_name: "spiral_visualizer",
|
||||||
|
display_name: "Spiral Visualizer",
|
||||||
|
description: "3D phase-space spiral visualization",
|
||||||
|
requires: Some("complex_stream"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn load_builtin(host: &mut ModuleHost, type_name: &str, config: &GlobalConfig) -> Option<u32> {
|
||||||
|
match type_name {
|
||||||
|
"complex_stream" => Some(host.load_builtin::<ComplexStream>("ComplexStream", config)),
|
||||||
|
"spiral_visualizer" => Some(host.load_builtin::<SpiralVisualizer>("SpiralVisualizer", config)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
use oxforge::mdk::{GlobalConfig, OxideModule, Ports, ProcessContext, ToGuiMessage};
|
||||||
|
|
||||||
|
use super::{AnalyticSignal, PhasePoint, VisualizationFrame};
|
||||||
|
|
||||||
|
const MAX_POINTS: usize = 4096;
|
||||||
|
const SEND_INTERVAL: u32 = 1470; // ~30fps at 44100Hz with 1024-sample buffers
|
||||||
|
|
||||||
|
pub struct SpiralVisualizer {
|
||||||
|
buffer_l: Vec<PhasePoint>,
|
||||||
|
buffer_r: Vec<PhasePoint>,
|
||||||
|
frame_counter: u32,
|
||||||
|
send_interval: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OxideModule for SpiralVisualizer {
|
||||||
|
fn new(config: &GlobalConfig) -> Self {
|
||||||
|
let buffers_per_second = config.sample_rate / config.buffer_size as f32;
|
||||||
|
let interval = (buffers_per_second / 30.0).max(1.0) as u32;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
buffer_l: Vec::with_capacity(MAX_POINTS),
|
||||||
|
buffer_r: Vec::with_capacity(MAX_POINTS),
|
||||||
|
frame_counter: 0,
|
||||||
|
send_interval: if interval > 0 { interval } else { SEND_INTERVAL },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process(&mut self, ports: Ports, context: &ProcessContext) {
|
||||||
|
// Read analytic signal from chain input
|
||||||
|
if let Some(ref chain_in) = ports.chain_in {
|
||||||
|
if let Some(signal) = chain_in.get::<AnalyticSignal>() {
|
||||||
|
for &(re, im) in &signal.left {
|
||||||
|
let amp = (re * re + im * im).sqrt();
|
||||||
|
self.buffer_l.push(PhasePoint {
|
||||||
|
x: re,
|
||||||
|
y: im,
|
||||||
|
amplitude: amp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for &(re, im) in &signal.right {
|
||||||
|
let amp = (re * re + im * im).sqrt();
|
||||||
|
self.buffer_r.push(PhasePoint {
|
||||||
|
x: re,
|
||||||
|
y: im,
|
||||||
|
amplitude: amp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim to max
|
||||||
|
if self.buffer_l.len() > MAX_POINTS {
|
||||||
|
let excess = self.buffer_l.len() - MAX_POINTS;
|
||||||
|
self.buffer_l.drain(..excess);
|
||||||
|
}
|
||||||
|
if self.buffer_r.len() > MAX_POINTS {
|
||||||
|
let excess = self.buffer_r.len() - MAX_POINTS;
|
||||||
|
self.buffer_r.drain(..excess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passthrough audio
|
||||||
|
if let (Some(audio_in), Some(mut audio_out)) =
|
||||||
|
(ports.main_audio_in, ports.main_audio_out)
|
||||||
|
{
|
||||||
|
let inp = audio_in.buffer();
|
||||||
|
let out = audio_out.buffer_mut();
|
||||||
|
let len = out.len().min(inp.len());
|
||||||
|
out[..len].copy_from_slice(&inp[..len]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send visualization frame at ~30fps
|
||||||
|
self.frame_counter += 1;
|
||||||
|
if self.frame_counter >= self.send_interval {
|
||||||
|
self.frame_counter = 0;
|
||||||
|
|
||||||
|
let frame = VisualizationFrame {
|
||||||
|
left: self.buffer_l.clone(),
|
||||||
|
right: self.buffer_r.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = context.to_gui.send(ToGuiMessage::VisualizationData {
|
||||||
|
data: frame.serialize(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||