17 Commits

Author SHA1 Message Date
362cdd498b Feat: update CHANGELOG.md 2026-03-20 23:59:34 +01:00
e26d5e2958 Fix: highlighting was a bit too intense with new 'at' 2026-03-20 23:43:54 +01:00
f020b5a172 Feat: improve 'at' in cagire grammar 2026-03-20 23:29:47 +01:00
609fe108bc Fix: device input name matching 2026-03-20 15:50:50 +01:00
f4a3e26d51 Feat: small fix for build 2026-03-20 13:36:35 +01:00
b6daa81304 Feat: big movement for ASIO 2026-03-20 13:03:32 +01:00
5c5488a9f0 ok 2026-03-20 00:17:18 +01:00
4043a67d38 ok 2026-03-20 00:15:30 +01:00
af3c5c0985 Redo lost work 2026-03-20 00:08:57 +01:00
44fe435770 Feat: wrong path 2026-03-18 16:09:48 +01:00
ef7ee019f1 Feat: adapt workflows for my runner 2026-03-18 16:04:01 +01:00
5dffdd4a8d Feat: adapt for v0.1.4 release
Some checks failed
Deploy Website / deploy (push) Failing after 3s
2026-03-18 15:59:49 +01:00
e1cf72542c Feat: import / export fix 2026-03-18 15:09:49 +01:00
97a1a997f6 Feat: better engine output device switching 2026-03-18 14:27:05 +01:00
005155e486 Feat: fix windows build script 2026-03-18 13:39:45 +01:00
712bd4e74e Feat: fix windows build script 2026-03-18 13:35:17 +01:00
144c2487c2 Feat: fix windows build script 2026-03-18 13:30:41 +01:00
63 changed files with 896 additions and 1734 deletions

View File

@@ -3,8 +3,3 @@ MACOSX_DEPLOYMENT_TARGET = "12.0"
[alias]
xtask = "run --package xtask --release --"
[target.x86_64-pc-windows-gnu]
rustflags = [
"-C", "link-args=-Wl,-Bstatic -lstdc++ -lgcc -lgcc_eh -lpthread -Wl,-Bdynamic -lmingwex -lmsvcrt -lws2_32 -liphlpapi -lwinmm -lole32 -loleaut32 -luuid -lkernel32",
]

View File

@@ -1,135 +0,0 @@
name: Assemble macOS Universal
on:
workflow_call:
jobs:
assemble:
runs-on: macos-14
timeout-minutes: 10
steps:
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
pattern: cagire-macos-*
path: artifacts
- name: Create universal CLI binary
run: |
lipo -create \
artifacts/cagire-macos-x86_64/cagire \
artifacts/cagire-macos-aarch64/cagire \
-output cagire
chmod +x cagire
lipo -info cagire
- name: Create universal app bundle
run: |
cd artifacts/cagire-macos-aarch64-desktop
unzip Cagire.app.zip
cd ../cagire-macos-x86_64-desktop
unzip Cagire.app.zip
cd ../..
cp -R artifacts/cagire-macos-aarch64-desktop/Cagire.app Cagire.app
lipo -create \
artifacts/cagire-macos-x86_64-desktop/Cagire.app/Contents/MacOS/cagire-desktop \
artifacts/cagire-macos-aarch64-desktop/Cagire.app/Contents/MacOS/cagire-desktop \
-output Cagire.app/Contents/MacOS/cagire-desktop
lipo -info Cagire.app/Contents/MacOS/cagire-desktop
zip -r Cagire.app.zip Cagire.app
- name: Create universal CLAP plugin
run: |
mkdir -p cagire-plugins.clap/Contents/MacOS
cp artifacts/cagire-macos-aarch64-clap/cagire-plugins.clap/Contents/Info.plist \
cagire-plugins.clap/Contents/ 2>/dev/null || true
cp artifacts/cagire-macos-aarch64-clap/cagire-plugins.clap/Contents/PkgInfo \
cagire-plugins.clap/Contents/ 2>/dev/null || true
lipo -create \
artifacts/cagire-macos-x86_64-clap/cagire-plugins.clap/Contents/MacOS/cagire-plugins \
artifacts/cagire-macos-aarch64-clap/cagire-plugins.clap/Contents/MacOS/cagire-plugins \
-output cagire-plugins.clap/Contents/MacOS/cagire-plugins
lipo -info cagire-plugins.clap/Contents/MacOS/cagire-plugins
- name: Create universal VST3 plugin
run: |
mkdir -p cagire-plugins.vst3/Contents/MacOS
cp -R artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/Info.plist \
cagire-plugins.vst3/Contents/ 2>/dev/null || true
cp artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/PkgInfo \
cagire-plugins.vst3/Contents/ 2>/dev/null || true
cp -R artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/Resources \
cagire-plugins.vst3/Contents/ 2>/dev/null || true
lipo -create \
artifacts/cagire-macos-x86_64-vst3/cagire-plugins.vst3/Contents/MacOS/cagire-plugins \
artifacts/cagire-macos-aarch64-vst3/cagire-plugins.vst3/Contents/MacOS/cagire-plugins \
-output cagire-plugins.vst3/Contents/MacOS/cagire-plugins
lipo -info cagire-plugins.vst3/Contents/MacOS/cagire-plugins
- uses: actions/checkout@v4
with:
sparse-checkout: |
assets/DMG-README.txt
scripts/make-dmg.sh
clean: false
- name: Create DMG
run: |
chmod +x scripts/make-dmg.sh
scripts/make-dmg.sh Cagire.app .
- name: Build .pkg installer
run: |
VERSION="${GITEA_REF_NAME#v}"
mkdir -p pkg-root/Applications pkg-root/usr/local/bin
cp -R Cagire.app pkg-root/Applications/
cp cagire pkg-root/usr/local/bin/
pkgbuild --analyze --root pkg-root component.plist
plutil -replace BundleIsRelocatable -bool NO component.plist
pkgbuild --root pkg-root --identifier com.sova.cagire \
--version "$VERSION" --install-location / \
--component-plist component.plist \
"Cagire-${VERSION}-universal.pkg"
- name: Upload universal CLI
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal
path: cagire
- name: Upload universal app bundle
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-desktop
path: Cagire.app.zip
- name: Prepare universal plugin staging
run: |
mkdir -p staging/clap staging/vst3
cp -R cagire-plugins.clap staging/clap/
cp -R cagire-plugins.vst3 staging/vst3/
- name: Upload universal CLAP plugin
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-clap
path: staging/clap/
- name: Upload universal VST3 plugin
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-vst3
path: staging/vst3/
- name: Upload DMG
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-dmg
path: Cagire-*.dmg
- name: Upload .pkg installer
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-pkg
path: Cagire-*-universal.pkg

View File

@@ -1,49 +0,0 @@
name: Build Cross (Linux ARM64)
on:
workflow_call:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: aarch64-unknown-linux-gnu
- name: Install cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build
run: cross build --release --target aarch64-unknown-linux-gnu
- name: Build desktop
run: cross build --release --features desktop --bin cagire-desktop --target aarch64-unknown-linux-gnu
- name: Upload CLI artifact
uses: actions/upload-artifact@v4
with:
name: cagire-linux-aarch64
path: target/aarch64-unknown-linux-gnu/release/cagire
- name: Upload desktop artifact
uses: actions/upload-artifact@v4
with:
name: cagire-linux-aarch64-desktop
path: target/aarch64-unknown-linux-gnu/release/cagire-desktop

View File

