init commit

This commit is contained in:
pszsh 2026-03-06 07:01:40 -08:00
commit dccfc1aea6
104 changed files with 23128 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

8
.idea/.gitignore vendored Normal file
View File

@ -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

11
.idea/audio-oxide.iml Normal file
View File

@ -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>

8
.idea/modules.xml Normal file
View File

@ -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>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

5681
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

31
Cargo.toml Normal file
View File

@ -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"

4
assets/icons/add.svg Normal file
View File

@ -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

View File

@ -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

4
assets/icons/close.svg Normal file
View File

@ -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

4
assets/icons/copy.svg Normal file
View File

@ -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

View File

@ -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

7
assets/icons/cut.svg Normal file
View File

@ -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

6
assets/icons/cycle.svg Normal file
View File

@ -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

3
assets/icons/eq.svg Normal file
View File

@ -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

View File

@ -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

3
assets/icons/folder.svg Normal file
View File

@ -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

7
assets/icons/freeze.svg Normal file
View File

@ -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

View File

@ -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

7
assets/icons/insert.svg Normal file
View File

@ -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

5
assets/icons/io.svg Normal file
View File

@ -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

4
assets/icons/lock.svg Normal file
View File

@ -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

View File

@ -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

5
assets/icons/mute.svg Normal file
View File

@ -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

6
assets/icons/pan.svg Normal file
View File

@ -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

4
assets/icons/paste.svg Normal file
View File

@ -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

4
assets/icons/pause.svg Normal file
View File

@ -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

3
assets/icons/play.svg Normal file
View File

@ -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

View File

@ -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

3
assets/icons/record.svg Normal file
View File

@ -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

4
assets/icons/redo.svg Normal file
View File

@ -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

3
assets/icons/remove.svg Normal file
View File

@ -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

4
assets/icons/rewind.svg Normal file
View File

@ -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

4
assets/icons/rtz.svg Normal file
View File

@ -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

5
assets/icons/save.svg Normal file
View File

@ -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

4
assets/icons/search.svg Normal file
View File

@ -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

4
assets/icons/send.svg Normal file
View File

@ -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

View File

@ -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

5
assets/icons/solo.svg Normal file
View File

@ -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

3
assets/icons/stop.svg Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

4
assets/icons/undo.svg Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

122
error.log Normal file
View File

@ -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`

9878
iced_pocket_guide.txt Normal file

File diff suppressed because it is too large Load Diff

139
imagdagdos.py Normal file
View File

@ -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)

123
json-md.py Normal file
View File

@ -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)

49
src/behaviors/mod.rs Normal file
View File

@ -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,
}

130
src/config.rs Normal file
View File

@ -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
}
}

702
src/editor.rs Normal file
View File

@ -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()
}
}

109
src/engine/bus.rs Normal file
View File

@ -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()
}
}

252
src/engine/cycle.rs Normal file
View File

@ -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));
}
}
}
}

151
src/engine/graph.rs Normal file
View File

@ -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)
}
}

61
src/engine/host.rs Normal file
View File

@ -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)
}
}

176
src/engine/io.rs Normal file
View File

@ -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)));
}
}
}

97
src/engine/mod.rs Normal file
View File

@ -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);
}
}

62
src/engine/param.rs Normal file
View File

@ -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
}
}

190
src/engine/schedule.rs Normal file
View File

@ -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]);
}
}
}
}
}
}
}

704
src/entry.rs Normal file
View File

@ -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)
}

41
src/first_run.rs Normal file
View File

@ -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");
}

View 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()
}

View File

@ -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()
}

129
src/gui/editor/inspector.rs Normal file
View File

@ -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()
}

395
src/gui/editor/menu_bar.rs Normal file
View File

@ -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)
}

84
src/gui/editor/mixer.rs Normal file
View File

@ -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()
}

10
src/gui/editor/mod.rs Normal file
View File

@ -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;

View File

@ -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()
}

314
src/gui/editor/timeline.rs Normal file
View File

@ -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(&region.start_time);
let w = self.time_to_x(&region.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));
}
}

42
src/gui/editor/toolbar.rs Normal file
View File

@ -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()
}

View File

@ -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()
}

View File

@ -0,0 +1,6 @@
pub mod spiral;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VisualizerKind {
Spiral,
}

View File

@ -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;
}

View File

@ -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()
}

View File

@ -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", &current_path_str)
.on_input(Message::FirstRunProjectDirChanged),
button("Continue").on_press(Message::FirstRunComplete),
]
.spacing(15)
.align_x(Alignment::Center);
content.into()
}

436
src/gui/icon_button.rs Normal file
View File

@ -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()
}

244
src/gui/icons.rs Normal file
View File

@ -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])
}
}

8
src/gui/mod.rs Normal file
View File

@ -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;

119
src/gui/new_project.rs Normal file
View File

@ -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()
}

154
src/gui/project_viewer.rs Normal file
View File

@ -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()
}

348
src/gui/settings.rs Normal file
View File

@ -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
}

365
src/gui/time_utility.rs Normal file
View File

@ -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
}

18
src/main.rs Normal file
View File

@ -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()
}

View File

@ -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
}
}

64
src/modules/mod.rs Normal file
View File

@ -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)
}

36
src/modules/registry.rs Normal file
View File

@ -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,
}
}

View File

@ -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(),
});
}
}
}

Some files were not shown because too many files have changed in this diff Show More