@@ -1,131 +0,0 @@
name: Build Linux
on:
workflow_call:
inputs:
run-tests:
type: boolean
default: false
run-clippy:
type: boolean
default: false
build-packages:
type: boolean
default: false
workflow_dispatch:
inputs:
run-tests:
type: boolean
default: true
run-clippy:
type: boolean
default: true
build-packages:
type: boolean
default: false
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
components: clippy
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: x86_64-unknown-linux-gnu
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential cmake pkg-config libasound2-dev libclang-dev libjack-dev \
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev \
libx11-dev libx11-xcb-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev
- name: Build
run: cargo build --release --target x86_64-unknown-linux-gnu
- name: Build desktop
run: cargo build --release --features desktop --bin cagire-desktop --target x86_64-unknown-linux-gnu
- name: Test
if: inputs.run-tests
run: cargo test --target x86_64-unknown-linux-gnu
- name: Clippy
if: inputs.run-clippy
run: cargo clippy --target x86_64-unknown-linux-gnu -- -D warnings
- name: Install cargo-bundle
if: inputs.build-packages
run: cargo install cargo-bundle
- name: Bundle desktop app
if: inputs.build-packages
run: cargo bundle --release --features desktop --bin cagire-desktop --target x86_64-unknown-linux-gnu
- name: Build AppImages
if: inputs.build-packages
run: |
mkdir -p target/releases
scripts/make-appimage.sh target/x86_64-unknown-linux-gnu/release/cagire x86_64 target/releases
scripts/make-appimage.sh target/x86_64-unknown-linux-gnu/release/cagire-desktop x86_64 target/releases
- name: Bundle CLAP plugin
if: inputs.build-packages
run: cargo xtask bundle cagire-plugins --release --target x86_64-unknown-linux-gnu
- name: Upload CLI artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-linux-x86_64
path: target/x86_64-unknown-linux-gnu/release/cagire
- name: Upload desktop artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-linux-x86_64-desktop
path: target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
- name: Upload AppImage artifacts
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-linux-x86_64-appimage
path: target/releases/*.AppImage
- name: Prepare plugin artifacts
if: inputs.build-packages
run: |
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-linux-x86_64-clap
path: staging/clap/
- name: Upload VST3 artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-linux-x86_64-vst3
path: staging/vst3/

View File

@@ -1,127 +0,0 @@
name: Build macOS
on:
workflow_call:
inputs:
run-tests:
type: boolean
default: false
run-clippy:
type: boolean
default: false
build-packages:
type: boolean
default: false
matrix:
type: string
default: '[{"os":"macos-14","target":"aarch64-apple-darwin","artifact":"cagire-macos-aarch64"}]'
workflow_dispatch:
inputs:
run-tests:
type: boolean
default: true
run-clippy:
type: boolean
default: true
build-packages:
type: boolean
default: false
matrix:
type: string
default: '[{"os":"macos-14","target":"aarch64-apple-darwin","artifact":"cagire-macos-aarch64"}]'
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "12.0"
jobs:
build:
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(inputs.matrix) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
components: clippy
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Install dependencies
run: brew list cmake &>/dev/null || brew install cmake
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Build desktop
run: cargo build --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Test
if: inputs.run-tests
run: cargo test --target ${{ matrix.target }}
- name: Clippy
if: inputs.run-clippy
run: cargo clippy --target ${{ matrix.target }} -- -D warnings
- name: Bundle desktop app
if: inputs.build-packages
run: scripts/make-app-bundle.sh ${{ matrix.target }}
- name: Bundle CLAP plugin
if: inputs.build-packages
run: cargo xtask bundle cagire-plugins --release --target ${{ matrix.target }}
- name: Zip macOS app bundle
if: inputs.build-packages
run: |
cd target/${{ matrix.target }}/release/bundle/osx
zip -r Cagire.app.zip Cagire.app
- name: Upload CLI artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/cagire
- name: Upload desktop artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/bundle/osx/Cagire.app.zip
- name: Prepare plugin artifacts
if: inputs.build-packages
run: |
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-clap
path: staging/clap/
- name: Upload VST3 artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-vst3
path: staging/vst3/

View File

@@ -1,56 +0,0 @@
name: Build Plugins Linux
on:
workflow_call:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: x86_64-unknown-linux-gnu-plugins
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential cmake pkg-config libasound2-dev libclang-dev libjack-dev \
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev \
libx11-dev libx11-xcb-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev
- name: Build plugins
run: cargo xtask bundle cagire-plugins --release --target x86_64-unknown-linux-gnu
- name: Prepare plugin artifacts
run: |
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
uses: actions/upload-artifact@v4
with:
name: plugins-linux-x86_64-clap
path: staging/clap/
- name: Upload VST3 artifact
uses: actions/upload-artifact@v4
with:
name: plugins-linux-x86_64-vst3
path: staging/vst3/

View File

@@ -1,66 +0,0 @@
name: Build Plugins macOS
on:
workflow_call:
inputs:
matrix:
type: string
default: '[{"os":"macos-14","target":"aarch64-apple-darwin","artifact":"plugins-macos-aarch64"},{"os":"macos-15-intel","target":"x86_64-apple-darwin","artifact":"plugins-macos-x86_64"}]'
workflow_dispatch:
inputs:
matrix:
type: string
default: '[{"os":"macos-14","target":"aarch64-apple-darwin","artifact":"plugins-macos-aarch64"}]'
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "12.0"
jobs:
build:
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(inputs.matrix) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}-plugins
- name: Install dependencies
run: brew list cmake &>/dev/null || brew install cmake
- name: Build plugins
run: cargo xtask bundle cagire-plugins --release --target ${{ matrix.target }}
- name: Prepare plugin artifacts
run: |
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-clap
path: staging/clap/
- name: Upload VST3 artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-vst3
path: staging/vst3/

View File

@@ -1,57 +0,0 @@
name: Build Plugins RPi
on:
workflow_call:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: aarch64-unknown-linux-gnu-plugins
- name: Install cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build plugins
run: cross build --release -p cagire-plugins --target aarch64-unknown-linux-gnu
- name: Prepare plugin artifacts
run: |
mkdir -p target/bundled
cp target/aarch64-unknown-linux-gnu/release/libcagire_plugins.so target/bundled/cagire-plugins.clap
mkdir -p "target/bundled/cagire-plugins.vst3/Contents/aarch64-linux"
cp target/aarch64-unknown-linux-gnu/release/libcagire_plugins.so "target/bundled/cagire-plugins.vst3/Contents/aarch64-linux/cagire-plugins.so"
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
uses: actions/upload-artifact@v4
with:
name: plugins-linux-aarch64-clap
path: staging/clap/
- name: Upload VST3 artifact
uses: actions/upload-artifact@v4
with:
name: plugins-linux-aarch64-vst3
path: staging/vst3/

View File

@@ -1,59 +0,0 @@
name: Build Plugins Windows
on:
workflow_call:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: windows-latest
timeout-minutes: 60
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: x86_64-pc-windows-msvc-plugins
- name: Install dependencies
shell: pwsh
run: |
choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'
echo "C:\Program Files\CMake\bin" >> $env:GITHUB_PATH
- name: Build plugins
run: cargo xtask bundle cagire-plugins --release --target x86_64-pc-windows-msvc
- name: Prepare plugin artifacts
run: |
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
uses: actions/upload-artifact@v4
with:
name: plugins-windows-x86_64-clap
path: staging/clap/
- name: Upload VST3 artifact
uses: actions/upload-artifact@v4
with:
name: plugins-windows-x86_64-vst3
path: staging/vst3/

View File

@@ -1,17 +0,0 @@
name: Build Plugins
on:
workflow_dispatch:
jobs:
linux:
uses: ./.gitea/workflows/build-plugins-linux.yml
macos:
uses: ./.gitea/workflows/build-plugins-macos.yml
windows:
uses: ./.gitea/workflows/build-plugins-windows.yml
rpi:
uses: ./.gitea/workflows/build-plugins-rpi.yml

View File

@@ -1,134 +0,0 @@
name: Build Windows
on:
workflow_call:
inputs:
run-tests:
type: boolean
default: false
run-clippy:
type: boolean
default: false
build-packages:
type: boolean
default: false
workflow_dispatch:
inputs:
run-tests:
type: boolean
default: true
run-clippy:
type: boolean
default: true
build-packages:
type: boolean
default: true
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
components: clippy
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: x86_64-pc-windows-msvc
- name: Install dependencies
run: |
choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'
echo "C:\Program Files\CMake\bin" >> $env:GITHUB_PATH
- name: Build
run: cargo build --release --features asio --target x86_64-pc-windows-msvc
- name: Build desktop
run: cargo build --release --features desktop,asio --bin cagire-desktop --target x86_64-pc-windows-msvc
- name: Test
if: inputs.run-tests
run: cargo test --features asio --target x86_64-pc-windows-msvc
- name: Clippy
if: inputs.run-clippy
run: cargo clippy --features asio --target x86_64-pc-windows-msvc -- -D warnings
- name: Bundle CLAP plugin
if: inputs.build-packages
run: cargo xtask bundle cagire-plugins --release --features asio --target x86_64-pc-windows-msvc
- name: Install NSIS
if: inputs.build-packages
run: choco install nsis
- name: Build NSIS installer
if: inputs.build-packages
shell: pwsh
run: |
$version = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"(.+)"' | Select-Object -First 1).Matches.Groups[1].Value
$root = (Get-Location).Path
$target = "x86_64-pc-windows-msvc"
& "C:\Program Files (x86)\NSIS\makensis.exe" `
"-DVERSION=$version" `
"-DCLI_EXE=$root\target\$target\release\cagire.exe" `
"-DDESKTOP_EXE=$root\target\$target\release\cagire-desktop.exe" `
"-DICON=$root\assets\Cagire.ico" `
"-DOUTDIR=$root\target" `
nsis/cagire.nsi
- name: Upload CLI artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-windows-x86_64
path: target/x86_64-pc-windows-msvc/release/cagire.exe
- name: Upload desktop artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-windows-x86_64-desktop
path: target/x86_64-pc-windows-msvc/release/cagire-desktop.exe
- name: Upload installer artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-windows-x86_64-installer
path: target/cagire-*-setup.exe
- name: Prepare plugin artifacts
if: inputs.build-packages
shell: bash
run: |
mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/
cp -R target/bundled/cagire-plugins.vst3 staging/vst3/
- name: Upload CLAP artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-windows-x86_64-clap
path: staging/clap/
- name: Upload VST3 artifact
if: inputs.build-packages
uses: actions/upload-artifact@v4
with:
name: cagire-windows-x86_64-vst3
path: staging/vst3/

View File

@@ -1,23 +0,0 @@
name: CI
on:
workflow_dispatch:
jobs:
linux:
uses: ./.gitea/workflows/build-linux.yml
with:
run-tests: true
run-clippy: true
macos:
uses: ./.gitea/workflows/build-macos.yml
with:
run-tests: true
run-clippy: true
windows:
uses: ./.gitea/workflows/build-windows.yml
with:
run-tests: true
run-clippy: true

View File

@@ -12,15 +12,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v4.2.2
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v4.1.0
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v4.4.0
with:
node-version: 22
@@ -34,5 +34,6 @@ jobs:
- name: Deploy to host volume
run: |
rm -rf /home/debian/my-services/cagire-website-data/*
cp -r website/dist/* /home/debian/my-services/cagire-website-data/
rm -rf /home/debian/my-services/data/cagire-website/*
cp -r website/dist/* /home/debian/my-services/data/cagire-website/

View File

@@ -1,111 +0,0 @@
name: Release
on:
workflow_dispatch:
jobs:
linux:
uses: ./.gitea/workflows/build-linux.yml
with:
build-packages: true
macos:
uses: ./.gitea/workflows/build-macos.yml
with:
build-packages: true
matrix: >-
[
{"os":"macos-14","target":"aarch64-apple-darwin","artifact":"cagire-macos-aarch64"},
{"os":"macos-15-intel","target":"x86_64-apple-darwin","artifact":"cagire-macos-x86_64"}
]
windows:
uses: ./.gitea/workflows/build-windows.yml
with:
build-packages: true
cross:
uses: ./.gitea/workflows/build-cross.yml
assemble-macos:
needs: macos
uses: ./.gitea/workflows/assemble-macos.yml
release:
needs: [linux, macos, windows, cross, assemble-macos]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release
for dir in artifacts/*/; do
name=$(basename "$dir")
if [[ "$name" == "cagire-macos-universal-dmg" ]]; then
cp "$dir"/*.dmg release/
elif [[ "$name" == "cagire-macos-universal-pkg" ]]; then
cp "$dir"/*.pkg release/
elif [[ "$name" == "cagire-macos-universal-desktop" ]]; then
cp "$dir/Cagire.app.zip" "release/cagire-macos-universal-desktop.app.zip"
elif [[ "$name" == "cagire-macos-universal" ]]; then
cp "$dir/cagire" "release/cagire-macos-universal"
elif [[ "$name" == "cagire-macos-universal-clap" ]]; then
cd "$dir" && zip -r "../../release/cagire-macos-universal-clap.zip" cagire-plugins.clap && cd ../..
elif [[ "$name" == "cagire-macos-universal-vst3" ]]; then
cd "$dir" && zip -r "../../release/cagire-macos-universal-vst3.zip" cagire-plugins.vst3 && cd ../..
elif [[ "$name" == *-clap ]]; then
base="${name%-clap}"
cd "$dir" && zip -r "../../release/${base}-clap.zip" cagire-plugins.clap && cd ../..
elif [[ "$name" == *-vst3 ]]; then
base="${name%-vst3}"
cd "$dir" && zip -r "../../release/${base}-vst3.zip" cagire-plugins.vst3 && cd ../..
elif [[ "$name" == *-installer ]]; then
cp "$dir"/*-setup.exe release/
elif [[ "$name" == *-appimage ]]; then
cp "$dir"/*.AppImage release/
elif [[ "$name" == *-desktop ]]; then
base="${name%-desktop}"
if ls "$dir"/*.deb 1>/dev/null 2>&1; then
cp "$dir"/*.deb "release/${base}-desktop.deb"
elif [ -f "$dir/Cagire.app.zip" ]; then
cp "$dir/Cagire.app.zip" "release/${base}-desktop.app.zip"
elif [ -f "$dir/cagire-desktop.exe" ]; then
cp "$dir/cagire-desktop.exe" "release/${base}-desktop.exe"
fi
else
if [ -f "$dir/cagire.exe" ]; then
cp "$dir/cagire.exe" "release/${name}.exe"
elif [ -f "$dir/cagire" ]; then
cp "$dir/cagire" "release/${name}"
fi
fi
done
- name: Create Gitea release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
TAG="${GITEA_REF_NAME:-manual-$(date +%Y%m%d-%H%M%S)}"
API_URL="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases"
RELEASE_ID=$(curl -s -X POST "$API_URL" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"draft\": true}" \
| jq -r '.id')
for file in release/*; do
filename=$(basename "$file")
curl -s -X POST "$API_URL/$RELEASE_ID/assets?name=$filename" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$file"
done
echo "Release $TAG created as draft with $(ls release | wc -l) assets"

View File

@@ -110,57 +110,49 @@ cargo run --release --features desktop --bin cagire-desktop
## Cross-Compilation
[cross](https://github.com/cross-rs/cross) uses Docker to build for other platforms without installing their toolchains locally. It works on any OS that runs Docker.
### Targets
| Target | Method | Binaries |
|--------|--------|----------|
| aarch64-apple-darwin | Native (macOS ARM only) | `cagire`, `cagire-desktop` |
| x86_64-apple-darwin | Native (macOS only) | `cagire`, `cagire-desktop` |
| x86_64-unknown-linux-gnu | `cross build` | `cagire`, `cagire-desktop` |
| aarch64-unknown-linux-gnu (RPi 64-bit) | `cross build` | `cagire`, `cagire-desktop` |
| x86_64-pc-windows-gnu | `cross build` | `cagire`, `cagire-desktop` |
| x86_64-unknown-linux-gnu | `cross build` (Docker) | `cagire`, `cagire-desktop` |
| aarch64-unknown-linux-gnu (RPi 64-bit) | `cross build` (Docker) | `cagire`, `cagire-desktop` |
| x86_64-pc-windows-msvc | `cargo xwin build` (native) | `cagire`, `cagire-desktop` |
macOS targets can only be built on macOS — Apple does not support cross-compilation to macOS from other platforms. Linux and Windows targets can be cross-compiled from any OS. The aarch64-unknown-linux-gnu target covers Raspberry Pi (64-bit OS).
### Windows ABI
CI produces `x86_64-pc-windows-msvc` binaries (native Windows build, better compatibility). Local cross-compilation from non-Windows hosts produces `x86_64-pc-windows-gnu` binaries (MinGW via Docker). Both work; MSVC is preferred for releases.
macOS targets can only be built on macOS. Linux targets are cross-compiled via Docker (`cross`). Windows targets are cross-compiled natively via `cargo-xwin` (downloads Windows SDK + MSVC CRT headers, no Docker needed).
### Prerequisites
1. **Docker**: https://docs.docker.com/get-docker/
2. **cross**: `cargo install cross --git https://github.com/cross-rs/cross`
1. **Docker** + **cross** (Linux targets only): `cargo install cross --git https://github.com/cross-rs/cross`
2. **cargo-xwin** (Windows target): `cargo install cargo-xwin` and `rustup target add x86_64-pc-windows-msvc`
3. On macOS, add the Intel target: `rustup target add x86_64-apple-darwin`
Docker must be running before invoking `cross` or `scripts/build-all.sh`.
### Building Individual Targets
```bash
# Linux x86_64
# Linux x86_64 (Docker)
cross build --release --target x86_64-unknown-linux-gnu
cross build --release --features desktop --bin cagire-desktop --target x86_64-unknown-linux-gnu
# Linux aarch64
# Linux aarch64 (Docker)
cross build --release --target aarch64-unknown-linux-gnu
cross build --release --features desktop --bin cagire-desktop --target aarch64-unknown-linux-gnu
# Windows x86_64
cross build --release --target x86_64-pc-windows-gnu
cross build --release --features desktop --bin cagire-desktop --target x86_64-pc-windows-gnu
# Windows x86_64 (native, no Docker)
cargo xwin build --release --target x86_64-pc-windows-msvc
cargo xwin build --release --features desktop --bin cagire-desktop --target x86_64-pc-windows-msvc
```
### Building All Targets (macOS only)
### Building All Targets
```bash
# Interactive (prompts for platform/target selection):
scripts/build-all.sh
uv run scripts/build.py
# Non-interactive:
scripts/build-all.sh --platforms macos-arm64,linux-x86_64 --targets cli,desktop --yes
scripts/build-all.sh --all --yes
uv run scripts/build.py --platforms macos-arm64,linux-x86_64 --targets cli,desktop
uv run scripts/build.py --all
```
Builds selected targets, producing binaries in `releases/`.
@@ -170,18 +162,11 @@ Target aliases: `cli`, `desktop`, `plugins`.
### Linux AppImage Packaging
Linux releases ship as AppImages — self-contained executables that bundle all shared library dependencies (ALSA, JACK, X11, OpenGL). No runtime dependencies required.
After building a Linux target, produce an AppImage with:
```bash
scripts/make-appimage.sh target/x86_64-unknown-linux-gnu/release/cagire x86_64 releases
```
`scripts/build-all.sh` does this automatically for every Linux target selected. The CI pipeline produces AppImages for the x86_64 Linux build. Cross-arch AppImage building (e.g. aarch64 on x86_64) is not supported — run on a matching host or in CI.
Linux releases ship as AppImages — self-contained executables that bundle all shared library dependencies (ALSA, JACK, X11, OpenGL). No runtime dependencies required. `build.py` handles AppImage creation automatically for Linux targets.
### Notes
- Custom Dockerfiles in `cross/` install the native libraries Cagire depends on (ALSA, JACK, X11, cmake, libclang, etc.). `Cross.toml` maps each target to its Dockerfile.
- The first build per target downloads Docker base images and installs packages. Subsequent builds use cached layers.
- Cross-architecture Docker builds (e.g. aarch64 on x86_64 or vice versa) run under QEMU emulation and are significantly slower.
- Custom Dockerfiles in `scripts/cross/` install the native libraries for Linux cross-compilation (ALSA, JACK, X11, cmake, libclang, etc.). `Cross.toml` maps each Linux target to its Dockerfile.
- The first Linux cross-build per target downloads Docker base images and installs packages. Subsequent builds use cached layers.
- Cross-architecture Docker builds (e.g. aarch64 on x86_64) run under QEMU emulation and are significantly slower.
- Windows cross-compilation via `cargo-xwin` runs natively on the host (no Docker) and uses real Windows SDK headers, ensuring correct ABI and struct layouts.

View File

@@ -2,6 +2,20 @@
All notable changes to this project will be documented in this file.
## [0.1.5]
### Forth Language
- **`at` reworked as a looping block**: `at` now captures all stack values as deltas, then re-executes its body once per delta. Closed by `.` (audio emit), `m.` (MIDI emit), or `done` (no emit). Each iteration gets independent nondeterministic rolls (e.g., `0 0.5 at kick snd 1 2 rand freq .` re-evaluates `kick snd 1 2 rand freq` at delta 0 and 0.5).
- Removed `ArpList` type and `arp` word — arpeggio spreading is now handled by at-loops directly.
### Added
- Support i32/i16 sample formats at cpal boundary for ASIO compatibility
### Fixed
- Resolved value annotations deduplicated: nondeterministic ops inside at-loops now show only the last resolved value per span, instead of one annotation per iteration.
- Audio input device name matching.
## [0.1.4]
### Breaking

39
Cargo.lock generated
View File

@@ -894,7 +894,7 @@ dependencies = [
"tachyonfx",
"thread-priority",
"tui-big-text",
"winres",
"winresource",
]
[[package]]
@@ -1824,8 +1824,8 @@ dependencies = [
[[package]]
name = "doux"
version = "0.0.15"
source = "git+https://github.com/sova-org/doux?tag=v0.0.15#29d8f055612f6141d7546d72b91e60026937b0fd"
version = "0.0.19"
source = "git+https://github.com/sova-org/doux?tag=v0.0.19#7f71e212287c0bedce38612361bad61ee6f1d6ef"
dependencies = [
"arc-swap",
"clap",
@@ -5596,15 +5596,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]]
name = "toml"
version = "0.7.8"
@@ -5632,6 +5623,21 @@ dependencies = [
"winnow 0.7.15",
]
[[package]]
name = "toml"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned 1.0.4",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
@@ -7086,12 +7092,13 @@ dependencies = [
]
[[package]]
name = "winres"
version = "0.1.12"
name = "winresource"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
dependencies = [
"toml 0.5.11",
"toml 1.0.6+spec-1.1.0",
"version_check",
]
[[package]]

View File

@@ -51,7 +51,7 @@ cagire-forth = { path = "crates/forth" }
cagire-markdown = { path = "crates/markdown" }
cagire-project = { path = "crates/project" }
cagire-ratatui = { path = "crates/ratatui" }
doux = { git = "https://github.com/sova-org/doux", tag = "v0.0.15", features = ["native", "soundfont"] }
doux = { git = "https://github.com/sova-org/doux", tag = "v0.0.19", features = ["native", "soundfont"] }
rusty_link = "0.4"
ratatui = "0.30"
crossterm = "0.29"
@@ -87,7 +87,7 @@ image = { version = "0.25", default-features = false, features = ["png"], option
cpal = { version = "0.17", optional = true, features = ["jack"] }
[build-dependencies]
winres = "0.1"
winresource = "0.1"
[profile.release]
opt-level = 3

View File

@@ -3,6 +3,3 @@ dockerfile = "./scripts/cross/aarch64-linux.Dockerfile"
[target.x86_64-unknown-linux-gnu]
dockerfile = "./scripts/cross/x86_64-linux.Dockerfile"
[target.x86_64-pc-windows-gnu]
dockerfile = "./scripts/cross/x86_64-windows.Dockerfile"

View File

@@ -31,8 +31,8 @@ A generative pattern using randomness, scales, and effects:
```forth
sine sound 2 fm 0.5 fmh
0 7 rand minor 50 + note
.1 .8 rrand cutoff
1 4 irand 10 * delay .5 delayfb
.1 .8 rand lpf
1 4 rand 10 * delay .5 delayfeedback
.
```
@@ -66,7 +66,7 @@ To build from source instead, see [BUILDING.md](BUILDING.md).
### Documentation
Cagire includes interactive documentation with runnable code examples. Press **F1** in the application to open it.
Cagire includes interactive documentation with runnable code examples. Press **F4** in the application to open it.
- [Website](https://cagire.raphaelforment.fr)
- [BUILDING.md](BUILDING.md) — build instructions and CLI flags

View File

@@ -4,8 +4,6 @@ fn main() {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os == "windows" {
// C++ runtime (stdc++, gcc, gcc_eh, pthread) linked statically via .cargo/config.toml
// using -Wl,-Bstatic. Only Windows system DLLs go here.
println!("cargo:rustc-link-lib=ws2_32");
println!("cargo:rustc-link-lib=iphlpapi");
println!("cargo:rustc-link-lib=winmm");
@@ -16,23 +14,12 @@ fn main() {
if target_os == "windows" {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let icon = format!("{manifest_dir}/assets/Cagire.ico");
let mut res = winres::WindowsResource::new();
// Cross-compiling from Unix: use prefixed MinGW tools
if cfg!(unix) {
res.set_windres_path("x86_64-w64-mingw32-windres");
res.set_ar_path("x86_64-w64-mingw32-ar");
res.set_toolkit_path("/");
}
res.set_icon(&icon)
winresource::WindowsResource::new()
.set_icon(&icon)
.set("ProductName", "Cagire")
.set("FileDescription", "Forth-based music sequencer")
.set("LegalCopyright", "Copyright (c) 2025 Raphaël Forment");
res.compile().expect("Failed to compile Windows resources");
// GNU ld discards unreferenced sections from static archives,
// so link the resource object directly to ensure .rsrc is kept.
if cfg!(unix) {
let out_dir = std::env::var("OUT_DIR").unwrap();
println!("cargo:rustc-link-arg-bins={out_dir}/resource.o");
}
.set("LegalCopyright", "Copyright (c) 2025 Raphaël Forment")
.compile()
.expect("Failed to compile Windows resources");
}
}

View File

@@ -176,6 +176,13 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
ops.push(Op::Branch(else_ops.len()));
ops.extend(else_ops);
}
} else if word == "at" {
if let Some((body_ops, consumed)) = compile_at(&tokens[i + 1..], dict)? {
i += consumed;
ops.push(Op::AtLoop(Arc::from(body_ops)));
} else if !compile_word(word, Some(*span), &mut ops, dict) {
return Err(format!("unknown word: {word}"));
}
} else if word == "case" {
let (case_ops, consumed) = compile_case(&tokens[i + 1..], dict)?;
i += consumed;
@@ -355,6 +362,37 @@ fn compile_if(
Ok((then_ops, else_ops, then_pos + 1, then_span, else_span))
}
fn compile_at(tokens: &[Token], dict: &Dictionary) -> Result<Option<(Vec<Op>, usize)>, String> {
let mut depth = 1;
enum AtCloser { Dot, MidiDot, Done }
let mut found: Option<(usize, AtCloser)> = None;
for (i, tok) in tokens.iter().enumerate() {
if let Token::Word(w, _) = tok {
match w.as_str() {
"at" => depth += 1,
"." if depth == 1 => { found = Some((i, AtCloser::Dot)); break; }
"m." if depth == 1 => { found = Some((i, AtCloser::MidiDot)); break; }
"done" if depth == 1 => { found = Some((i, AtCloser::Done)); break; }
"." | "m." | "done" => depth -= 1,
_ => {}
}
}
}
let Some((pos, closer)) = found else {
return Ok(None);
};
let mut body_ops = compile(&tokens[..pos], dict)?;
match closer {
AtCloser::Dot => body_ops.push(Op::Emit),
AtCloser::MidiDot => body_ops.push(Op::MidiEmit),
AtCloser::Done => {}
}
Ok(Some((body_ops, pos + 1)))
}
fn compile_case(tokens: &[Token], dict: &Dictionary) -> Result<(Vec<Op>, usize), String> {
let mut depth = 1;
let mut endcase_pos = None;

View File

@@ -110,7 +110,8 @@ pub enum Op {
ClearCmd,
SetSpeed,
At,
Arp,
AtLoop(Arc<[Op]>),
IntRange,
StepRange,
Generate,

View File

@@ -96,7 +96,7 @@ pub enum Value {
Str(Arc<str>, Option<SourceSpan>),
Quotation(Arc<[Op]>, Option<SourceSpan>),
CycleList(Arc<[Value]>),
ArpList(Arc<[Value]>),
}
impl PartialEq for Value {
@@ -107,7 +107,7 @@ impl PartialEq for Value {
(Value::Str(a, _), Value::Str(b, _)) => a == b,
(Value::Quotation(a, _), Value::Quotation(b, _)) => a == b,
(Value::CycleList(a), Value::CycleList(b)) => a == b,
(Value::ArpList(a), Value::ArpList(b)) => a == b,
_ => false,
}
}
@@ -143,7 +143,7 @@ impl Value {
Value::Float(f, _) => *f != 0.0,
Value::Str(s, _) => !s.is_empty(),
Value::Quotation(..) => true,
Value::CycleList(items) | Value::ArpList(items) => !items.is_empty(),
Value::CycleList(items) => !items.is_empty(),
}
}
@@ -153,14 +153,14 @@ impl Value {
Value::Float(f, _) => f.to_string(),
Value::Str(s, _) => s.to_string(),
Value::Quotation(..) => String::new(),
Value::CycleList(_) | Value::ArpList(_) => String::new(),
Value::CycleList(_) => String::new(),
}
}
pub(super) fn span(&self) -> Option<SourceSpan> {
match self {
Value::Int(_, s) | Value::Float(_, s) | Value::Str(_, s) | Value::Quotation(_, s) => *s,
Value::CycleList(_) | Value::ArpList(_) => None,
Value::CycleList(_) => None,
}
}
}
@@ -171,6 +171,7 @@ pub(super) struct CmdRegister {
params: Vec<(&'static str, Value)>,
deltas: Vec<Value>,
global_params: Vec<(&'static str, Value)>,
delta_secs: Option<f64>,
}
impl CmdRegister {
@@ -180,6 +181,7 @@ impl CmdRegister {
params: Vec::with_capacity(16),
deltas: Vec::with_capacity(4),
global_params: Vec::new(),
delta_secs: None,
}
}
@@ -237,9 +239,26 @@ impl CmdRegister {
std::mem::take(&mut self.global_params)
}
pub(super) fn set_delta_secs(&mut self, secs: f64) {
self.delta_secs = Some(secs);
}
pub(super) fn take_delta_secs(&mut self) -> Option<f64> {
self.delta_secs.take()
}
pub(super) fn clear_sound(&mut self) {
self.sound = None;
}
pub(super) fn clear_params(&mut self) {
self.params.clear();
}
pub(super) fn clear(&mut self) {
self.sound = None;
self.params.clear();
self.deltas.clear();
self.delta_secs = None;
}
}

View File

@@ -241,31 +241,7 @@ impl Forth {
sound_len.max(param_max)
};
let has_arp_list = |cmd: &CmdRegister| -> bool {
matches!(cmd.sound(), Some(Value::ArpList(_)))
|| cmd.global_params().iter().chain(cmd.params().iter())
.any(|(_, v)| matches!(v, Value::ArpList(_)))
};
let compute_arp_count = |cmd: &CmdRegister| -> usize {
let sound_len = match cmd.sound() {
Some(Value::ArpList(items)) => items.len(),
_ => 0,
};
let param_max = cmd
.params()
.iter()
.map(|(_, v)| match v {
Value::ArpList(items) => items.len(),
_ => 0,
})
.max()
.unwrap_or(0);
sound_len.max(param_max).max(1)
};
let emit_with_cycling = |cmd: &CmdRegister,
arp_idx: usize,
poly_idx: usize,
delta_secs: f64,
outputs: &mut Vec<String>|
@@ -277,7 +253,7 @@ impl Forth {
return Err("nothing to emit".into());
}
let resolved_sound_val =
cmd.sound().map(|sv| resolve_value(sv, arp_idx, poly_idx));
cmd.sound().map(|sv| resolve_value(sv, poly_idx));
let sound_str = match &resolved_sound_val {
Some(v) => Some(v.as_str()?.to_string()),
None => None,
@@ -286,8 +262,8 @@ impl Forth {
.iter()
.chain(cmd.params().iter())
.map(|(k, v)| {
let resolved = resolve_value(v, arp_idx, poly_idx);
if let Value::CycleList(_) | Value::ArpList(_) = v {
let resolved = resolve_value(v, poly_idx);
if let Value::CycleList(_) = v {
if let Some(span) = resolved.span() {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.selected_spans.push(span);
@@ -595,47 +571,17 @@ impl Forth {
}
Op::Emit => {
if has_arp_list(cmd) {
let arp_count = compute_arp_count(cmd);
if let Some(dsecs) = cmd.take_delta_secs() {
let poly_count = compute_poly_count(cmd);
let explicit_deltas = !cmd.deltas().is_empty();
let delta_list: Vec<Value> = if explicit_deltas {
cmd.deltas().to_vec()
} else {
Vec::new()
};
let count = if explicit_deltas {
arp_count.max(delta_list.len())
} else {
arp_count
};
for i in 0..count {
let delta_secs = if explicit_deltas {
let dv = &delta_list[i % delta_list.len()];
let frac = dv.as_float()?;
if let Some(span) = dv.span() {
for poly_idx in 0..poly_count {
if let Some(sound_val) =
emit_with_cycling(cmd, poly_idx, dsecs, outputs)?
{
if let Some(span) = sound_val.span() {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.selected_spans.push(span);
}
}
ctx.nudge_secs + frac * ctx.step_duration()
} else {
ctx.nudge_secs
+ (i as f64 / count as f64) * ctx.step_duration()
};
for poly_i in 0..poly_count {
if let Some(sound_val) =
emit_with_cycling(cmd, i, poly_i, delta_secs, outputs)?
{
if let Some(span) = sound_val.span() {
if let Some(trace) =
trace_cell.borrow_mut().as_mut()
{
trace.selected_spans.push(span);
}
}
}
}
}
} else {
@@ -657,7 +603,7 @@ impl Forth {
}
}
if let Some(sound_val) =
emit_with_cycling(cmd, 0, poly_idx, delta_secs, outputs)?
emit_with_cycling(cmd, poly_idx, delta_secs, outputs)?
{
if let Some(span) = sound_val.span() {
if let Some(trace) =
@@ -1196,12 +1142,60 @@ impl Forth {
cmd.set_deltas(deltas);
}
Op::Arp => {
Op::AtLoop(body_ops) => {
ensure(stack, 1)?;
let values = std::mem::take(stack);
stack.push(Value::ArpList(Arc::from(values)));
let deltas = std::mem::take(stack);
let n = deltas.len();
for (i, delta_val) in deltas.iter().enumerate() {
let frac = delta_val.as_float()?;
let delta_secs = ctx.nudge_secs + frac * ctx.step_duration();
let iter_ctx = StepContext {
step: ctx.step,
beat: ctx.beat,
bank: ctx.bank,
pattern: ctx.pattern,
tempo: ctx.tempo,
phase: ctx.phase,
slot: ctx.slot,
runs: ctx.runs * n + i,
iter: ctx.iter,
speed: ctx.speed,
fill: ctx.fill,
nudge_secs: ctx.nudge_secs,
sr: ctx.sr,
cc_access: ctx.cc_access,
speed_key: ctx.speed_key,
mouse_x: ctx.mouse_x,
mouse_y: ctx.mouse_y,
mouse_down: ctx.mouse_down,
};
cmd.set_delta_secs(delta_secs);
let mut trace_opt = trace_cell.borrow_mut().take();
let mut var_writes_guard = var_writes_cell.borrow_mut();
let vw = var_writes_guard.as_mut().expect("var_writes taken");
self.execute_ops(
body_ops,
&iter_ctx,
stack,
outputs,
cmd,
trace_opt.as_deref_mut(),
vars_snapshot,
vw,
)?;
drop(var_writes_guard);
*trace_cell.borrow_mut() = trace_opt;
cmd.clear_params();
cmd.clear_sound();
}
}
Op::Adsr => {
let r = pop(stack)?;
let s = pop(stack)?;
@@ -1499,35 +1493,13 @@ impl Forth {
// MIDI operations
Op::MidiEmit => {
let at_loop_delta = cmd.take_delta_secs();
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
// Build schedule: (arp_idx, poly_idx, delta_secs)
let schedule: Vec<(usize, usize, f64)> = if has_arp_list(cmd) {
let arp_count = compute_arp_count(cmd);
// Build schedule: (poly_idx, delta_secs)
let schedule: Vec<(usize, f64)> = if let Some(dsecs) = at_loop_delta {
let poly_count = compute_poly_count(cmd);
let explicit = !cmd.deltas().is_empty();
let delta_list = cmd.deltas();
let count = if explicit {
arp_count.max(delta_list.len())
} else {
arp_count
};
let mut sched = Vec::with_capacity(count * poly_count);
for i in 0..count {
let delta_secs = if explicit {
let frac = delta_list[i % delta_list.len()]
.as_float()
.unwrap_or(0.0);
ctx.nudge_secs + frac * ctx.step_duration()
} else {
ctx.nudge_secs
+ (i as f64 / count as f64) * ctx.step_duration()
};
for poly_i in 0..poly_count {
sched.push((i, poly_i, delta_secs));
}
}
sched
(0..poly_count).map(|pi| (pi, dsecs)).collect()
} else {
let poly_count = compute_poly_count(cmd);
let deltas: Vec<f64> = if cmd.deltas().is_empty() {
@@ -1542,7 +1514,6 @@ impl Forth {
for poly_idx in 0..poly_count {
for &frac in &deltas {
sched.push((
0,
poly_idx,
ctx.nudge_secs + frac * ctx.step_duration(),
));
@@ -1551,14 +1522,14 @@ impl Forth {
sched
};
for (arp_idx, poly_idx, delta_secs) in schedule {
for (poly_idx, delta_secs) in schedule {
let get_int = |name: &str| -> Option<i64> {
params
.iter()
.rev()
.find(|(k, _)| *k == name)
.and_then(|(_, v)| {
resolve_value(v, arp_idx, poly_idx).as_int().ok()
resolve_value(v, poly_idx).as_int().ok()
})
};
let get_float = |name: &str| -> Option<f64> {
@@ -1567,7 +1538,7 @@ impl Forth {
.rev()
.find(|(k, _)| *k == name)
.and_then(|(_, v)| {
resolve_value(v, arp_idx, poly_idx).as_float().ok()
resolve_value(v, poly_idx).as_float().ok()
})
};
let chan = get_int("chan")
@@ -1960,10 +1931,6 @@ where
F: Fn(f64) -> f64 + Copy,
{
match val {
Value::ArpList(items) => {
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
Value::CycleList(items) => {
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x, f)).collect();
Ok(Value::CycleList(Arc::from(mapped?)))
@@ -1977,11 +1944,6 @@ where
F: Fn(i64) -> i64 + Copy,
{
match val {
Value::ArpList(items) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_unary_int(x, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
Value::CycleList(items) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_unary_int(x, f)).collect();
@@ -1996,16 +1958,6 @@ where
F: Fn(f64, f64) -> f64 + Copy,
{
match (a, b) {
(Value::ArpList(items), b) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_binary(x, b, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
(a, Value::ArpList(items)) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_binary(a, x, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
(Value::CycleList(items), b) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_binary(x, b, f)).collect();
@@ -2045,11 +1997,8 @@ where
Ok(())
}
fn resolve_value(val: &Value, arp_idx: usize, poly_idx: usize) -> Cow<'_, Value> {
fn resolve_value(val: &Value, poly_idx: usize) -> Cow<'_, Value> {
match val {
Value::ArpList(items) if !items.is_empty() => {
Cow::Owned(items[arp_idx % items.len()].clone())
}
Value::CycleList(items) if !items.is_empty() => {
Cow::Owned(items[poly_idx % items.len()].clone())
}

View File

@@ -88,7 +88,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"tempo!" => Op::SetTempo,
"speed!" => Op::SetSpeed,
"at" => Op::At,
"arp" => Op::Arp,
"adsr" => Op::Adsr,
"ad" => Op::Ad,
"apply" => Op::Apply,

View File

@@ -309,9 +309,9 @@ pub(super) const WORDS: &[Word] = &[
name: "at",
aliases: &[],
category: "Time",
stack: "(v1..vn --)",
desc: "Set delta context for emit timing",
example: "0 0.5 at kick s . => emits at 0 and 0.5 of step",
stack: "(v1..vn -- )",
desc: "Looping block: re-executes body per delta. Close with . (audio), m. (MIDI), or done (no emit)",
example: "0 0.5 at kick snd 1 2 rand freq . | 0 0.5 at 60 note m. | 0 0.5 at !x done",
compile: Simple,
varargs: true,
},

View File

@@ -24,16 +24,6 @@ pub(super) const WORDS: &[Word] = &[
compile: Simple,
varargs: false,
},
Word {
name: "arp",
aliases: &[],
category: "Sound",
stack: "(v1..vn -- arplist)",
desc: "Wrap stack values as arpeggio list for spreading across deltas",
example: "c4 e4 g4 b4 arp note => arpeggio",
compile: Simple,
varargs: true,
},
Word {
name: "clear",
aliases: &[],

View File

@@ -11,6 +11,24 @@ use crate::{Bank, Pattern};
const PATTERN_PREFIX: &str = "cgr:";
const BANK_PREFIX: &str = "cgrb:";
pub enum ImportResult {
Pattern(Pattern),
Bank(Bank),
}
/// Auto-detect format from the prefix and decode.
pub fn import_auto(text: &str) -> Result<ImportResult, ShareError> {
// Strip everything non-ASCII — valid share strings are pure ASCII
let clean: String = text.chars().filter(|c| c.is_ascii_graphic()).collect();
if clean.starts_with(BANK_PREFIX) {
Ok(ImportResult::Bank(decode(&clean, BANK_PREFIX)?))
} else if clean.starts_with(PATTERN_PREFIX) {
Ok(ImportResult::Pattern(decode(&clean, PATTERN_PREFIX)?))
} else {
Err(ShareError::InvalidPrefix)
}
}
/// Error during pattern or bank import/export.
#[derive(Debug)]
pub enum ShareError {
@@ -63,7 +81,12 @@ fn encode<T: serde::Serialize>(value: &T, prefix: &str) -> Result<String, ShareE
fn decode<T: serde::de::DeserializeOwned>(text: &str, prefix: &str) -> Result<T, ShareError> {
let text = text.trim();
let payload = text.strip_prefix(prefix).ok_or(ShareError::InvalidPrefix)?;
let compressed = URL_SAFE_NO_PAD.decode(payload).map_err(ShareError::Base64)?;
// Strip invisible characters that clipboard managers / web copies can inject
let clean: String = payload
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect();
let compressed = URL_SAFE_NO_PAD.decode(&clean).map_err(ShareError::Base64)?;
let packed = decompress(&compressed)?;
rmp_serde::from_slice(&packed).map_err(ShareError::Deserialize)
}
@@ -146,7 +169,7 @@ mod tests {
#[test]
fn bad_base64() {
assert!(matches!(import("cgr:!!!"), Err(ShareError::Base64(_))));
assert!(import("cgr:not-valid-data").is_err());
}
#[test]

View File

@@ -7,7 +7,7 @@
"steps": [
{
"i": 0,
"script": "0 7 .. at\n c2 maj9 arp note\n wide bigverb mysynth \n 2000 1000 0.4 0.8 rand expslide llpf\n 0.4 0.8 rand llpq\n ."
"script": "0 7 .. at\n mysynth [ c2 maj9 ] cycle note\n wide bigverb\n 2000 1000 0.4 0.8 rand expslide llpf\n 0.4 0.8 rand llpq\n ."
},
{
"i": 8,

View File

@@ -34,9 +34,9 @@ clear
snare snd . ;; 1 snare (deltas cleared)
```
## Cross-product: at Without arp
## Polyphonic at
Without `arp`, deltas multiply with polyphonic voices. If you have 3 notes and 2 deltas, you get 6 emits -- every note at every delta:
Deltas multiply with polyphonic voices. If you have 3 notes and 2 deltas, you get 6 emits every note at every delta:
```forth
0 0.5 at
@@ -45,27 +45,25 @@ c4 e4 g4 note 1.5 decay sine snd .
6 emits: 3 notes x 2 deltas. A chord played twice per step.
## 1:1 Pairing: at With arp
## Arpeggios with at + cycle
`arp` changes the behavior. Instead of cross-product, deltas and arp values pair up 1:1. Each delta gets one note from the arpeggio:
Use `cycle` inside an `at` block to pick one note per subdivision:
```forth
0 0.33 0.66 at
c4 e4 g4 arp note 0.5 decay sine snd .
sine snd [ c4 e4 g4 ] cycle note 0.5 decay .
```
C4 at 0, E4 at 0.33, G4 at 0.66.
C4 at 0, E4 at 0.33, G4 at 0.66. `cycle` advances per iteration of the at-loop.
If the lists differ in length, the shorter one wraps around:
If the list is shorter than the number of deltas, it wraps:
```forth
0 0.25 0.5 0.75 at
c4 e4 arp note 0.3 decay sine snd .
sine snd [ c4 e4 ] cycle note 0.3 decay .
```
C4, E4, C4, E4 — the shorter list wraps to fill 4 time points.
This is THE key distinction. Without `arp`: every note at every time. With `arp`: one note per time slot.
C4, E4, C4, E4 — wraps to fill 4 time points.
## Generating Deltas

View File

@@ -1,6 +1,6 @@
# Generators & Sequences
Sequences of values drive music: arpeggios, parameter sweeps, rhythmic patterns. Cagire has dedicated words for building sequences on the stack, transforming them, and collapsing them to single values.
Sequences of values drive music: melodic patterns, parameter sweeps, rhythmic patterns. Cagire has dedicated words for building sequences on the stack, transforming them, and collapsing them to single values.
## Ranges
@@ -81,7 +81,7 @@ Four words reshape values already on the stack. All take n (the count of items t
```forth
1 2 3 4 4 rev ;; 4 3 2 1
c4 e4 g4 3 rev ;; g4 e4 c4 (descending arpeggio)
c4 e4 g4 3 rev ;; g4 e4 c4 (descending)
```
`shuffle` randomizes order:

View File

@@ -302,10 +302,10 @@ Combine with voicings for smoother voice leading:
note 1.5 decay saw snd .
```
Arpeggiate diatonic chords using `arp` (see the *Timing with at* tutorial for details on `arp`):
Arpeggiate diatonic chords using `at` + `cycle` (see the *Timing with at* tutorial):
```forth
0 major seventh arp note 0.5 decay sine snd .
0 0.25 0.5 0.75 at sine snd [ 0 major seventh ] cycle note 0.5 decay .
```
## Frequency Conversion

View File

@@ -1,98 +0,0 @@
; Cagire NSIS Installer Script
; Receives defines from command line:
; -DVERSION=x.y.z
; -DCLI_EXE=/path/to/cagire.exe
; -DDESKTOP_EXE=/path/to/cagire-desktop.exe
; -DICON=/path/to/Cagire.ico
; -DOUTDIR=/path/to/releases
!include "MUI2.nsh"
!include "WordFunc.nsh"
Name "Cagire ${VERSION}"
OutFile "${OUTDIR}\cagire-${VERSION}-windows-x86_64-setup.exe"
InstallDir "$PROGRAMFILES64\Cagire"
InstallDirRegKey HKLM "Software\Cagire" "InstallDir"
RequestExecutionLevel admin
Unicode True
!define MUI_ICON "${ICON}"
!define MUI_UNICON "${ICON}"
!define MUI_ABORTWARNING
!define MUI_HEADERIMAGE
!define MUI_HEADERIMAGE_BITMAP "header.bmp"
!define MUI_WELCOMEFINISHPAGE_BITMAP "sidebar.bmp"
!define MUI_UNWELCOMEFINISHPAGE_BITMAP "sidebar.bmp"
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_COMPONENTS
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
Section "Cagire (required)" SecCore
SectionIn RO
SetOutPath "$INSTDIR"
File "/oname=cagire.exe" "${CLI_EXE}"
File "/oname=cagire-desktop.exe" "${DESKTOP_EXE}"
WriteUninstaller "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "Software\Cagire" "InstallDir" "$INSTDIR"
; Add/Remove Programs entry
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "DisplayName" "Cagire"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "Publisher" "Raphael Forment"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "DisplayIcon" '"$INSTDIR\cagire-desktop.exe"'
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "URLInfoAbout" "https://git.raphaelforment.fr/BuboBubo/cagire"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "HelpLink" "https://cagire.raphaelforment.fr"
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "NoModify" 1
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "NoRepair" 1
SectionEnd
Section "Add to PATH" SecPath
ReadRegStr $0 HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "Path"
StrCpy $0 "$0;$INSTDIR"
WriteRegExpandStr HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "Path" "$0"
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
SectionEnd
Section "Start Menu Shortcut" SecStartMenu
CreateDirectory "$SMPROGRAMS\Cagire"
CreateShortCut "$SMPROGRAMS\Cagire\Cagire.lnk" "$INSTDIR\cagire-desktop.exe" "" "$INSTDIR\cagire-desktop.exe" 0
CreateShortCut "$SMPROGRAMS\Cagire\Uninstall.lnk" "$INSTDIR\uninstall.exe"
SectionEnd
!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
!insertmacro MUI_DESCRIPTION_TEXT ${SecCore} "Installs Cagire CLI and Desktop binaries."
!insertmacro MUI_DESCRIPTION_TEXT ${SecPath} "Add the install location to the PATH system environment variable."
!insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} "Add a Cagire shortcut to the Start Menu."
!insertmacro MUI_FUNCTION_DESCRIPTION_END
Section "Uninstall"
Delete "$INSTDIR\cagire.exe"
Delete "$INSTDIR\cagire-desktop.exe"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\Cagire\Cagire.lnk"
Delete "$SMPROGRAMS\Cagire\Uninstall.lnk"
RMDir "$SMPROGRAMS\Cagire"
; Remove from PATH
ReadRegStr $0 HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "Path"
; Remove ";$INSTDIR" from the path string
${WordReplace} $0 ";$INSTDIR" "" "+" $0
WriteRegExpandStr HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "Path" "$0"
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire"
DeleteRegKey HKLM "Software\Cagire"
SectionEnd

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

View File

@@ -14,7 +14,7 @@ cagire = { path = "../..", default-features = false, features = ["block-renderer
cagire-forth = { path = "../../crates/forth" }
cagire-project = { path = "../../crates/project" }
cagire-ratatui = { path = "../../crates/ratatui" }
doux = { git = "https://github.com/sova-org/doux", tag = "v0.0.15", features = ["native", "soundfont"] }
doux = { git = "https://github.com/sova-org/doux", tag = "v0.0.19", features = ["native", "soundfont"] }
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug", features = ["standalone"] }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug" }
egui_ratatui = "2.1"

View File

@@ -3,7 +3,7 @@
# requires-python = ">=3.11"
# dependencies = ["rich>=13.0", "questionary>=2.0"]
# ///
"""Cagire release builder — replaces build-all.sh, make-dmg.sh, make-appimage.sh."""
"""Cagire release builder."""
from __future__ import annotations
@@ -23,6 +23,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from rich.console import Console, Group
from rich.markup import escape
from rich.live import Live
from rich.panel import Panel
from rich.progress_bar import ProgressBar
@@ -149,6 +150,8 @@ def get_version(root: Path) -> str:
def builder_for(p: Platform) -> str:
if p.os == "windows" and p.cross:
return "cargo-xwin"
return "cross" if p.cross else "cargo"
@@ -194,16 +197,42 @@ def run_cmd(
# Build functions
# ---------------------------------------------------------------------------
def _macos_env(p: Platform) -> dict[str, str] | None:
if p.os == "macos":
return {"MACOSX_DEPLOYMENT_TARGET": "12.0"}
def _find_llvm_prefix() -> str | None:
"""Find Homebrew LLVM installation path."""
try:
result = subprocess.run(
["brew", "--prefix", "llvm"], capture_output=True, text=True,
)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
pass
return None
def build_binary(root: Path, p: Platform, log: list[str], extra_args: list[str] | None = None) -> None:
cmd = [builder_for(p), "build", "--release", *target_flags(p), *(extra_args or [])]
def _cross_env(p: Platform) -> dict[str, str] | None:
env = {}
if p.os == "macos":
env["MACOSX_DEPLOYMENT_TARGET"] = "12.0"
if p.os == "windows" and p.cross:
env["VCINSTALLDIR"] = "/dummy"
llvm_prefix = _find_llvm_prefix()
if llvm_prefix:
env["LIBCLANG_PATH"] = f"{llvm_prefix}/lib"
return env or None
def _platform_features(p: Platform) -> list[str]:
if p.os == "windows":
return ["--features", "asio"]
return []
def build_binary(root: Path, p: Platform, log: list[str], extra_args: list[str] | None = None, platform_features: bool = True) -> None:
features = _platform_features(p) if platform_features else []
cmd = [builder_for(p), "build", "--release", *target_flags(p), *features, *(extra_args or [])]
log.append(f" Building: {' '.join(extra_args or ['default'])}")
run_cmd(cmd, log, env=_macos_env(p), cwd=root)
run_cmd(cmd, log, env=_cross_env(p), cwd=root)
def bundle_plugins(root: Path, p: Platform, log: list[str]) -> None:
@@ -216,12 +245,12 @@ def bundle_plugins(root: Path, p: Platform, log: list[str]) -> None:
def _bundle_plugins_native(root: Path, p: Platform, log: list[str]) -> None:
log.append(" Bundling plugins (native xtask)")
cmd = ["cargo", "xtask", "bundle", PLUGIN_NAME, "--release", *target_flags(p)]
run_cmd(cmd, log, env=_macos_env(p), cwd=root)
run_cmd(cmd, log, env=_cross_env(p), cwd=root)
def _bundle_plugins_cross(root: Path, p: Platform, log: list[str]) -> None:
log.append(" Bundling plugins (cross)")
build_binary(root, p, log, extra_args=["-p", PLUGIN_NAME])
build_binary(root, p, log, extra_args=["-p", PLUGIN_NAME], platform_features=False)
rd = release_dir(root, p)
if p.os == "linux":
@@ -259,7 +288,7 @@ def bundle_desktop_app(root: Path, p: Platform, log: list[str]) -> None:
return
log.append(" Bundling desktop .app")
cmd = ["cargo", "bundle", "--release", "--features", "desktop", "--bin", "cagire-desktop", *target_flags(p)]
run_cmd(cmd, log, env=_macos_env(p), cwd=root)
run_cmd(cmd, log, env=_cross_env(p), cwd=root)
# ---------------------------------------------------------------------------
@@ -436,33 +465,6 @@ def make_appimage(root: Path, binary: Path, arch: str, output_dir: Path, log: li
return _make_appimage_docker(root, binary, arch, output_dir, log)
# ---------------------------------------------------------------------------
# Packaging: NSIS
# ---------------------------------------------------------------------------
def make_nsis(root: Path, rd: Path, version: str, output_dir: Path, log: list[str]) -> str | None:
if not shutil.which("makensis"):
log.append(" makensis not found, skipping NSIS installer")
return None
log.append(" Building NSIS installer")
abs_root = str(root.resolve())
run_cmd([
"makensis",
f"-DVERSION={version}",
f"-DCLI_EXE={abs_root}/{rd.relative_to(root)}/cagire.exe",
f"-DDESKTOP_EXE={abs_root}/{rd.relative_to(root)}/cagire-desktop.exe",
f"-DICON={abs_root}/assets/Cagire.ico",
f"-DOUTDIR={abs_root}/{OUT}",
str(root / "nsis" / "cagire.nsi"),
], log)
installer = f"cagire-{version}-windows-x86_64-setup.exe"
log.append(f" Installer -> {output_dir / installer}")
return str(output_dir / installer)
# ---------------------------------------------------------------------------
# Artifact copying & packaging dispatch
# ---------------------------------------------------------------------------
@@ -472,7 +474,6 @@ def copy_artifacts(root: Path, p: Platform, config: BuildConfig, log: list[str])
rd = release_dir(root, p)
out = root / OUT
sx = suffix_for(p)
version = get_version(root)
artifacts: list[str] = []
if config.cli:
@@ -504,11 +505,6 @@ def copy_artifacts(root: Path, p: Platform, config: BuildConfig, log: list[str])
if dmg:
artifacts.append(dmg)
if p.os == "windows":
nsis = make_nsis(root, rd, version, out, log)
if nsis:
artifacts.append(nsis)
if p.os == "linux":
if config.cli:
ai = make_appimage(root, rd / "cagire", p.arch, out, log)
@@ -647,7 +643,7 @@ def _build_display(
if recent:
lines: list[str] = []
for alias, line in recent:
lines.append(f"[dim]{alias}[/] {line.rstrip()}")
lines.append(f"[dim]{alias}[/] {escape(line.rstrip())}")
log_text = Text("\n") + Text.from_markup("\n".join(lines))
else:
log_text = Text("\nwaiting for output...", style="dim")
@@ -714,9 +710,10 @@ def run_builds(
for p in platforms:
_update_phase(p.alias, "waiting", 0)
# Split into native (share cargo lock) and cross (independent Docker builds)
native_platforms = [p for p in platforms if not p.cross]
cross_platforms = [p for p in platforms if p.cross]
# Docker-isolated cross builds (Linux only — each in its own container)
docker_cross = [p for p in platforms if p.cross and p.os != "windows"]
# Local builds share cargo lock — run sequentially
local_builds = [p for p in platforms if not p.cross or p.os == "windows"]
results: list[PlatformResult] = []
completed: dict[str, PlatformResult] = {}
@@ -729,19 +726,19 @@ def run_builds(
return _build_display(platforms, config, completed, start_times, log_max_lines)
with Live(make_display(), console=console, refresh_per_second=4) as live:
# Native builds run sequentially in one thread (they contend on cargo lock).
# Cross builds run in parallel (each in its own Docker container).
with ThreadPoolExecutor(max_workers=max(len(cross_platforms) + 1, 1)) as pool:
# Local builds run sequentially (they contend on cargo lock).
# Docker cross builds run in parallel (each in its own container).
with ThreadPoolExecutor(max_workers=max(len(docker_cross) + 1, 1)) as pool:
futures = {}
if native_platforms:
if local_builds:
f = pool.submit(
_build_native_sequential, root, native_platforms, config,
_build_native_sequential, root, local_builds, config,
completed, start_times,
)
futures[f] = "native"
for p in cross_platforms:
for p in docker_cross:
f = pool.submit(build_platform, root, p, config)
futures[f] = "cross"
@@ -792,7 +789,7 @@ def _print_platform_log(r: PlatformResult, verbose: bool = False) -> None:
lines = [f" ... ({len(r.log_lines) - _FAILURE_LOG_TAIL} lines omitted, use --verbose for full output)"] + lines[-_FAILURE_LOG_TAIL:]
console.print(Panel(
"\n".join(lines) if lines else "[dim]no output[/]",
"\n".join(escape(l) for l in lines) if lines else "[dim]no output[/]",
title=f"{r.platform.label} [{status}] {r.elapsed:.1f}s",
border_style=style,
))
@@ -917,20 +914,22 @@ def check_git_clean(root: Path) -> tuple[str, bool]:
def check_prerequisites(platforms: list[Platform], config: BuildConfig) -> None:
"""Verify required tools are available, fail fast if not."""
need_cross = any(p.cross for p in platforms)
need_xwin = any(p.os == "windows" and p.cross for p in platforms)
need_cross = any(p.cross and p.os != "windows" for p in platforms)
need_docker = any(p.cross and p.os == "linux" for p in platforms)
need_bundle = config.desktop and any(not p.cross and p.os == "macos" for p in platforms)
need_nsis = any(p.os == "windows" for p in platforms)
checks: list[tuple[str, bool]] = [("cargo", True)]
if need_xwin:
checks.append(("cargo-xwin", True))
checks.append(("clang", True))
checks.append(("cmake", True))
if need_cross:
checks.append(("cross", True))
if need_docker:
checks.append(("docker", True))
if need_bundle:
checks.append(("cargo-bundle", True))
if need_nsis:
checks.append(("makensis", False))
console.print("[bold]Prerequisites:[/]")
missing_critical: list[str] = []

View File

@@ -1,19 +0,0 @@
FROM ghcr.io/cross-rs/x86_64-pc-windows-gnu:main
RUN apt-get update && \
apt-get install -y --no-install-recommends \
cmake \
clang \
libclang-dev \
mingw-w64-tools \
mingw-w64-x86-64-dev \
g++-mingw-w64-x86-64 \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf windows.h /usr/x86_64-w64-mingw32/include/Windows.h \
&& ln -sf winsock2.h /usr/x86_64-w64-mingw32/include/WinSock2.h \
&& ln -sf ws2tcpip.h /usr/x86_64-w64-mingw32/include/WS2tcpip.h \
&& GCCDIR=$(ls -d /usr/lib/gcc/x86_64-w64-mingw32/*-posix 2>/dev/null | head -1) \
&& ln -sf "$GCCDIR/libstdc++.a" /usr/x86_64-w64-mingw32/lib/libstdc++.a \
&& ln -sf "$GCCDIR/libgcc.a" /usr/x86_64-w64-mingw32/lib/libgcc.a \
&& rm -f /usr/x86_64-w64-mingw32/lib/libstdc++.dll.a \
&& rm -f /usr/lib/gcc/x86_64-w64-mingw32/*/libstdc++.dll.a

View File

@@ -5,5 +5,5 @@ triples = [
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-pc-windows-gnu",
"x86_64-pc-windows-msvc",
]

View File

@@ -222,7 +222,7 @@ impl App {
}
}
pub fn import_bank(&mut self, bank: usize) {
pub fn import_shared(&mut self, bank: usize, pattern: usize) {
let text = match arboard::Clipboard::new().ok().and_then(|mut c| c.get_text().ok()) {
Some(t) => t,
None => {
@@ -230,36 +230,8 @@ impl App {
return;
}
};
match model::share::import_bank(&text) {
Ok(imported) => {
self.project_state.project.banks[bank] = imported;
for pattern in 0..model::MAX_PATTERNS {
self.project_state.mark_dirty(bank, pattern);
}
if self.editor_ctx.bank == bank {
self.load_step_to_editor();
}
self.ui
.flash("Bank imported", 150, FlashKind::Success);
}
Err(e) => {
self.ui
.flash(&format!("Import failed: {e}"), 200, FlashKind::Error);
}
}
}
pub fn import_pattern(&mut self, bank: usize, pattern: usize) {
let text = match arboard::Clipboard::new().ok().and_then(|mut c| c.get_text().ok()) {
Some(t) => t,
None => {
self.ui
.flash("Clipboard empty", 150, FlashKind::Error);
return;
}
};
match model::share::import(&text) {
Ok(imported) => {
match model::share::import_auto(&text) {
Ok(model::share::ImportResult::Pattern(imported)) => {
self.project_state.project.banks[bank].patterns[pattern] = imported;
self.project_state.mark_dirty(bank, pattern);
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
@@ -268,6 +240,17 @@ impl App {
self.ui
.flash("Pattern imported", 150, FlashKind::Success);
}
Ok(model::share::ImportResult::Bank(imported)) => {
self.project_state.project.banks[bank] = imported;
for p in 0..model::MAX_PATTERNS {
self.project_state.mark_dirty(bank, p);
}
if self.editor_ctx.bank == bank {
self.load_step_to_editor();
}
self.ui
.flash("Bank imported", 150, FlashKind::Success);
}
Err(e) => {
self.ui
.flash(&format!("Import failed: {e}"), 200, FlashKind::Error);

View File

@@ -111,9 +111,8 @@ impl App {
AppCommand::CopyPattern { bank, pattern } => self.copy_pattern(bank, pattern),
AppCommand::PastePattern { bank, pattern } => self.paste_pattern(bank, pattern),
AppCommand::SharePattern { bank, pattern } => self.share_pattern(bank, pattern),
AppCommand::ImportPattern { bank, pattern } => self.import_pattern(bank, pattern),
AppCommand::ImportShared { bank, pattern } => self.import_shared(bank, pattern),
AppCommand::ShareBank { bank } => self.share_bank(bank),
AppCommand::ImportBank { bank } => self.import_bank(bank),
AppCommand::CopyPatterns { bank, patterns } => self.copy_patterns(bank, &patterns),
AppCommand::PastePatterns { bank, start } => self.paste_patterns(bank, start),
AppCommand::CopyBank { bank } => self.copy_bank(bank),
@@ -376,7 +375,11 @@ impl App {
AppCommand::AudioSettingPrev => self.audio.prev_setting(self.plugin_mode),
AppCommand::SetOutputDevice(name) => self.audio.config.output_device = Some(name),
AppCommand::SetInputDevice(name) => self.audio.config.input_device = Some(name),
AppCommand::SetDeviceKind(kind) => self.audio.device_kind = kind,
AppCommand::SetDevicesFocus(focus) => self.audio.devices_focus = focus,
AppCommand::CycleHost { right } => {
self.audio.cycle_host(right);
self.audio.trigger_restart();
}
AppCommand::AdjustAudioSetting { setting, delta } => {
use crate::state::SettingKind;
match setting {

View File

@@ -16,6 +16,7 @@ impl App {
pub fn save_settings(&self, link: &LinkState) {
let settings = Settings {
audio: crate::settings::AudioSettings {
host: self.audio.config.selected_host.clone(),
output_device: self.audio.config.output_device.clone(),
input_device: self.audio.config.input_device.clone(),
channels: self.audio.config.channels,

View File

@@ -28,7 +28,6 @@ impl App {
| AppCommand::DeleteSteps { bank, pattern, .. }
| AppCommand::ResetPattern { bank, pattern }
| AppCommand::PastePattern { bank, pattern }
| AppCommand::ImportPattern { bank, pattern }
| AppCommand::RenamePattern { bank, pattern, .. } => {
let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
@@ -47,9 +46,9 @@ impl App {
let data = self.project_state.project.banks[bank].clone();
Some(UndoScope::Bank { bank, data })
}
AppCommand::ResetBank { bank }
| AppCommand::PasteBank { bank }
| AppCommand::ImportBank { bank } => {
AppCommand::ImportShared { bank, .. }
| AppCommand::ResetBank { bank }
| AppCommand::PasteBank { bank } => {
let data = self.project_state.project.banks[*bank].clone();
Some(UndoScope::Bank { bank: *bank, data })
}

View File

@@ -131,6 +131,7 @@ impl CagireDesktop {
let new_audio_rx = sequencer.swap_audio_channel();
let new_config = AudioStreamConfig {
host: self.app.audio.config.selected_host.clone(),
output_device: self.app.audio.config.output_device.clone(),
input_device: self.app.audio.config.input_device.clone(),
channels: self.app.audio.config.channels,

View File

@@ -4,7 +4,7 @@ use std::path::PathBuf;
use crate::model::{FollowUp, LaunchQuantization, PatternSpeed};
use crate::page::Page;
use crate::state::{ColorScheme, DeviceKind, Modal, OptionsFocus, PatternField, ScriptField, SettingKind};
use crate::state::{ColorScheme, DevicesFocus, Modal, OptionsFocus, PatternField, ScriptField, SettingKind};
pub enum AppCommand {
// Undo/Redo
@@ -75,16 +75,13 @@ pub enum AppCommand {
bank: usize,
pattern: usize,
},
ImportPattern {
ImportShared {
bank: usize,
pattern: usize,
},
ShareBank {
bank: usize,
},
ImportBank {
bank: usize,
},
CopyPatterns {
bank: usize,
patterns: Vec<usize>,
@@ -255,7 +252,8 @@ pub enum AppCommand {
AudioSettingPrev,
SetOutputDevice(String),
SetInputDevice(String),
SetDeviceKind(DeviceKind),
SetDevicesFocus(DevicesFocus),
CycleHost { right: bool },
AdjustAudioSetting {
setting: SettingKind,
delta: i32,

View File

@@ -268,6 +268,8 @@ pub fn preload_sample_heads(
#[cfg(feature = "cli")]
use cpal::traits::{DeviceTrait, StreamTrait};
#[cfg(feature = "cli")]
use cpal::FromSample;
#[cfg(feature = "cli")]
use cpal::Stream;
#[cfg(feature = "cli")]
use crossbeam_channel::{Receiver, Sender};
@@ -279,6 +281,7 @@ use super::AudioCommand;
#[cfg(feature = "cli")]
pub struct AudioStreamConfig {
pub host: Option<String>,
pub output_device: Option<String>,
pub input_device: Option<String>,
pub channels: u16,
@@ -317,10 +320,16 @@ pub fn build_stream(
sample_paths: &[std::path::PathBuf],
device_lost: Arc<AtomicBool>,
) -> Result<BuildStreamResult, String> {
let selection = match &config.host {
Some(name) => doux::audio::HostSelection::Named(name.to_lowercase()),
None => doux::audio::HostSelection::Auto,
};
let host = doux::audio::get_host(selection).map_err(|e| format!("{e}"))?;
let device = match &config.output_device {
Some(name) => doux::audio::find_output_device(name)
Some(name) => doux::audio::find_output_device_for(&host, name)
.ok_or_else(|| format!("Device not found: {name}"))?,
None => doux::audio::default_output_device().ok_or("No default output device")?,
None => doux::audio::default_output_device_for(&host).ok_or("No default output device")?,
};
let default_config = device.default_output_config().map_err(|e| e.to_string())?;
@@ -329,10 +338,10 @@ pub fn build_stream(
let max_channels = doux::audio::max_output_channels(&device);
let channels = config.channels.min(max_channels).max(2);
let host_name = doux::audio::preferred_host().id().name().to_string();
let is_jack = host_name.to_lowercase().contains("jack");
let host_name = host.id().name().to_string();
let host_managed_buffer = doux::audio::host_controls_buffer_size(&host);
let buffer_size = if config.buffer_size > 0 && !is_jack {
let buffer_size = if config.buffer_size > 0 && !host_managed_buffer {
cpal::BufferSize::Fixed(config.buffer_size)
} else {
cpal::BufferSize::Default
@@ -370,7 +379,7 @@ pub fn build_stream(
.input_device
.as_ref()
.and_then(|name| {
let dev = doux::audio::find_input_device(name);
let dev = doux::audio::find_input_device_for(&host, name);
if dev.is_none() {
eprintln!("input device not found: {name}");
}
@@ -414,29 +423,46 @@ pub fn build_stream(
input_cfg.channels(),
input_cfg.sample_rate()
);
let input_format = input_cfg.sample_format();
let mut input_producer = input_producer;
let stream = dev
.build_input_stream(
&input_cfg.into(),
move |data: &[f32], _| {
input_producer.push_slice(data);
},
{
let device_lost = Arc::clone(&device_lost);
move |err: cpal::StreamError| {
eprintln!("input stream error: {err}");
match err {
cpal::StreamError::DeviceNotAvailable
| cpal::StreamError::StreamInvalidated => {
device_lost.store(true, Ordering::Release);
}
_ => {}
macro_rules! build_input {
($T:ty) => {{
let mut scratch: Vec<f32> = Vec::new();
dev.build_input_stream(
&input_cfg.into(),
move |data: &[$T], _| {
scratch.resize(data.len(), 0.0);
for (dst, &src) in scratch.iter_mut().zip(data.iter()) {
*dst = <f32 as FromSample<$T>>::from_sample_(src);
}
}
},
None,
)
.ok()?;
input_producer.push_slice(&scratch);
},
{
let device_lost = Arc::clone(&device_lost);
move |err: cpal::StreamError| {
eprintln!("input stream error: {err}");
match err {
cpal::StreamError::DeviceNotAvailable
| cpal::StreamError::StreamInvalidated => {
device_lost.store(true, Ordering::Release);
}
_ => {}
}
}
},
None,
)
}};
}
let stream = match input_format {
cpal::SampleFormat::F32 => build_input!(f32),
cpal::SampleFormat::I32 => build_input!(i32),
cpal::SampleFormat::I16 => build_input!(i16),
_ => return None,
}
.ok()?;
stream.play().ok()?;
Some(stream)
});
@@ -448,94 +474,109 @@ pub fn build_stream(
let mut live_scratch = vec![0.0f32; 4096];
let mut input_consumer = input_consumer;
let mut current_pos: u64 = 0;
let output_format = default_config.sample_format();
let stream = device
.build_output_stream(
&stream_config,
move |data: &mut [f32], _| {
if !rt_set {
let ok = super::realtime::set_realtime_priority();
rt_set = true;
if !ok {
super::realtime::warn_no_rt("audio");
macro_rules! build_output {
($T:ty) => {{
let mut conv_buf: Vec<f32> = Vec::new();
device.build_output_stream(
&stream_config,
move |data: &mut [$T], _| {
conv_buf.resize(data.len(), 0.0f32);
if !rt_set {
let ok = super::realtime::set_realtime_priority();
rt_set = true;
if !ok {
super::realtime::warn_no_rt("audio");
}
}
}
let buffer_samples = data.len() / channels;
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
let buffer_samples = conv_buf.len() / channels;
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
while let Ok(cmd) = audio_rx.try_recv() {
match cmd {
AudioCommand::Evaluate { cmd, tick } => {
let cmd_ref = match tick {
Some(t) => {
cmd_buffer.clear();
use std::fmt::Write;
let _ = write!(&mut cmd_buffer, "{cmd}/tick/{t}");
cmd_buffer.as_str()
while let Ok(cmd) = audio_rx.try_recv() {
match cmd {
AudioCommand::Evaluate { cmd, tick } => {
let cmd_ref = match tick {
Some(t) => {
cmd_buffer.clear();
use std::fmt::Write;
let _ = write!(&mut cmd_buffer, "{cmd}/tick/{t}");
cmd_buffer.as_str()
}
None => &cmd,
};
engine.evaluate(cmd_ref);
}
AudioCommand::Hush => {
engine.hush();
}
AudioCommand::Panic => {
engine.panic();
}
AudioCommand::LoadSamples(samples) => {
engine.sample_index.extend(samples);
}
AudioCommand::LoadSoundfont(path) => {
if let Err(e) = engine.load_soundfont(&path) {
eprintln!("Failed to load soundfont: {e}");
}
None => &cmd,
};
engine.evaluate(cmd_ref);
}
AudioCommand::Hush => {
engine.hush();
}
AudioCommand::Panic => {
engine.panic();
}
AudioCommand::LoadSamples(samples) => {
engine.sample_index.extend(samples);
}
AudioCommand::LoadSoundfont(path) => {
if let Err(e) = engine.load_soundfont(&path) {
eprintln!("Failed to load soundfont: {e}");
}
}
}
}
let nch_in = input_channels.max(1);
let raw_len = buffer_samples * nch_in;
if live_scratch.len() < raw_len {
live_scratch.resize(raw_len, 0.0);
}
live_scratch[..raw_len].fill(0.0);
input_consumer.pop_slice(&mut live_scratch[..raw_len]);
engine.metrics.load.set_buffer_time(buffer_time_ns);
engine.process_block(data, &[], &live_scratch[..raw_len]);
// Publish accurate audio reference AFTER process_block
// so sample_pos matches doux's internal tick exactly.
current_pos += buffer_samples as u64;
audio_ref.store(Arc::new(AudioRef {
sample_pos: current_pos,
timestamp: Instant::now(),
sample_rate: sr as f64,
}));
scope_buffer.write(data);
// Feed mono mix to analysis thread via ring buffer (non-blocking)
for chunk in data.chunks(channels) {
let mono = chunk.iter().sum::<f32>() / channels as f32;
let _ = fft_producer.try_push(mono);
}
},
move |err: cpal::StreamError| {
let _ = error_tx.try_send(format!("stream error: {err}"));
match err {
cpal::StreamError::DeviceNotAvailable
| cpal::StreamError::StreamInvalidated => {
device_lost.store(true, Ordering::Release);
let nch_in = input_channels.max(1);
let raw_len = buffer_samples * nch_in;
if live_scratch.len() < raw_len {
live_scratch.resize(raw_len, 0.0);
}
_ => {}
}
},
None,
)
.map_err(|e| format!("Failed to build stream: {e}"))?;
live_scratch[..raw_len].fill(0.0);
input_consumer.pop_slice(&mut live_scratch[..raw_len]);
engine.metrics.load.set_buffer_time(buffer_time_ns);
engine.process_block(&mut conv_buf, &[], &live_scratch[..raw_len]);
current_pos += buffer_samples as u64;
audio_ref.store(Arc::new(AudioRef {
sample_pos: current_pos,
timestamp: Instant::now(),
sample_rate: sr as f64,
}));
scope_buffer.write(&conv_buf);
for chunk in conv_buf.chunks(channels) {
let mono = chunk.iter().sum::<f32>() / channels as f32;
let _ = fft_producer.try_push(mono);
}
for (out, &src) in data.iter_mut().zip(conv_buf.iter()) {
*out = <$T as FromSample<f32>>::from_sample_(src);
}
},
move |err: cpal::StreamError| {
let _ = error_tx.try_send(format!("stream error: {err}"));
match err {
cpal::StreamError::DeviceNotAvailable
| cpal::StreamError::StreamInvalidated => {
device_lost.store(true, Ordering::Release);
}
_ => {}
}
},
None,
)
}};
}
let stream = match output_format {
cpal::SampleFormat::F32 => build_output!(f32),
cpal::SampleFormat::I32 => build_output!(i32),
cpal::SampleFormat::I16 => build_output!(i16),
format => return Err(format!("unsupported output sample format: {format:?}")),
}
.map_err(|e| format!("Failed to build stream: {e}"))?;
stream
.play()

View File

@@ -99,6 +99,18 @@ pub fn init(args: InitArgs) -> Init {
});
}
app.audio.config.selected_host = settings.audio.host.clone();
if let Some(ref host_name) = app.audio.config.selected_host {
if let Some(idx) = app
.audio
.available_hosts
.iter()
.position(|h| &h.name == host_name)
{
app.audio.host_index = idx;
app.audio.refresh_devices_for_host();
}
}
app.audio.config.output_device = args.output.or(settings.audio.output_device.clone());
app.audio.config.input_device = args.input.or(settings.audio.input_device.clone());
app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels);
@@ -210,6 +222,7 @@ pub fn init(args: InitArgs) -> Init {
let (stream_error_tx, stream_error_rx) = crossbeam_channel::bounded(16);
let stream_config = AudioStreamConfig {
host: app.audio.config.selected_host.clone(),
output_device: app.audio.config.output_device.clone(),
input_device: app.audio.config.input_device.clone(),
channels: app.audio.config.channels,

View File

@@ -4,7 +4,7 @@ use std::sync::atomic::Ordering;
use super::{InputContext, InputResult};
use crate::commands::AppCommand;
use crate::engine::{AudioCommand, SeqCommand};
use crate::state::{ConfirmAction, DeviceKind, EngineSection, LinkSetting, Modal, SettingKind};
use crate::state::{ConfirmAction, DevicesFocus, EngineSection, LinkSetting, Modal, SettingKind};
pub(crate) fn cycle_engine_setting(ctx: &mut InputContext, right: bool) {
let sign = if right { 1 } else { -1 };
@@ -13,10 +13,14 @@ pub(crate) fn cycle_engine_setting(ctx: &mut InputContext, right: bool) {
setting: SettingKind::Channels,
delta: sign,
}),
SettingKind::BufferSize => ctx.dispatch(AppCommand::AdjustAudioSetting {
setting: SettingKind::BufferSize,
delta: sign * 64,
}),
SettingKind::BufferSize => {
if !ctx.app.audio.host_controls_buffer() {
ctx.dispatch(AppCommand::AdjustAudioSetting {
setting: SettingKind::BufferSize,
delta: sign * 64,
});
}
}
SettingKind::Polyphony => ctx.dispatch(AppCommand::AdjustAudioSetting {
setting: SettingKind::Polyphony,
delta: sign,
@@ -146,9 +150,22 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
KeyCode::Tab => ctx.dispatch(AppCommand::AudioNextSection),
KeyCode::BackTab => ctx.dispatch(AppCommand::AudioPrevSection),
KeyCode::Up => match ctx.app.audio.section {
EngineSection::Devices if !ctx.app.plugin_mode => match ctx.app.audio.device_kind {
DeviceKind::Output => ctx.dispatch(AppCommand::AudioOutputListUp),
DeviceKind::Input => ctx.dispatch(AppCommand::AudioInputListUp),
EngineSection::Devices if !ctx.app.plugin_mode => match ctx.app.audio.devices_focus {
DevicesFocus::Host => {}
DevicesFocus::Output => {
if ctx.app.audio.output_list.cursor == 0 {
ctx.dispatch(AppCommand::SetDevicesFocus(DevicesFocus::Host));
} else {
ctx.dispatch(AppCommand::AudioOutputListUp);
}
}
DevicesFocus::Input => {
if ctx.app.audio.input_list.cursor == 0 {
ctx.dispatch(AppCommand::SetDevicesFocus(DevicesFocus::Host));
} else {
ctx.dispatch(AppCommand::AudioInputListUp);
}
}
},
EngineSection::Settings => {
ctx.dispatch(AppCommand::AudioSettingPrev);
@@ -168,12 +185,15 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
_ => {}
},
KeyCode::Down => match ctx.app.audio.section {
EngineSection::Devices if !ctx.app.plugin_mode => match ctx.app.audio.device_kind {
DeviceKind::Output => {
EngineSection::Devices if !ctx.app.plugin_mode => match ctx.app.audio.devices_focus {
DevicesFocus::Host => {
ctx.dispatch(AppCommand::SetDevicesFocus(DevicesFocus::Output));
}
DevicesFocus::Output => {
let count = ctx.app.audio.output_devices.len();
ctx.dispatch(AppCommand::AudioOutputListDown(count));
}
DeviceKind::Input => {
DevicesFocus::Input => {
let count = ctx.app.audio.input_devices.len();
ctx.dispatch(AppCommand::AudioInputListDown(count));
}
@@ -198,20 +218,22 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
},
KeyCode::PageUp => {
if !ctx.app.plugin_mode && ctx.app.audio.section == EngineSection::Devices {
match ctx.app.audio.device_kind {
DeviceKind::Output => ctx.dispatch(AppCommand::AudioOutputPageUp),
DeviceKind::Input => ctx.app.audio.input_list.page_up(),
match ctx.app.audio.devices_focus {
DevicesFocus::Host => {}
DevicesFocus::Output => ctx.dispatch(AppCommand::AudioOutputPageUp),
DevicesFocus::Input => ctx.app.audio.input_list.page_up(),
}
}
}
KeyCode::PageDown => {
if !ctx.app.plugin_mode && ctx.app.audio.section == EngineSection::Devices {
match ctx.app.audio.device_kind {
DeviceKind::Output => {
match ctx.app.audio.devices_focus {
DevicesFocus::Host => {}
DevicesFocus::Output => {
let count = ctx.app.audio.output_devices.len();
ctx.dispatch(AppCommand::AudioOutputPageDown(count));
}
DeviceKind::Input => {
DevicesFocus::Input => {
let count = ctx.app.audio.input_devices.len();
ctx.dispatch(AppCommand::AudioInputPageDown(count));
}
@@ -220,8 +242,9 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
}
KeyCode::Enter => {
if !ctx.app.plugin_mode && ctx.app.audio.section == EngineSection::Devices {
match ctx.app.audio.device_kind {
DeviceKind::Output => {
match ctx.app.audio.devices_focus {
DevicesFocus::Host => {}
DevicesFocus::Output => {
let cursor = ctx.app.audio.output_list.cursor;
if cursor < ctx.app.audio.output_devices.len() {
let name = ctx.app.audio.output_devices[cursor].name.clone();
@@ -229,7 +252,7 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
ctx.app.save_settings(ctx.link);
}
}
DeviceKind::Input => {
DevicesFocus::Input => {
let cursor = ctx.app.audio.input_list.cursor;
if cursor < ctx.app.audio.input_devices.len() {
let name = ctx.app.audio.input_devices[cursor].name.clone();
@@ -241,9 +264,16 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
}
}
KeyCode::Left => match ctx.app.audio.section {
EngineSection::Devices if !ctx.app.plugin_mode => {
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output));
}
EngineSection::Devices if !ctx.app.plugin_mode => match ctx.app.audio.devices_focus {
DevicesFocus::Host => {
ctx.dispatch(AppCommand::CycleHost { right: false });
ctx.app.save_settings(ctx.link);
}
DevicesFocus::Output => {}
DevicesFocus::Input => {
ctx.dispatch(AppCommand::SetDevicesFocus(DevicesFocus::Output));
}
},
EngineSection::Settings => cycle_engine_setting(ctx, false),
EngineSection::Link => cycle_link_setting(ctx, false),
EngineSection::MidiOutput => cycle_midi_output(ctx, false),
@@ -251,9 +281,16 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
_ => {}
},
KeyCode::Right => match ctx.app.audio.section {
EngineSection::Devices if !ctx.app.plugin_mode => {
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input));
}
EngineSection::Devices if !ctx.app.plugin_mode => match ctx.app.audio.devices_focus {
DevicesFocus::Host => {
ctx.dispatch(AppCommand::CycleHost { right: true });
ctx.app.save_settings(ctx.link);
}
DevicesFocus::Output => {
ctx.dispatch(AppCommand::SetDevicesFocus(DevicesFocus::Input));
}
DevicesFocus::Input => {}
},
EngineSection::Settings => cycle_engine_setting(ctx, true),
EngineSection::Link => cycle_link_setting(ctx, true),
EngineSection::MidiOutput => cycle_midi_output(ctx, true),

View File

@@ -202,7 +202,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
}
KeyCode::Char('G') => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.dispatch(AppCommand::ImportPattern { bank, pattern });
ctx.dispatch(AppCommand::ImportShared { bank, pattern });
}
_ => {}
}

View File

@@ -699,8 +699,8 @@ fn execute_confirm(ctx: &mut InputContext, action: &ConfirmAction) -> InputResul
ConfirmAction::ResetBanks { banks } => {
ctx.dispatch(AppCommand::ResetBanks { banks: banks.clone() });
}
ConfirmAction::ImportBank { bank } => {
ctx.dispatch(AppCommand::ImportBank { bank: *bank });
ConfirmAction::ImportShared { bank, pattern } => {
ctx.dispatch(AppCommand::ImportShared { bank: *bank, pattern: *pattern });
}
}
ctx.dispatch(AppCommand::CloseModal);

View File

@@ -279,14 +279,14 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp
}
KeyCode::Char('G') => {
let bank = ctx.app.patterns_nav.bank_cursor;
let pattern = ctx.app.patterns_nav.pattern_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::ImportPattern { bank, pattern });
ctx.dispatch(AppCommand::ImportShared { bank, pattern });
}
PatternsColumn::Banks => {
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::ImportBank { bank },
action: ConfirmAction::ImportShared { bank, pattern },
selected: false,
}));
}

View File

@@ -126,6 +126,7 @@ fn main() -> io::Result<()> {
let new_audio_rx = sequencer.swap_audio_channel();
let new_config = AudioStreamConfig {
host: app.audio.config.selected_host.clone(),
output_device: app.audio.config.output_device.clone(),
input_device: app.audio.config.input_device.clone(),
channels: app.audio.config.channels,

View File

@@ -395,15 +395,15 @@ pub static COMMANDS: LazyLock<Vec<CommandEntry>> = LazyLock::new(|| {
})),
},
CommandEntry {
name: "Import Pattern",
description: "Import pattern from clipboard",
name: "Import Shared",
description: "Import pattern or bank from clipboard",
category: "Pattern",
keybinding: "G",
pages: &[Main, Patterns],
normal_mode: false,
action: Some(PaletteAction::Resolve(|app| {
let (bank, pattern) = (app.editor_ctx.bank, app.editor_ctx.pattern);
Some(AppCommand::ImportPattern { bank, pattern })
Some(AppCommand::ImportShared { bank, pattern })
})),
},
CommandEntry {
@@ -503,8 +503,9 @@ pub static COMMANDS: LazyLock<Vec<CommandEntry>> = LazyLock::new(|| {
normal_mode: false,
action: Some(PaletteAction::Resolve(|app| {
let bank = app.editor_ctx.bank;
let pattern = app.editor_ctx.pattern;
Some(AppCommand::OpenModal(Modal::Confirm {
action: crate::state::ConfirmAction::ImportBank { bank },
action: crate::state::ConfirmAction::ImportShared { bank, pattern },
selected: false,
}))
})),

View File

@@ -26,6 +26,8 @@ pub struct Settings {
#[derive(Debug, Serialize, Deserialize)]
pub struct AudioSettings {
#[serde(default)]
pub host: Option<String>,
pub output_device: Option<String>,
pub input_device: Option<String>,
pub channels: u16,
@@ -96,6 +98,7 @@ pub struct LinkSettings {
impl Default for AudioSettings {
fn default() -> Self {
Self {
host: None,
output_device: None,
input_device: None,
channels: 2,

View File

@@ -105,6 +105,7 @@ impl RefreshRate {
#[derive(Clone)]
pub struct AudioConfig {
pub selected_host: Option<String>,
pub output_device: Option<String>,
pub input_device: Option<String>,
pub channels: u16,
@@ -133,6 +134,7 @@ pub struct AudioConfig {
impl Default for AudioConfig {
fn default() -> Self {
Self {
selected_host: None,
output_device: None,
input_device: None,
channels: 2,
@@ -234,7 +236,8 @@ impl CyclicEnum for LinkSetting {
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum DeviceKind {
pub enum DevicesFocus {
Host,
#[default]
Output,
Input,
@@ -293,11 +296,13 @@ impl Default for Metrics {
pub struct AudioSettings {
pub config: AudioConfig,
pub section: EngineSection,
pub device_kind: DeviceKind,
pub devices_focus: DevicesFocus,
pub setting_kind: SettingKind,
pub link_setting: LinkSetting,
pub midi_output_slot: usize,
pub midi_input_slot: usize,
pub available_hosts: Vec<doux::audio::AudioHostInfo>,
pub host_index: usize,
pub output_devices: Vec<AudioDeviceInfo>,
pub input_devices: Vec<AudioDeviceInfo>,
pub output_list: ListSelectState,
@@ -310,16 +315,25 @@ pub struct AudioSettings {
impl Default for AudioSettings {
fn default() -> Self {
let hosts = doux::audio::list_hosts();
let preferred = doux::audio::preferred_host();
let preferred_name = preferred.id().name().to_string();
let host_index = hosts
.iter()
.position(|h| h.name == preferred_name)
.unwrap_or(0);
Self {
config: AudioConfig::default(),
section: EngineSection::default(),
device_kind: DeviceKind::default(),
devices_focus: DevicesFocus::default(),
setting_kind: SettingKind::default(),
link_setting: LinkSetting::default(),
midi_output_slot: 0,
midi_input_slot: 0,
output_devices: doux::audio::list_output_devices(),
input_devices: doux::audio::list_input_devices(),
available_hosts: hosts,
host_index,
output_devices: doux::audio::list_output_devices_for(&preferred),
input_devices: doux::audio::list_input_devices_for(&preferred),
output_list: ListSelectState {
cursor: 0,
scroll_offset: 0,
@@ -344,11 +358,13 @@ impl AudioSettings {
Self {
config: AudioConfig::default(),
section: EngineSection::Settings,
device_kind: DeviceKind::default(),
devices_focus: DevicesFocus::default(),
setting_kind: SettingKind::Polyphony,
link_setting: LinkSetting::default(),
midi_output_slot: 0,
midi_input_slot: 0,
available_hosts: Vec::new(),
host_index: 0,
output_devices: Vec::new(),
input_devices: Vec::new(),
output_list: ListSelectState {
@@ -370,8 +386,75 @@ impl AudioSettings {
}
pub fn refresh_devices(&mut self) {
self.output_devices = doux::audio::list_output_devices();
self.input_devices = doux::audio::list_input_devices();
self.refresh_devices_for_host();
}
pub fn refresh_devices_for_host(&mut self) {
let host_name = self
.available_hosts
.get(self.host_index)
.map(|h| h.name.clone());
let selection = match host_name {
Some(name) => doux::audio::HostSelection::Named(name.to_lowercase()),
None => doux::audio::HostSelection::Auto,
};
if let Ok(host) = doux::audio::get_host(selection) {
self.output_devices = doux::audio::list_output_devices_for(&host);
self.input_devices = doux::audio::list_input_devices_for(&host);
} else {
self.output_devices = Vec::new();
self.input_devices = Vec::new();
}
self.output_list.cursor = 0;
self.output_list.scroll_offset = 0;
self.input_list.cursor = 0;
self.input_list.scroll_offset = 0;
}
pub fn cycle_host(&mut self, right: bool) {
let available: Vec<usize> = self
.available_hosts
.iter()
.enumerate()
.filter(|(_, h)| h.available)
.map(|(i, _)| i)
.collect();
if available.len() <= 1 {
return;
}
let current_pos = available
.iter()
.position(|&i| i == self.host_index)
.unwrap_or(0);
let new_pos = if right {
(current_pos + 1) % available.len()
} else if current_pos == 0 {
available.len() - 1
} else {
current_pos - 1
};
self.host_index = available[new_pos];
let name = self.available_hosts[self.host_index].name.clone();
self.config.selected_host = Some(name);
self.config.output_device = None;
self.config.input_device = None;
self.refresh_devices_for_host();
}
pub fn host_controls_buffer(&self) -> bool {
let host_name = self
.available_hosts
.get(self.host_index)
.map(|h| h.name.clone());
let selection = match host_name {
Some(name) => doux::audio::HostSelection::Named(name.to_lowercase()),
None => doux::audio::HostSelection::Auto,
};
if let Ok(host) = doux::audio::get_host(selection) {
doux::audio::host_controls_buffer_size(&host)
} else {
false
}
}
pub fn next_section(&mut self, plugin_mode: bool) {
@@ -448,11 +531,23 @@ impl AudioSettings {
pub fn current_output_device_index(&self) -> usize {
match &self.config.output_device {
Some(name) => self
.output_devices
.iter()
.position(|d| &d.name == name)
.unwrap_or(0),
Some(spec) => {
let spec_lower = spec.to_lowercase();
self.output_devices
.iter()
.position(|d| d.name == *spec)
.or_else(|| {
self.output_devices
.iter()
.position(|d| d.name.to_lowercase() == spec_lower)
})
.or_else(|| {
spec.parse::<usize>().ok().and_then(|idx| {
self.output_devices.iter().position(|d| d.index == idx)
})
})
.unwrap_or(0)
}
None => self
.output_devices
.iter()
@@ -463,11 +558,23 @@ impl AudioSettings {
pub fn current_input_device_index(&self) -> usize {
match &self.config.input_device {
Some(name) => self
.input_devices
.iter()
.position(|d| &d.name == name)
.unwrap_or(0),
Some(spec) => {
let spec_lower = spec.to_lowercase();
self.input_devices
.iter()
.position(|d| d.name == *spec)
.or_else(|| {
self.input_devices
.iter()
.position(|d| d.name.to_lowercase() == spec_lower)
})
.or_else(|| {
spec.parse::<usize>().ok().and_then(|idx| {
self.input_devices.iter().position(|d| d.index == idx)
})
})
.unwrap_or(0)
}
None => self
.input_devices
.iter()

View File

@@ -28,7 +28,7 @@ pub mod sample_browser;
pub mod undo;
pub mod ui;
pub use audio::{AudioSettings, DeviceKind, EngineSection, LinkSetting, MainLayout, Metrics, ScopeMode, SettingKind, SpectrumMode};
pub use audio::{AudioSettings, DevicesFocus, EngineSection, LinkSetting, MainLayout, Metrics, ScopeMode, SettingKind, SpectrumMode};
pub use color_scheme::ColorScheme;
pub use editor::{
CopiedStepData, CopiedSteps, EditorContext, EditorTarget, EuclideanField, PatternField,

View File

@@ -11,7 +11,7 @@ pub enum ConfirmAction {
ResetBank { bank: usize },
ResetPatterns { bank: usize, patterns: Vec<usize> },
ResetBanks { banks: Vec<usize> },
ImportBank { bank: usize },
ImportShared { bank: usize, pattern: usize },
}
impl ConfirmAction {
@@ -27,7 +27,7 @@ impl ConfirmAction {
Self::ResetBank { bank } => format!("Reset bank {}?", bank + 1),
Self::ResetPatterns { patterns, .. } => format!("Reset {} patterns?", patterns.len()),
Self::ResetBanks { banks } => format!("Reset {} banks?", banks.len()),
Self::ImportBank { bank } => format!("Import bank from clipboard? (replaces bank {:02})", bank + 1),
Self::ImportShared { bank, .. } => format!("Import from clipboard? (target: bank {:02})", bank + 1),
}
}
}

View File

@@ -8,7 +8,7 @@ use ratatui::Frame;
use crate::app::App;
use crate::engine::LinkState;
use crate::midi;
use crate::state::{DeviceKind, EngineSection, LinkSetting, SettingKind};
use crate::state::{DevicesFocus, EngineSection, LinkSetting, SettingKind};
use crate::theme;
use crate::widgets::{
render_scroll_indicators, render_section_header, IndicatorAlign, Scope,
@@ -607,18 +607,46 @@ pub fn list_height(item_count: usize) -> u16 {
pub fn devices_section_height(app: &App) -> u16 {
let output_h = list_height(app.audio.output_devices.len());
let input_h = list_height(app.audio.input_devices.len());
3 + output_h.max(input_h)
4 + output_h.max(input_h)
}
fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let section_focused = app.audio.section == EngineSection::Devices;
let [header_area, content_area] =
Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).areas(area);
let [header_area, host_area, content_area] =
Layout::vertical([Constraint::Length(2), Constraint::Length(1), Constraint::Min(1)])
.areas(area);
render_section_header(frame, "DEVICES", section_focused, header_area);
// Host row
let host_focused = section_focused && app.audio.devices_focus == DevicesFocus::Host;
let host_name = app
.audio
.available_hosts
.get(app.audio.host_index)
.map(|h| h.name.as_str())
.unwrap_or("-");
let highlight = Style::new()
.fg(theme.engine.focused)
.add_modifier(Modifier::BOLD);
let normal = Style::new().fg(theme.engine.normal);
let label_style = Style::new().fg(theme.engine.label);
let host_line = Line::from(vec![
Span::styled(
if host_focused {
"> Driver "
} else {
" Driver "
},
label_style,
),
render_selector(host_name, host_focused, highlight, normal),
]);
frame.render_widget(Paragraph::new(host_line), host_area);
let [output_col, separator, input_col] = Layout::horizontal([
Constraint::Percentage(48),
Constraint::Length(3),
@@ -626,8 +654,8 @@ fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
])
.areas(content_area);
let output_focused = section_focused && app.audio.device_kind == DeviceKind::Output;
let input_focused = section_focused && app.audio.device_kind == DeviceKind::Input;
let output_focused = section_focused && app.audio.devices_focus == DevicesFocus::Output;
let input_focused = section_focused && app.audio.devices_focus == DevicesFocus::Input;
render_device_column(
frame,
@@ -758,8 +786,8 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
label_style,
),
render_selector(
&if app.audio.config.host_name.to_lowercase().contains("jack") {
"JACK managed".to_string()
&if app.audio.host_controls_buffer() {
"Host managed".to_string()
} else {
format!("{}", app.audio.config.buffer_size)
},

View File

@@ -1,4 +1,4 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
use ratatui::style::{Modifier, Style};
@@ -220,6 +220,12 @@ pub fn highlight_line_with_runtime(
let theme = theme::get();
let annotation_style = Style::default().fg(theme.ui.text_dim);
// Keep only the last resolved value per span (at-loops produce one per iteration)
let mut last_resolved: HashMap<u32, &str> = HashMap::new();
for (span, display) in resolved {
last_resolved.insert(span.start, display.as_str());
}
for token in &tokens {
if token.start > last_end {
result.push((gap_style, line[last_end..token.start].to_string(), false));
@@ -244,10 +250,8 @@ pub fn highlight_line_with_runtime(
result.push((style, line[token.start..token.end].to_string(), false));
for (span, display) in resolved {
if token.start == span.start as usize {
result.push((annotation_style, format!(" [{display}]"), true));
}
if let Some(display) = last_resolved.get(&(token.start as u32)) {
result.push((annotation_style, format!(" [{display}]"), true));
}
last_end = token.end;

View File

@@ -253,19 +253,12 @@ fn test_midi_at_with_polyphony() {
}
#[test]
fn test_midi_arp_notes() {
let outputs = expect_outputs("c4 e4 g4 arp note m.", 3);
assert!(outputs[0].contains("/note/60/"));
assert!(outputs[1].contains("/note/64/"));
assert!(outputs[2].contains("/note/67/"));
}
#[test]
fn test_midi_arp_with_at() {
let outputs = expect_outputs("0 0.25 0.5 at c4 e4 g4 arp note m.", 3);
assert!(outputs[0].contains("/note/60/"));
assert!(outputs[1].contains("/note/64/"));
assert!(outputs[2].contains("/note/67/"));
fn test_midi_at_loop_notes() {
// at-loop with m. closer: 3 iterations, each emits one MIDI note
let outputs = expect_outputs("0 0.25 0.5 at 60 note m.", 3);
for o in &outputs {
assert!(o.contains("/note/60/"));
}
}
#[test]

View File

@@ -171,7 +171,9 @@ fn at_three_deltas() {
#[test]
fn at_persists_across_emits() {
let outputs = expect_outputs(r#"0 0.5 at "kick" snd . "hat" snd ."#, 4);
// With at as a loop, each at...block is independent.
// Two separate at blocks each emit twice.
let outputs = expect_outputs(r#"0 0.5 at "kick" snd . 0 0.5 at "hat" snd ."#, 4);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "kick", "hat", "hat"]);
}
@@ -202,17 +204,10 @@ fn at_records_selected_spans() {
let script = r#"0 0.5 0.75 at "kick" snd ."#;
f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap();
// Should have 6 selected spans: 3 for at deltas + 3 for sound (one per emit)
assert_eq!(trace.selected_spans.len(), 6, "expected 6 selected spans (3 at + 3 sound)");
// Verify at delta spans (even indices: 0, 2, 4)
assert_eq!(&script[trace.selected_spans[0].start as usize..trace.selected_spans[0].end as usize], "0");
assert_eq!(&script[trace.selected_spans[2].start as usize..trace.selected_spans[2].end as usize], "0.5");
assert_eq!(&script[trace.selected_spans[4].start as usize..trace.selected_spans[4].end as usize], "0.75");
// With at-loop, each iteration emits once: 3 sound spans total
assert_eq!(trace.selected_spans.len(), 3, "expected 3 selected spans (1 sound per iteration)");
}
// --- arp tests ---
fn get_notes(outputs: &[String]) -> Vec<f64> {
outputs
.iter()
@@ -220,16 +215,13 @@ fn get_notes(outputs: &[String]) -> Vec<f64> {
.collect()
}
fn get_gains(outputs: &[String]) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get("gain").copied().unwrap_or(f64::NAN))
.collect()
}
#[test]
fn arp_auto_subdivide() {
let outputs = expect_outputs(r#"sine snd c4 e4 g4 b4 arp note ."#, 4);
fn at_loop_with_cycle_notes() {
// at-loop + cycle replaces at + arp: cycle advances per subdivision
let ctx = ctx_with(|c| c.runs = 0);
let f = forth();
let outputs = f.evaluate(r#"0 0.25 0.5 0.75 at sine snd [ c4 e4 g4 b4 ] cycle note ."#, &ctx).unwrap();
assert_eq!(outputs.len(), 4);
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 64.0));
@@ -245,104 +237,41 @@ fn arp_auto_subdivide() {
}
#[test]
fn arp_with_explicit_at() {
let outputs = expect_outputs(r#"0 0.25 0.5 0.75 at sine snd c4 e4 g4 b4 arp note ."#, 4);
fn at_loop_cycle_wraps() {
// cycle inside at-loop wraps when more deltas than items
let ctx = ctx_with(|c| c.runs = 0);
let f = forth();
let outputs = f.evaluate(r#"0 0.25 0.5 0.75 at sine snd [ c4 e4 ] cycle note ."#, &ctx).unwrap();
assert_eq!(outputs.len(), 4);
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 64.0));
assert!(approx_eq(notes[2], 67.0));
assert!(approx_eq(notes[3], 71.0));
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], (0.25 * step_dur * sr).round()));
assert!(approx_eq(deltas[2], (0.5 * step_dur * sr).round()));
assert!(approx_eq(deltas[3], (0.75 * step_dur * sr).round()));
assert!(approx_eq(notes[0], 60.0)); // idx 0 % 2 = 0 -> c4
assert!(approx_eq(notes[1], 64.0)); // idx 1 % 2 = 1 -> e4
assert!(approx_eq(notes[2], 60.0)); // idx 2 % 2 = 0 -> c4
assert!(approx_eq(notes[3], 64.0)); // idx 3 % 2 = 1 -> e4
}
#[test]
fn arp_single_note() {
let outputs = expect_outputs(r#"sine snd c4 arp note ."#, 1);
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
fn at_loop_rand_different_per_subdivision() {
// rand inside at-loop produces different values per iteration
let f = forth();
let outputs = f.evaluate(r#"0 0.5 at sine snd 1 1000 rand freq ."#, &default_ctx()).unwrap();
assert_eq!(outputs.len(), 2);
let freqs: Vec<f64> = outputs.iter()
.map(|o| parse_params(o).get("freq").copied().unwrap_or(0.0))
.collect();
assert!(freqs[0] != freqs[1], "rand should produce different values: {} vs {}", freqs[0], freqs[1]);
}
#[test]
fn arp_fewer_deltas_than_notes() {
let outputs = expect_outputs(r#"0 0.5 at sine snd c4 e4 g4 b4 arp note ."#, 4);
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 64.0));
assert!(approx_eq(notes[2], 67.0));
assert!(approx_eq(notes[3], 71.0));
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], (0.5 * step_dur * sr).round()));
assert!(approx_eq(deltas[2], 0.0)); // wraps: 2 % 2 = 0
assert!(approx_eq(deltas[3], (0.5 * step_dur * sr).round())); // wraps: 3 % 2 = 1
}
#[test]
fn arp_fewer_notes_than_deltas() {
let outputs = expect_outputs(r#"0 0.25 0.5 0.75 at sine snd c4 e4 arp note ."#, 4);
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 64.0));
assert!(approx_eq(notes[2], 60.0)); // wraps
assert!(approx_eq(notes[3], 64.0)); // wraps
}
#[test]
fn arp_multiple_params() {
let outputs = expect_outputs(r#"sine snd c4 e4 g4 arp note 0.5 0.7 0.9 arp gain ."#, 3);
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 64.0));
assert!(approx_eq(notes[2], 67.0));
let gains = get_gains(&outputs);
assert!(approx_eq(gains[0], 0.5));
assert!(approx_eq(gains[1], 0.7));
assert!(approx_eq(gains[2], 0.9));
}
#[test]
fn arp_no_arp_unchanged() {
// Standard CycleList without arp → cross-product (backward compat)
fn at_loop_poly_cycling() {
// CycleList inside at-loop: poly stacking within each iteration
let outputs = expect_outputs(r#"0 0.5 at sine snd c4 e4 note ."#, 4);
let notes = get_notes(&outputs);
// Cross-product: each note at each delta
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 60.0));
assert!(approx_eq(notes[2], 64.0));
assert!(approx_eq(notes[3], 64.0));
}
#[test]
fn arp_mixed_cycle_and_arp() {
// CycleList sound (2) + ArpList note (3) → 3 arp × 2 poly = 6 voices
// Each arp step plays both sine and saw simultaneously (poly stacking)
let outputs = expect_outputs(r#"sine saw snd c4 e4 g4 arp note ."#, 6);
let sounds = get_sounds(&outputs);
// Arp step 0: poly 0=sine, poly 1=saw
assert_eq!(sounds[0], "sine");
assert_eq!(sounds[1], "saw");
// Arp step 1: poly 0=sine, poly 1=saw
assert_eq!(sounds[2], "sine");
assert_eq!(sounds[3], "saw");
// Arp step 2: poly 0=sine, poly 1=saw
assert_eq!(sounds[4], "sine");
assert_eq!(sounds[5], "saw");
let notes = get_notes(&outputs);
// Both poly voices in each arp step share the same note
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 60.0));
assert!(approx_eq(notes[2], 64.0));
assert!(approx_eq(notes[3], 64.0));
assert!(approx_eq(notes[4], 67.0));
assert!(approx_eq(notes[5], 67.0));
// Each iteration emits both poly voices (c4 and e4)
assert!(approx_eq(notes[0], 60.0)); // iter 0, poly 0
assert!(approx_eq(notes[1], 64.0)); // iter 0, poly 1
assert!(approx_eq(notes[2], 60.0)); // iter 1, poly 0
assert!(approx_eq(notes[3], 64.0)); // iter 1, poly 1
}
// --- every+ / except+ tests ---
@@ -400,3 +329,102 @@ fn every_offset_zero_is_same_as_every() {
assert_eq!(a.len(), b.len(), "iter={}: every and every+ 0 should match", iter);
}
}
// --- at-loop feature tests ---
#[test]
fn at_loop_choose_independent_per_subdivision() {
let f = forth();
let outputs = f.evaluate(r#"0 0.5 at sine snd [ 60 64 67 71 ] choose note ."#, &default_ctx()).unwrap();
assert_eq!(outputs.len(), 2);
// Both are valid notes from the set (just verify they're within range)
let notes = get_notes(&outputs);
for n in &notes {
assert!([60.0, 64.0, 67.0, 71.0].contains(n), "unexpected note {n}");
}
}
#[test]
fn at_loop_multiple_blocks_independent() {
let outputs = expect_outputs(
r#"0 0.5 at "kick" snd . 0 0.25 0.5 at "hat" snd ."#,
5,
);
let sounds = get_sounds(&outputs);
assert_eq!(sounds[0], "kick");
assert_eq!(sounds[1], "kick");
assert_eq!(sounds[2], "hat");
assert_eq!(sounds[3], "hat");
assert_eq!(sounds[4], "hat");
}
#[test]
fn at_loop_single_delta_one_iteration() {
let outputs = expect_outputs(r#"0.25 at "kick" snd ."#, 1);
let sounds = get_sounds(&outputs);
assert_eq!(sounds[0], "kick");
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], (0.25 * step_dur * sr).round()));
}
#[test]
fn at_without_closer_falls_back_to_legacy() {
// When at has no matching closer (., m., done), falls back to Op::At
let f = forth();
let result = f.evaluate(r#"0 0.5 at "kick" snd"#, &default_ctx());
assert!(result.is_ok());
}
#[test]
fn at_loop_cycle_advances_across_runs() {
// Across different runs values, cycle inside at-loop picks correctly
for base_runs in 0..3 {
let ctx = ctx_with(|c| c.runs = base_runs);
let f = forth();
let outputs = f.evaluate(
r#"0 0.5 at sine snd [ c4 e4 g4 ] cycle note ."#,
&ctx,
).unwrap();
assert_eq!(outputs.len(), 2);
let notes = get_notes(&outputs);
// runs for iter i = base_runs * 2 + i
let expected_0 = [60.0, 64.0, 67.0][(base_runs * 2) % 3];
let expected_1 = [60.0, 64.0, 67.0][(base_runs * 2 + 1) % 3];
assert!(approx_eq(notes[0], expected_0), "runs={base_runs}: iter 0 expected {expected_0}, got {}", notes[0]);
assert!(approx_eq(notes[1], expected_1), "runs={base_runs}: iter 1 expected {expected_1}, got {}", notes[1]);
}
}
#[test]
fn at_loop_midi_emit() {
let f = forth();
let outputs = f.evaluate("0 0.25 0.5 at 60 note m.", &default_ctx()).unwrap();
assert_eq!(outputs.len(), 3);
for o in &outputs {
assert!(o.contains("/midi/note/60/"));
}
// First should have no delta (or delta/0), others should have delta
assert!(outputs[1].contains("/delta/"));
assert!(outputs[2].contains("/delta/"));
}
#[test]
fn at_loop_done_no_emit() {
let f = forth();
let outputs = f.evaluate("0 0.5 at [ 1 2 ] cycle drop done", &default_ctx()).unwrap();
assert!(outputs.is_empty());
}
#[test]
fn at_loop_done_sets_variables() {
let f = forth();
let outputs = f
.evaluate("0 0.5 at [ 10 20 ] cycle !x done kick snd @x freq .", &default_ctx())
.unwrap();
assert_eq!(outputs.len(), 1);
// Last iteration wins: cycle(1) = 20
let params = parse_params(&outputs[0]);
assert!(approx_eq(*params.get("freq").unwrap(), 20.0));
}

View File

@@ -63,26 +63,26 @@ const DL = 'https://dlcagire.raphaelforment.fr';
<tr>
<td>macOS (ARM)</td>
<td><a href={`${DL}/cagire-macos-aarch64.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-macos-aarch64-desktop.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-macos-aarch64-desktop.zip`}>zip</a> · <a href={`${DL}/Cagire-aarch64.dmg`}>dmg</a> · <a href={`${DL}/cagire-macos-aarch64-app.zip`}>app</a></td>
<td><a href={`${DL}/cagire-macos-aarch64-clap.zip`}>CLAP</a> · <a href={`${DL}/cagire-macos-aarch64-vst3.zip`}>VST3</a></td>
</tr>
<tr>
<td>macOS (Intel)</td>
<td><a href={`${DL}/cagire-macos-x86_64.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-macos-x86_64-desktop.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-macos-x86_64-desktop.zip`}>zip</a> · <a href={`${DL}/Cagire-x86_64.dmg`}>dmg</a> · <a href={`${DL}/cagire-macos-x86_64-app.zip`}>app</a></td>
<td><a href={`${DL}/cagire-macos-x86_64-clap.zip`}>CLAP</a> · <a href={`${DL}/cagire-macos-x86_64-vst3.zip`}>VST3</a></td>
</tr>
<tr>
<td>Linux (x86_64)</td>
<td><a href={`${DL}/cagire-linux-x86_64.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-linux-x86_64-desktop.zip`}>zip</a> · <a href={`${DL}/cagire-linux-x86_64-appimage.zip`}>AppImage</a></td>
<td><a href={`${DL}/cagire-linux-x86_64.zip`}>zip</a> · <a href={`${DL}/cagire-linux-x86_64-appimage.zip`}>AppImage</a></td>
<td><a href={`${DL}/cagire-linux-x86_64-desktop.zip`}>zip</a> · <a href={`${DL}/cagire-linux-x86_64-desktop-appimage.zip`}>AppImage</a></td>
<td><a href={`${DL}/cagire-linux-x86_64-clap.zip`}>CLAP</a> · <a href={`${DL}/cagire-linux-x86_64-vst3.zip`}>VST3</a></td>
</tr>
<tr>
<td>Linux (ARM)</td>
<td><a href={`${DL}/cagire-linux-aarch64.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-linux-aarch64-desktop.zip`}>zip</a></td>
<td><s>CLAP</s> · <s>VST3</s></td>
<td><a href={`${DL}/cagire-linux-aarch64.zip`}>zip</a> · <a href={`${DL}/cagire-linux-aarch64-appimage.zip`}>AppImage</a></td>
<td><a href={`${DL}/cagire-linux-aarch64-desktop.zip`}>zip</a> · <a href={`${DL}/cagire-linux-aarch64-desktop-appimage.zip`}>AppImage</a></td>
<td><a href={`${DL}/cagire-linux-aarch64-clap.zip`}>CLAP</a> · <a href={`${DL}/cagire-linux-aarch64-vst3.zip`}>VST3</a></td>
</tr>
<tr>
<td>Windows (x86_64)</td>