Compare commits
308 Commits
4a8396670f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 362cdd498b | |||
| e26d5e2958 | |||
| f020b5a172 | |||
| 609fe108bc | |||
| f4a3e26d51 | |||
| b6daa81304 | |||
| 5c5488a9f0 | |||
| 4043a67d38 | |||
| af3c5c0985 | |||
| 44fe435770 | |||
| ef7ee019f1 | |||
| 5dffdd4a8d | |||
| e1cf72542c | |||
| 97a1a997f6 | |||
| 005155e486 | |||
| 712bd4e74e | |||
| 144c2487c2 | |||
| 260bc9dbdf | |||
| 68bd62f57f | |||
| f1c83c66a0 | |||
| 30dfe7372d | |||
| faf541e536 | |||
| 85cacfe53e | |||
| c507552b7c | |||
| d0b2076bf6 | |||
| ab93acd17f | |||
| d72b36b8f1 | |||
| 3d9d2ad759 | |||
| 5b1353f7e7 | |||
| f78b4374b6 | |||
| dacc9bd6be | |||
| bfd52c0053 | |||
| 12172ce1e8 | |||
| 1513d80a8d | |||
| 6d71c64a34 | |||
| 097104a074 | |||
| c13ddaaf37 | |||
| 001a42abfc | |||
| 0d0c2738f5 | |||
| 859629ae34 | |||
| 82e5f47933 | |||
| 9cc17d14de | |||
| 453ba62403 | |||
| 35aa97a93d | |||
| 25866f66d4 | |||
| 8b058f2bb9 | |||
| cb82337d24 | |||
| 539aa6a9f7 | |||
| b7d9436cee | |||
| 3d345d57f5 | |||
| c6b14bf508 | |||
|
|
5d755594cb | ||
| 6b60b3761b | |||
| 63fd2419d3 | |||
| da92fa6622 | |||
| 8e43e1bb3c | |||
| 3104a61490 | |||
| 20d72c9b21 | |||
| 09cfa82809 | |||
| bc1396d61d | |||
| 82d51a9add | |||
| fed7781bae | |||
| d055d2bfc6 | |||
| f273470eaf | |||
| b2a089fb0c | |||
| 04b68850d0 | |||
| 77364dddae | |||
| 5a72e4cef4 | |||
| 0097777449 | |||
| 4743c33916 | |||
| 2c8a6794a3 | |||
| 60fb62829f | |||
| 35370a6f2c | |||
| 4e1c04f9c7 | |||
| 80a3d91f76 | |||
| f130c9b54a | |||
| bdd2f9210e | |||
| 1fb599f574 | |||
| e8cf8c506b | |||
| 16d6d76422 | |||
| cf1d2be140 | |||
| cc89021cc0 | |||
| 470f62df89 | |||
| 88cb43a760 | |||
| eeefb7d54d | |||
| cfd7d31d3d | |||
| e9f5d8bb6d | |||
| 17643b3332 | |||
| 95879c852d | |||
| 3ad82a1954 | |||
| 4718248ee6 | |||
| 6fd844cdf6 | |||
| 2d3094464f | |||
| db44f9b98e | |||
| ecb559e556 | |||
| 5a59937cc7 | |||
| 11cc925faf | |||
| b72c782b2b | |||
| 6cd20732ed | |||
| d30ef8bb5b | |||
| e73ee1eb1e | |||
| 19bb3e0820 | |||
| cb7fcdb74a | |||
| 651ed1219d | |||
| ec98274dfe | |||
| 66abc4f961 | |||
| ca08074686 | |||
| 81fb174d7e | |||
| 7ae3f255b0 | |||
| 511726b65b | |||
| 052a6caa1a | |||
| fb62b121c1 | |||
| 0ecc4dae11 | |||
| 6b56655661 | |||
| f618f47811 | |||
| 47099a6eef | |||
| 70032acc75 | |||
| e1cf57918e | |||
| 71bd09d5ea | |||
| 6dd265067f | |||
| aa607a78d8 | |||
| 03c8187359 | |||
| c219b4efab | |||
| 0119988d7c | |||
| a6ff19bb08 | |||
| 2de49bdeba | |||
| 848d0e773f | |||
| 8f131b46cc | |||
| 8b745a77a6 | |||
| 502f7afe8f | |||
| e7137cc7ed | |||
| d9e6505e07 | |||
| 009d68087d | |||
| f47285385c | |||
| 81f475a75b | |||
| 3d552ec072 | |||
| 3093b40dbc | |||
| e2f3bcd4a9 | |||
| d3b27e8245 | |||
| c9c8fe4117 | |||
| a7a1f9e759 | |||
| 2ba957f2d4 | |||
| 7207a5fefe | |||
| 4526156c37 | |||
| 7a95207c58 | |||
| ab353edc0b | |||
| 77d5235d92 | |||
| e9bca2548c | |||
| 5ef988382b | |||
| 2d734c471f | |||
| 6216b9341b | |||
| bf361d3ab9 | |||
| 8fcc0f4e54 | |||
| 524e686b3a | |||
| 540f59dcf5 | |||
| 773c7bbd1c | |||
| b60703aa16 | |||
| c749ed6f85 | |||
| af6732db1c | |||
| b23dd85d0f | |||
| 160546d64d | |||
| cfaadd9d33 | |||
| 5e7fd8b79c | |||
| d56fa58157 | |||
| c803591ebb | |||
| d2e28b0415 | |||
| 38fad92f2e | |||
| d010392a3c | |||
| 80c392c24b | |||
| 60bc7618d3 | |||
| 55878707f2 | |||
| f6132bdd70 | |||
| 2c1765effa | |||
| f6e7330ad6 | |||
| af6016b9a9 | |||
| c7fabf3424 | |||
| 152536901b | |||
| dbd17a7946 | |||
| 83c756618f | |||
| e0d338a030 | |||
| 9a769518f9 | |||
| f1af4d2cdb | |||
| 3c518e4c5a | |||
| 53167e35b6 | |||
| 5a83c4c1d1 | |||
| 3fe837653b | |||
| 636126e7c6 | |||
| b46b65ed2a | |||
| 122d88c48d | |||
| 07523a49e7 | |||
| fb751c8691 | |||
| 5af536dea2 | |||
| b342595a09 | |||
| c92a29ab85 | |||
| 53fb3eb759 | |||
| b75b9562af | |||
| 8d249cf89b | |||
| a943d9622e | |||
| 467c504071 | |||
| 3bb1fa6e51 | |||
| ed70b47c81 | |||
| c95c82169f | |||
| bbbd8ff64a | |||
| 65736ccf84 | |||
| 75336656c2 | |||
| 96489c8f72 | |||
| 9b5759d794 | |||
| 3284354f40 | |||
| 266a625cf3 | |||
| 243f76ce05 | |||
| e01014a89a | |||
| 9d9dd5be38 | |||
| 9ff024cf9b | |||
| e337eb35e7 | |||
| a07a87a35f | |||
| 5c805c60d7 | |||
| b305df3d79 | |||
| 33ee1822a5 | |||
| 2cee1ba686 | |||
| c283887ada | |||
| 4235862d86 | |||
| 74fe999496 | |||
| cd8182425a | |||
| 7626f97695 | |||
| 19555be975 | |||
| 0aaa3efbb0 | |||
| f1902e18d3 | |||
| 39ca7de169 | |||
| 7c14ce7634 | |||
| d382c9e83a | |||
| d54d9218c1 | |||
| 7348bd38b1 | |||
| 2af0b67714 | |||
| 3e8076e416 | |||
| ceee3228c3 | |||
| 255cd34380 | |||
| 83fd4d028e | |||
| efacda2976 | |||
| ccce0df79d | |||
| 8452033473 | |||
| bc66f0a34c | |||
| cda987c2cb | |||
| ea202a2ab0 | |||
| dd77f6d92d | |||
| c356aebfde | |||
| 5b4a6ddd14 | |||
| 96e7fb6bc4 | |||
| dfd024cab7 | |||
| 03c0baf5b5 | |||
| b5fe6a1437 | |||
| 2e94bd90b0 | |||
| 92d80d1dfe | |||
| 971f40813f | |||
| 55383a2aa4 | |||
| 07287d2939 | |||
| c3f8ab5fb4 | |||
| 1903d77ac1 | |||
| 029b228025 | |||
| 9b730c310e | |||
| 8cd0ec92c0 | |||
| e1c4987db5 | |||
| bdba58312c | |||
| 6c9ec9a05f | |||
| f6679c5d66 | |||
| 2aa58670e3 | |||
| eb3969b952 | |||
| 44d1e9af24 | |||
| c2e6dfe88b | |||
| 17027b3968 | |||
| f841d8ba06 | |||
| aac9524316 | |||
| aee7433641 | |||
| 7729868939 | |||
| 89e4795e86 | |||
| 00a90f1c15 | |||
| 845c1134fe | |||
| 4d0d837e14 | |||
| f1f1b28b31 | |||
| 7e4f8d0e46 | |||
| db5237480a | |||
| 4c633a895f | |||
| 0520ef872e | |||
| 556058bfe9 | |||
| c7a9f7bc5a | |||
| 322885b908 | |||
| a9ce70d292 | |||
| 4dfb81af89 | |||
| 5fa2c5b6b0 | |||
| 324d1feda1 | |||
| 5456c9414a | |||
| 66933433d1 | |||
| 1b32a91b0d | |||
| bde64e7dc5 | |||
| 4ae8e28b2f | |||
| 87fd59549d | |||
| 016d050678 | |||
| 2d609f6b7a | |||
| 73470ded79 | |||
| ac83ceb2cb | |||
| b1a982aaa0 | |||
| 6f5fa762a4 | |||
| 04f5e19ab2 | |||
| f75ea4bb97 | |||
| a1ddb4a170 | |||
| 1433e07066 | |||
| 74f178f271 | |||
| a88904ed0f | |||
| 1bb5ba0061 |
5
.cargo/config.toml
Normal file
5
.cargo/config.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[env]
|
||||
MACOSX_DEPLOYMENT_TARGET = "12.0"
|
||||
|
||||
[alias]
|
||||
xtask = "run --package xtask --release --"
|
||||
39
.gitea/workflows/deploy-website.yml
Normal file
39
.gitea/workflows/deploy-website.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Deploy Website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'website/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: website
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
working-directory: website
|
||||
run: pnpm build
|
||||
|
||||
- name: Deploy to host volume
|
||||
run: |
|
||||
rm -rf /home/debian/my-services/data/cagire-website/*
|
||||
cp -r website/dist/* /home/debian/my-services/data/cagire-website/
|
||||
|
||||
305
.github/workflows/ci.yml
vendored
305
.github/workflows/ci.yml
vendored
@@ -1,305 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact: cagire-linux-x86_64
|
||||
- os: macos-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
artifact: cagire-macos-x86_64
|
||||
- os: macos-14
|
||||
target: aarch64-apple-darwin
|
||||
artifact: cagire-macos-aarch64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact: cagire-windows-x86_64
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
|
||||
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 }}
|
||||
|
||||
- name: Install dependencies (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
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
|
||||
cargo install cargo-bundle
|
||||
|
||||
- name: Install dependencies (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
brew list cmake &>/dev/null || brew install cmake
|
||||
cargo install cargo-bundle
|
||||
|
||||
- name: Install dependencies (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
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 --target ${{ matrix.target }}
|
||||
|
||||
- name: Build desktop
|
||||
run: cargo build --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
|
||||
|
||||
- name: Bundle desktop app
|
||||
if: runner.os != 'Windows'
|
||||
run: cargo bundle --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
|
||||
|
||||
- name: Bundle CLAP plugin
|
||||
run: cargo xtask bundle cagire-plugins --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Zip macOS app bundle
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release/bundle/osx
|
||||
zip -r Cagire.app.zip Cagire.app
|
||||
|
||||
- name: Upload artifact (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: target/${{ matrix.target }}/release/cagire
|
||||
|
||||
- name: Upload artifact (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: target/${{ matrix.target }}/release/cagire.exe
|
||||
|
||||
- name: Upload desktop artifact (Linux deb)
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-desktop
|
||||
path: target/${{ matrix.target }}/release/bundle/deb/*.deb
|
||||
|
||||
- name: Upload desktop artifact (macOS app bundle)
|
||||
if: runner.os == 'macOS'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-desktop
|
||||
path: target/${{ matrix.target }}/release/bundle/osx/Cagire.app.zip
|
||||
|
||||
- name: Upload desktop artifact (Windows exe)
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-desktop
|
||||
path: target/${{ matrix.target }}/release/cagire-desktop.exe
|
||||
|
||||
- name: Upload CLAP artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-clap
|
||||
path: target/bundled/cagire-plugins.clap
|
||||
|
||||
- name: Upload VST3 artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-vst3
|
||||
path: target/bundled/cagire-plugins.vst3
|
||||
|
||||
universal-macos:
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
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
|
||||
|
||||
- name: Build .pkg installer
|
||||
run: |
|
||||
VERSION="${GITHUB_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: Upload universal CLAP plugin
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cagire-macos-universal-clap
|
||||
path: cagire-plugins.clap
|
||||
|
||||
- name: Upload universal VST3 plugin
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cagire-macos-universal-vst3
|
||||
path: cagire-plugins.vst3
|
||||
|
||||
- name: Upload .pkg installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cagire-macos-universal-pkg
|
||||
path: Cagire-*-universal.pkg
|
||||
|
||||
release:
|
||||
needs: [build, universal-macos]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
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-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" == *-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 Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/*
|
||||
generate_release_notes: true
|
||||
58
.github/workflows/pages.yml
vendored
58
.github/workflows/pages.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: Deploy Website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: website/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
working-directory: website
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
working-directory: website
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: website/dist
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,10 +1,11 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
/.cache
|
||||
*.prof
|
||||
.DS_Store
|
||||
releases/
|
||||
|
||||
# Cargo config
|
||||
.cargo/config.toml
|
||||
# Local cargo overrides (doux path patch)
|
||||
.cargo/config.local.toml
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
172
BUILDING.md
Normal file
172
BUILDING.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Building Cagire
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://git.raphaelforment.fr/BuboBubo/cagire
|
||||
cd cagire
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The `doux` audio engine is fetched automatically from git. No local path setup needed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Rust** (stable toolchain): https://rustup.rs
|
||||
|
||||
## System Dependencies
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
brew install cmake
|
||||
```
|
||||
|
||||
cmake is required by `rusty_link` (Ableton Link C++ bindings). Xcode Command Line Tools provide the C++ compiler. CoreAudio and CoreMIDI are built-in. The desktop build needs no additional dependencies on macOS (Cocoa/Metal are provided by the system).
|
||||
|
||||
### Linux (Debian/Ubuntu)
|
||||
|
||||
```bash
|
||||
sudo apt install cmake g++ pkg-config libasound2-dev libjack-jackd2-dev
|
||||
```
|
||||
|
||||
For the desktop build (egui/eframe), also install:
|
||||
|
||||
```bash
|
||||
sudo apt install libgl-dev libxkbcommon-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev
|
||||
```
|
||||
|
||||
### Linux (Arch)
|
||||
|
||||
```bash
|
||||
sudo pacman -S cmake gcc pkgconf alsa-lib jack2
|
||||
```
|
||||
|
||||
For the desktop build:
|
||||
|
||||
```bash
|
||||
sudo pacman -S libxkbcommon libx11 libxcursor libxrandr libxi wayland mesa
|
||||
```
|
||||
|
||||
### Linux (Fedora)
|
||||
|
||||
```bash
|
||||
sudo dnf install cmake gcc-c++ pkgconf-pkg-config alsa-lib-devel jack-audio-connection-kit-devel
|
||||
```
|
||||
|
||||
For the desktop build:
|
||||
|
||||
```bash
|
||||
sudo dnf install libxkbcommon-devel libX11-devel libXcursor-devel libXrandr-devel libXi-devel wayland-devel mesa-libGL-devel
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Install Visual Studio Build Tools (MSVC) and CMake. Everything else is provided by the Windows SDK.
|
||||
|
||||
## Build
|
||||
|
||||
Terminal (default):
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Desktop (egui window):
|
||||
|
||||
```bash
|
||||
cargo build --release --features desktop --bin cagire-desktop
|
||||
```
|
||||
|
||||
Plugins (CLAP/VST3):
|
||||
|
||||
```bash
|
||||
cargo xtask bundle cagire-plugins --release
|
||||
```
|
||||
|
||||
The xtask alias is defined in `.cargo/config.toml` (committed). Plugin bundles are output to `target/bundled/`.
|
||||
|
||||
## Run
|
||||
|
||||
Terminal (default):
|
||||
|
||||
```bash
|
||||
cargo run --release -- [OPTIONS]
|
||||
```
|
||||
|
||||
Desktop (egui window):
|
||||
|
||||
```bash
|
||||
cargo run --release --features desktop --bin cagire-desktop
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-s, --samples <path>` | Sample directory (repeatable) |
|
||||
| `-o, --output <device>` | Output audio device |
|
||||
| `-i, --input <device>` | Input audio device |
|
||||
| `-c, --channels <n>` | Output channel count |
|
||||
| `-b, --buffer <size>` | Audio buffer size |
|
||||
|
||||
## Cross-Compilation
|
||||
|
||||
### 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` (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. 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** + **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`
|
||||
|
||||
### Building Individual Targets
|
||||
|
||||
```bash
|
||||
# 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 (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 (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
|
||||
|
||||
```bash
|
||||
# Interactive (prompts for platform/target selection):
|
||||
uv run scripts/build.py
|
||||
|
||||
# Non-interactive:
|
||||
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/`.
|
||||
|
||||
Platform aliases: `macos-arm64`, `macos-x86_64`, `linux-x86_64`, `linux-aarch64`, `windows-x86_64`.
|
||||
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. `build.py` handles AppImage creation automatically for Linux targets.
|
||||
|
||||
### Notes
|
||||
|
||||
- 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.
|
||||
237
CHANGELOG.md
237
CHANGELOG.md
@@ -2,56 +2,237 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
### UI / UX (breaking cosmetic changes)
|
||||
- **Options page**: Each option now shows a short description line below when focused, replacing the static header box.
|
||||
- **Dictionary page**: Removed the Forth description box at the top. The word list now uses the full page height.
|
||||
|
||||
### CLAP Plugin (experimental)
|
||||
- Early CLAP plugin support via nih-plug, baseview, and egui. Feature-gated builds separate CLI from plugin targets.
|
||||
## [0.1.5]
|
||||
|
||||
### Forth Language
|
||||
- Removed `chain` word (replaced by pattern-level Follow Up setting).
|
||||
- `case/of/endof/endcase` control flow for pattern-matching dispatch.
|
||||
- `bjork` / `pbjork` — euclidean rhythm gates using quotations: execute a block only on Bjorklund-distributed hits.
|
||||
- `arp` — arpeggio list type that spreads notes across time positions instead of stacking them simultaneously.
|
||||
- `,varname` assignment syntax (SetKeep): assign to a variable without consuming the value from the stack.
|
||||
- `every` reworked to accept quotations for cleaner conditional step logic.
|
||||
- All parameter words now accept varargs — over 100 words updated to consume the full stack.
|
||||
- Reverb parameter words added.
|
||||
- **`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
|
||||
- **Doux v0.0.12**: removed Mutable Instruments Plaits modes (`modal`, `va`, `analog`, `waveshape`, `grain`, `chord`, `swarm`, `pnoise`, etc.). Native percussion models retained; new models added: `tom`, `cowbell`, `cymbal`.
|
||||
- Simplified effects/filter API: removed per-filter envelope parameters in favor of the universal `env` word.
|
||||
- Recording commands simplified: removed `/sound/` path segment from `rec`, `overdub`, `orec`, `odub`.
|
||||
|
||||
### Forth Language
|
||||
- New modulation transition words: `islide` (swell), `oslide` (pluck), `pslide` (stair/stepped).
|
||||
- New `lpg` word (Low Pass Gate): pairs amplitude envelope with lowpass filter modulation.
|
||||
- New `inchan` word: select audio input channel by index.
|
||||
- New EQ frequency words: `eqlofreq`, `eqmidfreq`, `eqhifreq`.
|
||||
|
||||
### UI / UX
|
||||
- Redesigned top bar: consolidated transport, tempo, bar:beat display with visual beat segments.
|
||||
- CPU meter with color-coded fill bar (green/yellow/red).
|
||||
|
||||
### Engine
|
||||
- Follow-up actions: patterns now have a configurable follow-up behavior (Loop, Stop, or Chain to another pattern). Replaces the Forth `chain` word with a declarative setting in the Pattern Properties modal (`e` key). Chain targets specify bank and pattern via UI fields.
|
||||
- Delta-time MIDI scheduling for tighter, sample-accurate timing.
|
||||
- Audio input channel selection support.
|
||||
- Audio buffer sizing improved for multi-channel input.
|
||||
- MIDI output sends directly from dispatcher thread, bypassing UI-thread polling (~30x less jitter).
|
||||
|
||||
### Packaging
|
||||
- CI migrated from GitHub Actions to Gitea Actions.
|
||||
- Removed WIX installer; Windows now distributed via zip and NSIS only.
|
||||
- Gitea Actions workflow for automatic website deployment.
|
||||
- Added LICENSE file.
|
||||
|
||||
### Documentation
|
||||
- Extensive documentation updates reflecting doux v0.0.12 API changes across sources, filters, modulation, wavetable, and audio modulation docs.
|
||||
|
||||
## [0.1.3]
|
||||
|
||||
### Forth Language
|
||||
- New `stretch` word: pitch-independent time stretching via phase vocoder (e.g., `kick sound 2 stretch .` plays at half speed, same pitch).
|
||||
- Automatic default release time on sounds when none is explicitly set.
|
||||
|
||||
### Engine
|
||||
- Sample-accurate timing: delta computation switched from float seconds to integer sample ticks, fixing precision issues.
|
||||
- Lock-free audio input buffer: replaced `Arc<Mutex<VecDeque>>` with `HeapRb` ring buffer.
|
||||
- Theme access optimized: `Rc<ThemeColors>` replaces deep cloning on every `get()`.
|
||||
- Dictionary keys cached in `App` to avoid repeated lock acquisitions during rendering.
|
||||
|
||||
### Fixed
|
||||
- Realtime priority diagnostics: dedicated `warn_no_rt()` on Linux, lookahead widened from 20ms to 40ms when RT priority unavailable.
|
||||
- Float epsilon precision in delta/nudge zero-comparisons.
|
||||
- Windows build fixes for standalone and plugin targets.
|
||||
|
||||
### Documentation
|
||||
- Time stretching usage guide added to `docs/engine/samples.md`.
|
||||
|
||||
## [0.1.2]
|
||||
|
||||
### Forth Language
|
||||
- Single-letter envelope aliases: `a` (attack), `d` (decay), `s` (sustain), `r` (release).
|
||||
- `sound` alias changed from `s` to `snd` (frees `s` for sustain).
|
||||
- New `partials` word: set number of active harmonics for additive oscillator.
|
||||
- Velocity parameter normalized to 0–1 float range (was 0–127 integer).
|
||||
|
||||
### UI / UX
|
||||
- **Sample Explorer as dedicated page**: the side panel is now a full page (Tab key), with keyboard navigation (j/k, search with `/`, preview with Enter), replacing the old collapsible side panel.
|
||||
- **Pulsing armed-changes bar** on patterns page: staged play/stop/mute/solo changes shown in a launch bar with animated feedback ("c to launch").
|
||||
- Pulsing highlight on banks and patterns with staged changes.
|
||||
- Sample browser shows child count on collapsed folders and uses `+`/`-` tree icons.
|
||||
- File browser modal: shows audio file counts per directory, colored path segments, and hint bar.
|
||||
- Audio devices refreshed automatically when entering the Engine page.
|
||||
- Bank prelude field added to data model (foundation for bank-level Forth scripts).
|
||||
|
||||
### Engine
|
||||
- Audio timing switched from float seconds to integer tick-based scheduling, improving timing precision.
|
||||
- Stream error handling refined: only `DeviceNotAvailable` and `StreamInvalidated` trigger device-lost recovery (non-fatal errors no longer restart the stream).
|
||||
- Step traces use `Arc` for cheaper cloning between threads.
|
||||
|
||||
### Packaging
|
||||
- **Windows: NSIS installer** replaces cargo-wix MSI. Includes optional PATH registration, Start Menu shortcut, and proper Add/Remove Programs entry with uninstaller.
|
||||
- Improved Windows cross-compilation from Unix hosts (MinGW toolchain detection).
|
||||
- CI build timeouts increased to 60 minutes across all platforms.
|
||||
- Website download matrix updated.
|
||||
|
||||
## [0.1.1]
|
||||
|
||||
### Forth Language
|
||||
- `map` word: apply a quotation to each stack element (`1 2 3 ( 10 * ) map => 10 20 30`).
|
||||
- `loop` fix: now operates in steps instead of beats, uses `step_duration()` for correct timing.
|
||||
|
||||
### Fixed
|
||||
- Crash on missing sample directories: sample path scanning now validates directories exist before scanning.
|
||||
- Audio channel minimum enforced to 2, preventing crash on devices reporting fewer channels.
|
||||
- Audio device disconnect: automatic stream restart when device is lost (terminal and desktop).
|
||||
- Live keys (e.g. `f` for fill) no longer trigger while searching in dictionary or help views.
|
||||
- Side panel always uses horizontal layout (removed broken vertical fallback for narrow terminals).
|
||||
|
||||
### Changed
|
||||
- Runtime highlight enabled by default.
|
||||
|
||||
### Packaging
|
||||
- Modular CI: split monolithic release workflow into per-platform builds (Linux, macOS, Windows, cross-compilation).
|
||||
- Separate CI workflows for CLAP/VST plugin builds (Linux, macOS, Windows, Raspberry Pi).
|
||||
- Windows MSI installer workflow fixes.
|
||||
- Website download matrix updated.
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
### Breaking
|
||||
- **Quotation syntax changed from `{ }` to `( )`** — all deferred code blocks now use parentheses.
|
||||
|
||||
### Forth Language
|
||||
|
||||
**Syntax:**
|
||||
- `[ v1 v2 v3 ]` bracket lists with implicit count.
|
||||
- `( ... )` quotation syntax (replaces `{ }`).
|
||||
- `,varname` assignment syntax (SetKeep): assign without consuming.
|
||||
- `case/of/endof/endcase` control flow.
|
||||
- `print` — debug word, outputs top-of-stack as text.
|
||||
- Arithmetic and unary ops now lift over ArpList and CycleList element-wise.
|
||||
|
||||
**New words:**
|
||||
- `index` — select item at explicit index (wraps with modulo).
|
||||
- `slice` / `pick` — sample slicing: divide a sample into N equal parts and select which slice to play.
|
||||
- `wave` / `waveform` — set drum synthesis waveform (0=sine, 0.5=triangle, 1=saw).
|
||||
- `pbounce` — ping-pong cycle keyed by pattern iteration.
|
||||
- `except` — inverse of `every`.
|
||||
- `every+` / `except+` — phase-offset variants.
|
||||
- `bjork` / `pbjork` — euclidean rhythm gates using quotations.
|
||||
- `arp` — arpeggio list type (spreads notes across time).
|
||||
- `all` / `noall` — apply params globally to all emitted sounds.
|
||||
- `linmap` / `expmap` — linear and exponential range mapping.
|
||||
- `rec` / `overdub` (`dub`) — record/overdub master audio to a named sample.
|
||||
- `orec` / `odub` — record/overdub a single orbit.
|
||||
|
||||
**Harmony and voicing:**
|
||||
- `key!` — set tonal center.
|
||||
- `triad` / `seventh` — diatonic chord from scale degree.
|
||||
- `inv` / `dinv` — chord inversion / down inversion.
|
||||
- `drop2` / `drop3` — drop voicings.
|
||||
- `tp` — transpose all ints on stack by N semitones.
|
||||
|
||||
**New chord types:**
|
||||
- `pwr`, `augmaj7`, `7sus4`, `9sus4`, `maj69`, `min69`, `maj11`, `maj13`, `min13`, `dom7s11`.
|
||||
|
||||
**Effect parameters:**
|
||||
- Ducking compressor: `comp`, `compattack`/`cattack`, `comprelease`/`crelease`, `comporbit`/`corbit`.
|
||||
- Smear effect: `smear`, `smearfreq`, `smearfb`.
|
||||
- Reverb: `verbtype`, `verbchorus`, `verbchorusfreq`, `verbprelow`, `verbprehigh`, `verblowcut`, `verbhighcut`, `verblowgain`.
|
||||
|
||||
**Behavior changes:**
|
||||
- All parameter words now accept varargs (100+ words updated to consume the full stack).
|
||||
- `every` reworked to accept quotations.
|
||||
- Removed `chain` word (replaced by pattern-level Follow Up setting).
|
||||
|
||||
### Engine
|
||||
- SF2 soundfont support: auto-scans sample directories for `.sf2` files.
|
||||
- Follow-up actions: patterns have configurable follow-up (Loop, Stop, Chain). Replaces the `chain` word with a declarative UI setting (`e` key).
|
||||
- Delta-time MIDI scheduling for tighter timing.
|
||||
- Audio stream errors surfaced as flash messages.
|
||||
- Prelude script evaluated on application startup (not only on play).
|
||||
- Global periodic script: a hidden script page runs alongside all patterns at its own speed/length.
|
||||
- RestartAll command: reset all active patterns to step 0 and clear state.
|
||||
- Tempo and current beat exposed in sequencer snapshot.
|
||||
- Spectrum analyzer rescaling.
|
||||
|
||||
### UI / UX
|
||||
- Patterns view redesign: new layout with banks column (showing pattern counts), expandable detail rows for the focused pattern (quantization, sync mode, progress bar), and a bottom preview strip with mini step grid and pattern properties.
|
||||
- Smooth playback progress: playing patterns display a real-time progress bar interpolated between steps.
|
||||
- Dynamic step grid sizing: `steps_per_page` adapts to terminal height instead of using a fixed constant.
|
||||
- Mouse support: click navigation on the pattern grid, panels, and modals.
|
||||
- **Engine page redesign**: responsive narrow/wide layout, Link/MIDI/device settings moved here from Options.
|
||||
- **Patterns view redesign**: banks column with pattern counts, expandable detail rows, bottom preview strip with mini step grid.
|
||||
- **Mouse support**: click navigation on header/grid/panels/modals, text selection in code editor (click+drag), double-click on scope/spectrum/lissajous to cycle display modes.
|
||||
- Smooth playback progress bar interpolated between steps.
|
||||
- Dynamic step grid sizing adapts to terminal height.
|
||||
- Lissajous XY scope with Braille rendering and thermal trail mode.
|
||||
- Gain boost (1x–16x) and normalize toggle for scope/lissajous/spectrum.
|
||||
- Pattern description field: editable via `d`, shown in pattern list and properties.
|
||||
- Bank/pattern import and export via clipboard (base64 serialization for sharing).
|
||||
- Mute/solo on main page now apply immediately (no staging).
|
||||
- Step name automatically cleared when deleting a step.
|
||||
- F1–F6 page navigation across the 3×2 page grid.
|
||||
- Collapsible help sections with code block copy.
|
||||
- Onboarding system for first-time users.
|
||||
- New reusable widgets: CategoryList, HintBar, PropsForm, ScrollIndicators, SearchBar, SectionHeader.
|
||||
- Show/hide preview pane toggle and zoom factor setting.
|
||||
- Reduced UI lag: sequencer snapshot moved after render call.
|
||||
- 10 bundled demo projects loaded on fresh startup (togglable in Options).
|
||||
- Options page: each option shows a description line below when focused.
|
||||
- Dictionary page: word list uses full page height (removed description box).
|
||||
|
||||
### Themes
|
||||
- 5 new themes: Iceberg, Everforest, Fauve, Tropicalia, Jaipur.
|
||||
- Palette-based generation: all 18 themes derived from a 14-field Palette via Oklab color space (definitions reduced from ~300 to ~20 lines each).
|
||||
|
||||
### Desktop (egui)
|
||||
- Fixed Alt/Option key on macOS (dead-key composition now works).
|
||||
- Fixed multi-character text paste.
|
||||
- Extended function key support (F13–F20).
|
||||
- No console window on Windows desktop build.
|
||||
|
||||
### Packaging
|
||||
- macOS: `.dmg` disk image with `.app` bundle (Intel + Apple Silicon fat binaries via `lipo`).
|
||||
- Windows: `.msi` installer via WiX.
|
||||
- Linux: improved AppImage build scripts and Docker cross-compilation.
|
||||
|
||||
### CLAP Plugin (experimental)
|
||||
- Early CLAP plugin support via nih-plug, baseview, and egui. Feature-gated builds separate CLI from plugin targets.
|
||||
|
||||
### Documentation
|
||||
- Complete reorganization into `docs/` subdirectories.
|
||||
- 10 getting-started guides, 5 interactive tutorials.
|
||||
- New topics: control flow, generators, harmony, randomness, variables, timing.
|
||||
- New tutorials: Recording, Soundfonts, Sharing (import/export).
|
||||
- New topics: control flow, generators, harmony, randomness, variables, timing, bracket syntax.
|
||||
- Crate-level READMEs for forth, markdown, project, ratatui.
|
||||
|
||||
### Theme System
|
||||
- Palette-based generation: all 18 themes now derived from a 14-field Palette via Oklab color space.
|
||||
- Theme definitions reduced from ~300 lines each to ~20 lines.
|
||||
### Fixed
|
||||
- CycleList + ArpList index collision: arp uses timing index, cycle uses polyphony slot.
|
||||
- Scope widget not drawing completely in some terminal sizes.
|
||||
|
||||
### Codebase
|
||||
- `src/app.rs` split into 10 focused modules (dispatch, clipboard, editing, navigation, persistence, scripting, sequencer, staging, undo).
|
||||
- `src/app.rs` split into 10 focused modules.
|
||||
- `src/input.rs` split into 8 page-specific handlers.
|
||||
- Undo/redo system with scope-based tracking.
|
||||
- Feature-gated CLI vs plugin builds.
|
||||
- New reusable widgets: CategoryList, HintBar, PropsForm, ScrollIndicators, SearchBar, SectionHeader.
|
||||
|
||||
## [0.0.9]
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Contributions are welcome. There are many ways to contribute beyond code:
|
||||
## Prerequisites
|
||||
|
||||
- **Rust** (stable toolchain) - [rustup.rs](https://rustup.rs/)
|
||||
- **System libraries** - See [BUILDING.md](BUILDING.md) for platform-specific packages (cmake, ALSA, etc.)
|
||||
|
||||
## Quick start
|
||||
|
||||
|
||||
7542
Cargo.lock
generated
Normal file
7542
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
Cargo.toml
22
Cargo.toml
@@ -2,11 +2,11 @@
|
||||
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui", "plugins/cagire-plugins", "plugins/baseview", "plugins/egui-baseview", "plugins/nih-plug-egui", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.9"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
|
||||
license = "AGPL-3.0"
|
||||
repository = "https://github.com/Bubobubobubobubo/cagire"
|
||||
repository = "https://git.raphaelforment.fr/BuboBubo/cagire"
|
||||
homepage = "https://cagire.raphaelforment.fr"
|
||||
description = "Forth-based live coding music sequencer"
|
||||
|
||||
@@ -36,26 +36,26 @@ required-features = ["desktop"]
|
||||
[features]
|
||||
default = ["cli"]
|
||||
cli = ["dep:cpal", "dep:midir", "dep:confy", "dep:clap", "dep:thread-priority"]
|
||||
block-renderer = ["dep:soft_ratatui", "dep:rustc-hash", "dep:egui"]
|
||||
block-renderer = ["dep:soft_ratatui", "dep:rustc-hash", "dep:egui", "dep:egui_ratatui"]
|
||||
desktop = [
|
||||
"cli",
|
||||
"block-renderer",
|
||||
"cagire-forth/desktop",
|
||||
"dep:eframe",
|
||||
"dep:egui_ratatui",
|
||||
"dep:image",
|
||||
]
|
||||
asio = ["doux/asio", "cpal/asio"]
|
||||
|
||||
[dependencies]
|
||||
cagire-forth = { path = "crates/forth" }
|
||||
cagire-markdown = { path = "crates/markdown" }
|
||||
cagire-project = { path = "crates/project" }
|
||||
cagire-ratatui = { path = "crates/ratatui" }
|
||||
doux = { path = "/Users/bubo/doux", features = ["native"] }
|
||||
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"
|
||||
cpal = { version = "0.17", features = ["jack"], optional = true }
|
||||
cpal = { version = "0.17", optional = true }
|
||||
clap = { version = "4", features = ["derive"], optional = true }
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -83,12 +83,15 @@ rustc-hash = { version = "2", optional = true }
|
||||
image = { version = "0.25", default-features = false, features = ["png"], optional = true }
|
||||
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
winres = "0.1"
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
cpal = { version = "0.17", optional = true, features = ["jack"] }
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
@@ -109,3 +112,4 @@ icon = ["assets/Cagire.icns", "assets/Cagire.ico", "assets/Cagire.png"]
|
||||
copyright = "Copyright (c) 2025 Raphaël Forment"
|
||||
category = "Music"
|
||||
short_description = "Forth-based music sequencer"
|
||||
minimum_system_version = "12.0"
|
||||
|
||||
5
Cross.toml
Normal file
5
Cross.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
dockerfile = "./scripts/cross/aarch64-linux.Dockerfile"
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
dockerfile = "./scripts/cross/x86_64-linux.Dockerfile"
|
||||
88
README.md
88
README.md
@@ -1,37 +1,83 @@
|
||||
<h1 align="center">Cagire</h1>
|
||||
|
||||
<p align="center"><em>A Forth Music Sequencer</em></p>
|
||||
<p align="center"><em>A Forth-based live coding sequencer</em></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="cagire_pixel.png" alt="Cagire" width="256">
|
||||
<img src="assets/Cagire.png" alt="Cagire" width="256">
|
||||
</p>
|
||||
|
||||
Cagire is a terminal-based step sequencer for live coding music. Each step in a pattern contains a **Forth** script that produces sound and create events.
|
||||
<p align="center">
|
||||
<a href="https://cagire.raphaelforment.fr">Website</a> ·
|
||||
<a href="https://git.raphaelforment.fr/BuboBubo/cagire">Gitea</a> ·
|
||||
AGPL-3.0
|
||||
</p>
|
||||
|
||||
## Build
|
||||
Cagire is a terminal based step sequencer and live coding platform. Each step in a sequence is represented by a **Forth** script. It ships with a self-contained audio engine. No external software is needed, Cagire is a fully autonomous musical instrument that provides everything you need to perform.
|
||||
|
||||
Terminal version:
|
||||
```
|
||||
cargo build --release
|
||||
### Examples
|
||||
|
||||
A filtered sawtooth with reverb:
|
||||
|
||||
```forth
|
||||
saw sound
|
||||
200 199 freq
|
||||
400 lpf
|
||||
.8 lpq .3 verb
|
||||
.
|
||||
```
|
||||
|
||||
Desktop version (with egui window):
|
||||
```
|
||||
cargo build --release --features desktop --bin cagire-desktop
|
||||
A generative pattern using randomness, scales, and effects:
|
||||
|
||||
```forth
|
||||
sine sound 2 fm 0.5 fmh
|
||||
0 7 rand minor 50 + note
|
||||
.1 .8 rand lpf
|
||||
1 4 rand 10 * delay .5 delayfeedback
|
||||
.
|
||||
```
|
||||
|
||||
## Run
|
||||
### Features
|
||||
|
||||
Terminal version:
|
||||
```
|
||||
cargo run --release
|
||||
```
|
||||
- **Cagire's Forth**: a stack-based language made for live coding
|
||||
- Forth has almost no syntax, only words, numbers and spaces. Very easy to learn for beginners, quite deep for experienced programmers.
|
||||
- Nondeterminism and generative: randomness, probabilities, patterns thought as first-class features.
|
||||
- Quotations: code blocks `( ... )` that compose with probability, cycling, euclidean, and conditional words.
|
||||
- User-defined words: extend (or redefine) the language on the fly with `:name ... ;` definitions.
|
||||
- Interactive documentation: built-in tutorials with runnable examples.
|
||||
- **Audio engine** (powered by [Doux](https://doux.livecoding.fr)):
|
||||
- Synthesis: classic waveforms (saw, pulse, tri, sine), additive (up to 32 partials), FM (2-op, 3 algorithms), wavetables, 7-voice spread.
|
||||
- Drum models: seven drum models with timbral morphing.
|
||||
- Sampling: disk-loaded samples with slicing, looping, pitch tracking, wavetable mode, and live recording from engine output or line input.
|
||||
- Filters: biquad LP/HP/BP and ladder filters. Filters can be modulated, stacked, etc.
|
||||
- Effects: phaser, flanger, chorus, smear, distortion, wavefolder, wavewrapper, bitcrusher, sample-rate reduction, 3-band EQ, tilt EQ, Haas stereo.
|
||||
- Bus effects: delay (standard, ping-pong, tape, multitap), two reverb engines (Dattorro plate, Vital Space), comb filter, feedback delay with LFO, sidechain compressor.
|
||||
- Modulation: vibrato, AM, ring mod, audio-rate LFO, transitions, DAHDSR envelope modulation — all applicable to any parameter.
|
||||
- **Sequencing**: probabilities, patterns, euclidean structures, sub-step timing, pattern chaining and a lot more.
|
||||
- **MIDI**: receive or send MIDI messages across up to 4 inputs and 4 outputs.
|
||||
- **Ableton Link**: tempo and phase sync with any Link-enabled software or hardware.
|
||||
- **Cross-platform**: terminal and desktop interfaces on macOS, Linux, and Windows.
|
||||
- **Plugins**: run Cagire as a CLAP or VST3 plugin inside your DAW (separate version).
|
||||
|
||||
Desktop version:
|
||||
```
|
||||
cargo run --release --features desktop --bin cagire-desktop
|
||||
```
|
||||
### Getting started
|
||||
|
||||
## License
|
||||
Download the latest release for your platform from the [website](https://cagire.raphaelforment.fr).
|
||||
|
||||
AGPL-3.0
|
||||
To build from source instead, see [BUILDING.md](BUILDING.md).
|
||||
|
||||
### Documentation
|
||||
|
||||
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
|
||||
- [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
### Credits
|
||||
|
||||
Cagire is developed by [BuboBubo](https://raphaelforment.fr) (Raphael Forment).
|
||||
|
||||
- **[Doux](https://doux.livecoding.fr)** (audio engine) — Rust port of Dough, originally written in C by Felix Roos
|
||||
|
||||
### License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 72 KiB |
18
assets/DMG-README.txt
Normal file
18
assets/DMG-README.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
# Cagire - A Forth-based music sequencer
|
||||
|
||||
## Installation
|
||||
|
||||
Drag Cagire.app into the Applications folder.
|
||||
|
||||
## Unquarantine
|
||||
|
||||
Since this app is not signed with an Apple Developer certificate,
|
||||
macOS will block it from running. Thanks Apple! To fix this, open
|
||||
Terminal and run:
|
||||
|
||||
xattr -cr /Applications/Cagire.app
|
||||
|
||||
## Support
|
||||
|
||||
If you enjoy this software, consider supporting development:
|
||||
https://ko-fi.com/raphaelbubo
|
||||
7
assets/cagire.desktop
Normal file
7
assets/cagire.desktop
Normal file
@@ -0,0 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Cagire
|
||||
Comment=Forth-based music sequencer
|
||||
Exec=cagire
|
||||
Icon=cagire
|
||||
Categories=Audio;Music;AudioVideo;
|
||||
26
build.rs
26
build.rs
@@ -1,11 +1,25 @@
|
||||
//! Build script — embeds Windows application resources (icon, metadata).
|
||||
|
||||
fn main() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut res = winres::WindowsResource::new();
|
||||
res.set_icon("assets/Cagire.ico")
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
|
||||
if target_os == "windows" {
|
||||
println!("cargo:rustc-link-lib=ws2_32");
|
||||
println!("cargo:rustc-link-lib=iphlpapi");
|
||||
println!("cargo:rustc-link-lib=winmm");
|
||||
println!("cargo:rustc-link-lib=ole32");
|
||||
println!("cargo:rustc-link-lib=oleaut32");
|
||||
}
|
||||
|
||||
if target_os == "windows" {
|
||||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let icon = format!("{manifest_dir}/assets/Cagire.ico");
|
||||
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");
|
||||
.set("LegalCopyright", "Copyright (c) 2025 Raphaël Forment")
|
||||
.compile()
|
||||
.expect("Failed to compile Windows resources");
|
||||
}
|
||||
}
|
||||
|
||||
22
crates/forth/README.md
Normal file
22
crates/forth/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# cagire-forth
|
||||
|
||||
Stack-based Forth VM for the Cagire sequencer. Tokenizes, compiles, and executes step scripts to produce audio and MIDI commands.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `vm` | Interpreter loop, `Forth::evaluate()` entry point |
|
||||
| `compiler` | Tokenization (with source spans) and single-pass compilation to ops |
|
||||
| `ops` | `Op` enum (~90 variants) |
|
||||
| `types` | `Value`, `StepContext`, shared state types |
|
||||
| `words/` | Built-in word definitions: `core`, `sound`, `music`, `midi`, `effects`, `sequencing`, `compile` |
|
||||
| `theory/` | Music theory lookups: `scales` (~200 patterns), `chords` (interval arrays) |
|
||||
|
||||
## Key Types
|
||||
|
||||
- **`Forth`** — VM instance, holds stacks and compilation state
|
||||
- **`Value`** — Stack value (int, float, string, list, quotation, ...)
|
||||
- **`StepContext`** — Per-step evaluation context (step index, tempo, variables, ...)
|
||||
- **`Op`** — Compiled operation; nondeterministic variants carry `Option<SourceSpan>` for tracing
|
||||
- **`ExecutionTrace`** — Records executed/selected spans and resolved values during evaluation
|
||||
@@ -15,6 +15,7 @@ enum Token {
|
||||
Word(String, SourceSpan),
|
||||
}
|
||||
|
||||
/// Compile Forth source text into an executable Op sequence.
|
||||
pub(super) fn compile_script(input: &str, dict: &Dictionary) -> Result<Vec<Op>, String> {
|
||||
let tokens = tokenize(input);
|
||||
compile(&tokens, dict)
|
||||
@@ -30,7 +31,7 @@ fn tokenize(input: &str) -> Vec<Token> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if c == '(' || c == ')' {
|
||||
if c == '{' || c == '}' {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
@@ -132,7 +133,7 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
|
||||
Token::Str(s, span) => ops.push(Op::PushStr(Arc::from(s.as_str()), Some(*span))),
|
||||
Token::Word(w, span) => {
|
||||
let word = w.as_str();
|
||||
if word == "{" {
|
||||
if word == "(" {
|
||||
let (quote_ops, consumed, end_span) =
|
||||
compile_quotation(&tokens[i + 1..], dict)?;
|
||||
i += consumed;
|
||||
@@ -141,8 +142,21 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
|
||||
end: end_span.end,
|
||||
};
|
||||
ops.push(Op::Quotation(Arc::from(quote_ops), Some(body_span)));
|
||||
} else if word == "}" {
|
||||
return Err("unexpected }".into());
|
||||
} else if word == ")" {
|
||||
return Err("unexpected )".into());
|
||||
} else if word == "[" {
|
||||
let (bracket_ops, consumed, end_span) =
|
||||
compile_bracket(&tokens[i + 1..], dict)?;
|
||||
i += consumed;
|
||||
ops.push(Op::Mark);
|
||||
ops.extend(bracket_ops);
|
||||
let count_span = SourceSpan {
|
||||
start: span.start,
|
||||
end: end_span.end,
|
||||
};
|
||||
ops.push(Op::Count(Some(count_span)));
|
||||
} else if word == "]" {
|
||||
return Err("unexpected ]".into());
|
||||
} else if word == ":" {
|
||||
let (consumed, name, body) = compile_colon_def(&tokens[i + 1..], dict)?;
|
||||
i += consumed;
|
||||
@@ -162,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;
|
||||
@@ -189,8 +210,8 @@ fn compile_quotation(
|
||||
for (i, tok) in tokens.iter().enumerate() {
|
||||
if let Token::Word(w, _) = tok {
|
||||
match w.as_str() {
|
||||
"{" => depth += 1,
|
||||
"}" => {
|
||||
"(" => depth += 1,
|
||||
")" => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end_idx = Some(i);
|
||||
@@ -202,7 +223,7 @@ fn compile_quotation(
|
||||
}
|
||||
}
|
||||
|
||||
let end_idx = end_idx.ok_or("missing }")?;
|
||||
let end_idx = end_idx.ok_or("missing )")?;
|
||||
let end_span = match &tokens[end_idx] {
|
||||
Token::Word(_, span) => *span,
|
||||
_ => unreachable!(),
|
||||
@@ -211,6 +232,38 @@ fn compile_quotation(
|
||||
Ok((quote_ops, end_idx + 1, end_span))
|
||||
}
|
||||
|
||||
fn compile_bracket(
|
||||
tokens: &[Token],
|
||||
dict: &Dictionary,
|
||||
) -> Result<(Vec<Op>, usize, SourceSpan), String> {
|
||||
let mut depth = 1;
|
||||
let mut end_idx = None;
|
||||
|
||||
for (i, tok) in tokens.iter().enumerate() {
|
||||
if let Token::Word(w, _) = tok {
|
||||
match w.as_str() {
|
||||
"[" => depth += 1,
|
||||
"]" => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end_idx = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let end_idx = end_idx.ok_or("missing ]")?;
|
||||
let end_span = match &tokens[end_idx] {
|
||||
Token::Word(_, span) => *span,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let body_ops = compile(&tokens[..end_idx], dict)?;
|
||||
Ok((body_ops, end_idx + 1, end_span))
|
||||
}
|
||||
|
||||
fn token_span(tok: &Token) -> Option<SourceSpan> {
|
||||
match tok {
|
||||
Token::Int(_, s) | Token::Float(_, s) | Token::Str(_, s) | Token::Word(_, s) => Some(*s),
|
||||
@@ -309,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;
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use super::types::SourceSpan;
|
||||
|
||||
/// Single VM instruction produced by the compiler.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Op {
|
||||
PushInt(i64, Option<SourceSpan>),
|
||||
@@ -64,6 +65,7 @@ pub enum Op {
|
||||
NewCmd,
|
||||
SetParam(&'static str),
|
||||
Emit,
|
||||
Print,
|
||||
Get,
|
||||
Set,
|
||||
SetKeep,
|
||||
@@ -76,6 +78,7 @@ pub enum Op {
|
||||
PCycle(Option<SourceSpan>),
|
||||
Choose(Option<SourceSpan>),
|
||||
Bounce(Option<SourceSpan>),
|
||||
PBounce(Option<SourceSpan>),
|
||||
WChoose(Option<SourceSpan>),
|
||||
ChanceExec(Option<SourceSpan>),
|
||||
ProbExec(Option<SourceSpan>),
|
||||
@@ -84,6 +87,9 @@ pub enum Op {
|
||||
Ftom,
|
||||
SetTempo,
|
||||
Every(Option<SourceSpan>),
|
||||
Except(Option<SourceSpan>),
|
||||
EveryOffset(Option<SourceSpan>),
|
||||
ExceptOffset(Option<SourceSpan>),
|
||||
Bjork(Option<SourceSpan>),
|
||||
PBjork(Option<SourceSpan>),
|
||||
Quotation(Arc<[Op]>, Option<SourceSpan>),
|
||||
@@ -104,7 +110,8 @@ pub enum Op {
|
||||
ClearCmd,
|
||||
SetSpeed,
|
||||
At,
|
||||
Arp,
|
||||
AtLoop(Arc<[Op]>),
|
||||
|
||||
IntRange,
|
||||
StepRange,
|
||||
Generate,
|
||||
@@ -112,6 +119,7 @@ pub enum Op {
|
||||
Euclid,
|
||||
EuclidRot,
|
||||
Times,
|
||||
Map,
|
||||
Chord(&'static [i64]),
|
||||
Transpose,
|
||||
Invert,
|
||||
@@ -126,6 +134,12 @@ pub enum Op {
|
||||
ModSlide(u8),
|
||||
ModRnd(u8),
|
||||
ModEnv,
|
||||
ModEnvAd,
|
||||
ModEnvAdr,
|
||||
Lpg,
|
||||
// Global params
|
||||
EmitAll,
|
||||
ClearGlobal,
|
||||
// MIDI
|
||||
MidiEmit,
|
||||
GetMidiCC,
|
||||
@@ -133,4 +147,13 @@ pub enum Op {
|
||||
MidiStart,
|
||||
MidiStop,
|
||||
MidiContinue,
|
||||
// Recording
|
||||
Rec,
|
||||
Overdub,
|
||||
Orec,
|
||||
Odub,
|
||||
// Bracket syntax (mark/count for auto-counting)
|
||||
Mark,
|
||||
Count(Option<SourceSpan>),
|
||||
Index(Option<SourceSpan>),
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
//! Chord definitions as semitone interval arrays.
|
||||
|
||||
/// Named chord with its interval pattern.
|
||||
pub struct Chord {
|
||||
pub name: &'static str,
|
||||
pub intervals: &'static [i64],
|
||||
}
|
||||
|
||||
/// All built-in chord types.
|
||||
pub static CHORDS: &[Chord] = &[
|
||||
// Triads
|
||||
Chord {
|
||||
@@ -169,6 +173,7 @@ pub static CHORDS: &[Chord] = &[
|
||||
},
|
||||
];
|
||||
|
||||
/// Find a chord's intervals by name.
|
||||
pub fn lookup(name: &str) -> Option<&'static [i64]> {
|
||||
CHORDS.iter().find(|c| c.name == name).map(|c| c.intervals)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Music theory data — chord and scale lookup tables.
|
||||
|
||||
pub mod chords;
|
||||
mod scales;
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
//! Scale definitions as semitone offset arrays.
|
||||
|
||||
/// Named scale with its semitone pattern.
|
||||
pub struct Scale {
|
||||
pub name: &'static str,
|
||||
pub pattern: &'static [i64],
|
||||
}
|
||||
|
||||
/// All built-in scale types.
|
||||
pub static SCALES: &[Scale] = &[
|
||||
Scale {
|
||||
name: "major",
|
||||
@@ -125,6 +129,7 @@ pub static SCALES: &[Scale] = &[
|
||||
},
|
||||
];
|
||||
|
||||
/// Find a scale's pattern by name.
|
||||
pub fn lookup(name: &str) -> Option<&'static [i64]> {
|
||||
SCALES.iter().find(|s| s.name == name).map(|s| s.pattern)
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ pub trait CcAccess: Send + Sync {
|
||||
fn get_cc(&self, device: usize, channel: usize, cc: usize) -> u8;
|
||||
}
|
||||
|
||||
/// Byte range in source text.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SourceSpan {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
}
|
||||
|
||||
/// Concrete value resolved from a nondeterministic op, used for trace annotations.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ResolvedValue {
|
||||
Int(i64),
|
||||
@@ -39,6 +41,7 @@ impl ResolvedValue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spans and resolved values collected during a single evaluation, used for UI highlighting.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ExecutionTrace {
|
||||
pub executed_spans: Vec<SourceSpan>,
|
||||
@@ -46,6 +49,7 @@ pub struct ExecutionTrace {
|
||||
pub resolved: Vec<(SourceSpan, ResolvedValue)>,
|
||||
}
|
||||
|
||||
/// Per-step sequencer state passed into the VM.
|
||||
pub struct StepContext<'a> {
|
||||
pub step: usize,
|
||||
pub beat: f64,
|
||||
@@ -59,6 +63,7 @@ pub struct StepContext<'a> {
|
||||
pub speed: f64,
|
||||
pub fill: bool,
|
||||
pub nudge_secs: f64,
|
||||
pub sr: f64,
|
||||
pub cc_access: Option<&'a dyn CcAccess>,
|
||||
pub speed_key: &'a str,
|
||||
pub mouse_x: f64,
|
||||
@@ -72,13 +77,18 @@ impl StepContext<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Underlying map for user-defined variables.
|
||||
pub type VariablesMap = HashMap<String, Value>;
|
||||
/// Shared variable store, swapped atomically after each step.
|
||||
pub type Variables = Arc<ArcSwap<VariablesMap>>;
|
||||
/// Shared user-defined word dictionary.
|
||||
pub type Dictionary = Arc<Mutex<HashMap<String, Vec<Op>>>>;
|
||||
/// Shared random number generator.
|
||||
pub type Rng = Arc<Mutex<StdRng>>;
|
||||
pub type Stack = Mutex<Vec<Value>>;
|
||||
pub(super) type CmdSnapshot<'a> = (Option<&'a Value>, &'a [(&'static str, Value)]);
|
||||
|
||||
/// Stack value in the Forth VM.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Value {
|
||||
Int(i64, Option<SourceSpan>),
|
||||
@@ -86,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 {
|
||||
@@ -97,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,
|
||||
}
|
||||
}
|
||||
@@ -133,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,6 +170,8 @@ pub(super) struct CmdRegister {
|
||||
sound: Option<Value>,
|
||||
params: Vec<(&'static str, Value)>,
|
||||
deltas: Vec<Value>,
|
||||
global_params: Vec<(&'static str, Value)>,
|
||||
delta_secs: Option<f64>,
|
||||
}
|
||||
|
||||
impl CmdRegister {
|
||||
@@ -168,6 +180,8 @@ impl CmdRegister {
|
||||
sound: None,
|
||||
params: Vec::with_capacity(16),
|
||||
deltas: Vec::with_capacity(4),
|
||||
global_params: Vec::new(),
|
||||
delta_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,9 +217,48 @@ impl CmdRegister {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn global_params(&self) -> &[(&'static str, Value)] {
|
||||
&self.global_params
|
||||
}
|
||||
|
||||
pub(super) fn commit_global(&mut self) {
|
||||
self.global_params.append(&mut self.params);
|
||||
self.sound = None;
|
||||
self.deltas.clear();
|
||||
}
|
||||
|
||||
pub(super) fn clear_global(&mut self) {
|
||||
self.global_params.clear();
|
||||
}
|
||||
|
||||
pub fn set_global(&mut self, params: Vec<(&'static str, Value)>) {
|
||||
self.global_params = params;
|
||||
}
|
||||
|
||||
pub fn take_global(&mut self) -> Vec<(&'static str, Value)> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ use super::types::{
|
||||
Value, Variables, VariablesMap,
|
||||
};
|
||||
|
||||
/// Forth VM instance. Holds the stack, variables, dictionary, and RNG.
|
||||
pub struct Forth {
|
||||
stack: Stack,
|
||||
vars: Variables,
|
||||
dict: Dictionary,
|
||||
rng: Rng,
|
||||
global_params: Mutex<Vec<(&'static str, Value)>>,
|
||||
}
|
||||
|
||||
impl Forth {
|
||||
@@ -28,6 +30,7 @@ impl Forth {
|
||||
vars,
|
||||
dict,
|
||||
rng,
|
||||
global_params: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +42,18 @@ impl Forth {
|
||||
self.stack.lock().clear();
|
||||
}
|
||||
|
||||
pub fn clear_global_params(&self) {
|
||||
self.global_params.lock().clear();
|
||||
}
|
||||
|
||||
/// Evaluate a Forth script and return audio command strings.
|
||||
pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result<Vec<String>, String> {
|
||||
let (outputs, var_writes) = self.evaluate_impl(script, ctx, None)?;
|
||||
self.apply_var_writes(var_writes);
|
||||
Ok(outputs)
|
||||
}
|
||||
|
||||
/// Evaluate and collect an execution trace for UI highlighting.
|
||||
pub fn evaluate_with_trace(
|
||||
&self,
|
||||
script: &str,
|
||||
@@ -56,6 +65,7 @@ impl Forth {
|
||||
Ok(outputs)
|
||||
}
|
||||
|
||||
/// Evaluate and return both outputs and pending variable writes (without applying them).
|
||||
pub fn evaluate_raw(
|
||||
&self,
|
||||
script: &str,
|
||||
@@ -102,6 +112,8 @@ impl Forth {
|
||||
let vars_snapshot = self.vars.load_full();
|
||||
let mut var_writes: HashMap<String, Value> = HashMap::new();
|
||||
|
||||
cmd.set_global(std::mem::take(&mut *self.global_params.lock()));
|
||||
|
||||
self.execute_ops(
|
||||
ops,
|
||||
ctx,
|
||||
@@ -113,6 +125,8 @@ impl Forth {
|
||||
&mut var_writes,
|
||||
)?;
|
||||
|
||||
*self.global_params.lock() = cmd.take_global();
|
||||
|
||||
Ok((outputs, var_writes))
|
||||
}
|
||||
|
||||
@@ -130,6 +144,7 @@ impl Forth {
|
||||
var_writes: &mut HashMap<String, Value>,
|
||||
) -> Result<(), String> {
|
||||
let mut pc = 0;
|
||||
let mut marks: Vec<usize> = Vec::new();
|
||||
let trace_cell = std::cell::RefCell::new(trace);
|
||||
let var_writes_cell = std::cell::RefCell::new(Some(var_writes));
|
||||
|
||||
@@ -214,8 +229,9 @@ impl Forth {
|
||||
_ => 1,
|
||||
};
|
||||
let param_max = cmd
|
||||
.params()
|
||||
.global_params()
|
||||
.iter()
|
||||
.chain(cmd.params().iter())
|
||||
.map(|(_, v)| match v {
|
||||
Value::CycleList(items) => items.len(),
|
||||
_ => 1,
|
||||
@@ -225,46 +241,29 @@ impl Forth {
|
||||
sound_len.max(param_max)
|
||||
};
|
||||
|
||||
let has_arp_list = |cmd: &CmdRegister| -> bool {
|
||||
matches!(cmd.sound(), Some(Value::ArpList(_)))
|
||||
|| 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>|
|
||||
-> Result<Option<Value>, String> {
|
||||
let (sound_opt, params) = cmd.snapshot().ok_or("nothing to emit")?;
|
||||
let has_sound = cmd.sound().is_some();
|
||||
let has_params = !cmd.params().is_empty();
|
||||
let has_global = !cmd.global_params().is_empty();
|
||||
if !has_sound && !has_params && !has_global {
|
||||
return Err("nothing to emit".into());
|
||||
}
|
||||
let resolved_sound_val =
|
||||
sound_opt.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,
|
||||
};
|
||||
let resolved_params: Vec<(&str, String)> = params
|
||||
let resolved_params: Vec<(&str, String)> = cmd.global_params()
|
||||
.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);
|
||||
@@ -279,6 +278,7 @@ impl Forth {
|
||||
&resolved_params,
|
||||
ctx.step_duration(),
|
||||
delta_secs,
|
||||
ctx.sr,
|
||||
outputs,
|
||||
);
|
||||
Ok(resolved_sound_val.map(|v| v.into_owned()))
|
||||
@@ -292,7 +292,7 @@ impl Forth {
|
||||
|
||||
Op::Dup => {
|
||||
ensure(stack, 1)?;
|
||||
let v = stack.last().unwrap().clone();
|
||||
let v = stack.last().expect("stack non-empty after ensure").clone();
|
||||
stack.push(v);
|
||||
}
|
||||
Op::Dupn => {
|
||||
@@ -305,6 +305,16 @@ impl Forth {
|
||||
Op::Drop => {
|
||||
pop(stack)?;
|
||||
}
|
||||
Op::Print => {
|
||||
let val = pop(stack)?;
|
||||
let text = match &val {
|
||||
Value::Int(n, _) => n.to_string(),
|
||||
Value::Float(f, _) => format!("{f}"),
|
||||
Value::Str(s, _) => s.to_string(),
|
||||
_ => format!("{val:?}"),
|
||||
};
|
||||
outputs.push(format!("print:{text}"));
|
||||
}
|
||||
Op::Swap => {
|
||||
ensure(stack, 2)?;
|
||||
let len = stack.len();
|
||||
@@ -426,7 +436,7 @@ impl Forth {
|
||||
if b.as_float().map_or(true, |v| v == 0.0) {
|
||||
return Err("division by zero".into());
|
||||
}
|
||||
stack.push(lift_binary(a, b, |x, y| x / y)?);
|
||||
stack.push(lift_binary(&a, &b, |x, y| x / y)?);
|
||||
}
|
||||
Op::Mod => {
|
||||
let b = pop(stack)?;
|
||||
@@ -434,47 +444,47 @@ impl Forth {
|
||||
if b.as_float().map_or(true, |v| v == 0.0) {
|
||||
return Err("modulo by zero".into());
|
||||
}
|
||||
let result = lift_binary(a, b, |x, y| (x as i64 % y as i64) as f64)?;
|
||||
let result = lift_binary(&a, &b, |x, y| (x as i64 % y as i64) as f64)?;
|
||||
stack.push(result);
|
||||
}
|
||||
Op::Neg => {
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| -x)?);
|
||||
stack.push(lift_unary(&v, |x| -x)?);
|
||||
}
|
||||
Op::Abs => {
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.abs())?);
|
||||
stack.push(lift_unary(&v, |x| x.abs())?);
|
||||
}
|
||||
Op::Floor => {
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.floor())?);
|
||||
stack.push(lift_unary(&v, |x| x.floor())?);
|
||||
}
|
||||
Op::Ceil => {
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.ceil())?);
|
||||
stack.push(lift_unary(&v, |x| x.ceil())?);
|
||||
}
|
||||
Op::Round => {
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.round())?);
|
||||
stack.push(lift_unary(&v, |x| x.round())?);
|
||||
}
|
||||
Op::Min => binary_op(stack, |a, b| a.min(b))?,
|
||||
Op::Max => binary_op(stack, |a, b| a.max(b))?,
|
||||
Op::Pow => binary_op(stack, |a, b| a.powf(b))?,
|
||||
Op::Sqrt => {
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.sqrt())?);
|
||||
stack.push(lift_unary(&v, |x| x.sqrt())?);
|
||||
}
|
||||
Op::Sin => {
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.sin())?);
|
||||
stack.push(lift_unary(&v, |x| x.sin())?);
|
||||
}
|
||||
Op::Cos => {
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.cos())?);
|
||||
stack.push(lift_unary(&v, |x| x.cos())?);
|
||||
}
|
||||
Op::Log => {
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.ln())?);
|
||||
stack.push(lift_unary(&v, |x| x.ln())?);
|
||||
}
|
||||
|
||||
Op::Eq => cmp_op(stack, |a, b| (a - b).abs() < f64::EPSILON)?,
|
||||
@@ -535,7 +545,10 @@ impl Forth {
|
||||
|
||||
Op::NewCmd => {
|
||||
ensure(stack, 1)?;
|
||||
let values = std::mem::take(stack);
|
||||
let values = drain_skip_quotations(stack);
|
||||
if values.is_empty() {
|
||||
return Err("expected sound name".into());
|
||||
}
|
||||
let val = if values.len() == 1 {
|
||||
values.into_iter().next().unwrap()
|
||||
} else {
|
||||
@@ -545,7 +558,10 @@ impl Forth {
|
||||
}
|
||||
Op::SetParam(param) => {
|
||||
ensure(stack, 1)?;
|
||||
let values = std::mem::take(stack);
|
||||
let values = drain_skip_quotations(stack);
|
||||
if values.is_empty() {
|
||||
return Err("expected parameter value".into());
|
||||
}
|
||||
let val = if values.len() == 1 {
|
||||
values.into_iter().next().unwrap()
|
||||
} else {
|
||||
@@ -555,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 {
|
||||
@@ -617,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) =
|
||||
@@ -780,16 +766,20 @@ impl Forth {
|
||||
drain_select_run(count, idx, stack, outputs, cmd)?;
|
||||
}
|
||||
|
||||
Op::Bounce(word_span) => {
|
||||
Op::Bounce(word_span) | Op::PBounce(word_span) => {
|
||||
let count = pop_int(stack)? as usize;
|
||||
if count == 0 {
|
||||
return Err("bounce count must be > 0".into());
|
||||
}
|
||||
let counter = match &ops[pc] {
|
||||
Op::Bounce(_) => ctx.runs,
|
||||
_ => ctx.iter,
|
||||
};
|
||||
let idx = if count == 1 {
|
||||
0
|
||||
} else {
|
||||
let period = 2 * (count - 1);
|
||||
let raw = ctx.runs % period;
|
||||
let raw = counter % period;
|
||||
if raw < count { raw } else { period - raw }
|
||||
};
|
||||
if let Some(span) = word_span {
|
||||
@@ -876,6 +866,47 @@ impl Forth {
|
||||
}
|
||||
}
|
||||
|
||||
Op::Except(word_span) => {
|
||||
let n = pop_int(stack)?;
|
||||
let quot = pop(stack)?;
|
||||
if n <= 0 {
|
||||
return Err("except count must be > 0".into());
|
||||
}
|
||||
let result = ctx.iter as i64 % n != 0;
|
||||
record_resolved(&trace_cell, *word_span, ResolvedValue::Bool(result));
|
||||
if result {
|
||||
run_quotation(quot, stack, outputs, cmd)?;
|
||||
}
|
||||
}
|
||||
|
||||
Op::EveryOffset(word_span) => {
|
||||
let offset = pop_int(stack)?;
|
||||
let n = pop_int(stack)?;
|
||||
let quot = pop(stack)?;
|
||||
if n <= 0 {
|
||||
return Err("every+ count must be > 0".into());
|
||||
}
|
||||
let result = ctx.iter as i64 % n == offset.rem_euclid(n);
|
||||
record_resolved(&trace_cell, *word_span, ResolvedValue::Bool(result));
|
||||
if result {
|
||||
run_quotation(quot, stack, outputs, cmd)?;
|
||||
}
|
||||
}
|
||||
|
||||
Op::ExceptOffset(word_span) => {
|
||||
let offset = pop_int(stack)?;
|
||||
let n = pop_int(stack)?;
|
||||
let quot = pop(stack)?;
|
||||
if n <= 0 {
|
||||
return Err("except+ count must be > 0".into());
|
||||
}
|
||||
let result = ctx.iter as i64 % n != offset.rem_euclid(n);
|
||||
record_resolved(&trace_cell, *word_span, ResolvedValue::Bool(result));
|
||||
if result {
|
||||
run_quotation(quot, stack, outputs, cmd)?;
|
||||
}
|
||||
}
|
||||
|
||||
Op::Bjork(word_span) | Op::PBjork(word_span) => {
|
||||
let n = pop_int(stack)?;
|
||||
let k = pop_int(stack)?;
|
||||
@@ -971,7 +1002,7 @@ impl Forth {
|
||||
let key = read_key(&var_writes_cell, vars_snapshot);
|
||||
let values = std::mem::take(stack);
|
||||
for val in values {
|
||||
let result = lift_unary_int(val, |degree| {
|
||||
let result = lift_unary_int(&val, |degree| {
|
||||
let octave_offset = degree.div_euclid(len);
|
||||
let idx = degree.rem_euclid(len) as usize;
|
||||
key + octave_offset * 12 + pattern[idx]
|
||||
@@ -1071,7 +1102,7 @@ impl Forth {
|
||||
Op::Oct => {
|
||||
let shift = pop(stack)?;
|
||||
let note = pop(stack)?;
|
||||
let result = lift_binary(note, shift, |n, s| n + s * 12.0)?;
|
||||
let result = lift_binary(¬e, &shift, |n, s| n + s * 12.0)?;
|
||||
stack.push(result);
|
||||
}
|
||||
|
||||
@@ -1096,13 +1127,13 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Loop => {
|
||||
let beats = pop_float(stack)?;
|
||||
let steps = pop_float(stack)?;
|
||||
if ctx.tempo == 0.0 || ctx.speed == 0.0 {
|
||||
return Err("tempo and speed must be non-zero".into());
|
||||
}
|
||||
let dur = beats * 60.0 / ctx.tempo / ctx.speed;
|
||||
let dur = steps * ctx.step_duration();
|
||||
cmd.set_param("fit", Value::Float(dur, None));
|
||||
cmd.set_param("dur", Value::Float(dur, None));
|
||||
cmd.set_param("gate", Value::Float(steps, None));
|
||||
}
|
||||
|
||||
Op::At => {
|
||||
@@ -1111,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)?;
|
||||
@@ -1194,6 +1273,37 @@ impl Forth {
|
||||
cmd.clear();
|
||||
}
|
||||
|
||||
Op::EmitAll => {
|
||||
// Retroactive: patch existing sound outputs with current params
|
||||
if !cmd.params().is_empty() {
|
||||
let step_duration = ctx.step_duration();
|
||||
for output in outputs.iter_mut() {
|
||||
if output.starts_with("/sound/") {
|
||||
use std::fmt::Write;
|
||||
for (k, v) in cmd.params() {
|
||||
let val_str = v.to_param_string();
|
||||
if !output.ends_with('/') {
|
||||
output.push('/');
|
||||
}
|
||||
if is_tempo_scaled_param(k) {
|
||||
if let Ok(val) = val_str.parse::<f64>() {
|
||||
let _ = write!(output, "{k}/{}", val * step_duration);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let _ = write!(output, "{k}/{val_str}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prospective: store for future emits
|
||||
cmd.commit_global();
|
||||
}
|
||||
|
||||
Op::ClearGlobal => {
|
||||
cmd.clear_global();
|
||||
}
|
||||
|
||||
Op::IntRange => {
|
||||
let end = pop_int(stack)?;
|
||||
let start = pop_int(stack)?;
|
||||
@@ -1259,6 +1369,15 @@ impl Forth {
|
||||
}
|
||||
}
|
||||
|
||||
Op::Map => {
|
||||
let quot = pop(stack)?;
|
||||
let items = std::mem::take(stack);
|
||||
for item in items {
|
||||
stack.push(item);
|
||||
run_quotation(quot.clone(), stack, outputs, cmd)?;
|
||||
}
|
||||
}
|
||||
|
||||
Op::GeomRange => {
|
||||
let count = pop_int(stack)?;
|
||||
let ratio = pop_float(stack)?;
|
||||
@@ -1308,7 +1427,7 @@ impl Forth {
|
||||
let dur = pop_float(stack)? * ctx.step_duration();
|
||||
let end = pop_float(stack)?;
|
||||
let start = pop_float(stack)?;
|
||||
let suffix = match curve { 1 => "e", 2 => "s", _ => "" };
|
||||
let suffix = match curve { 1 => "e", 2 => "s", 3 => "i", 4 => "o", 5 => "p", _ => "" };
|
||||
let s = format!("{start}>{end}:{dur}{suffix}");
|
||||
stack.push(Value::Str(s.into(), None));
|
||||
}
|
||||
@@ -1321,56 +1440,66 @@ impl Forth {
|
||||
stack.push(Value::Str(s.into(), None));
|
||||
}
|
||||
Op::ModEnv => {
|
||||
ensure(stack, 1)?;
|
||||
let values = std::mem::take(stack);
|
||||
let mut floats = Vec::with_capacity(values.len());
|
||||
for v in &values {
|
||||
floats.push(v.as_float()?);
|
||||
}
|
||||
if floats.len() < 3 || (floats.len() - 1) % 2 != 0 {
|
||||
return Err("env expects: start target1 dur1 [target2 dur2 ...]".into());
|
||||
}
|
||||
let step_dur = ctx.step_duration();
|
||||
let release = pop_float(stack)? * ctx.step_duration();
|
||||
let sustain = pop_float(stack)?;
|
||||
let decay = pop_float(stack)? * ctx.step_duration();
|
||||
let attack = pop_float(stack)? * ctx.step_duration();
|
||||
let max = pop_float(stack)?;
|
||||
let min = pop_float(stack)?;
|
||||
use std::fmt::Write;
|
||||
let mut s = String::new();
|
||||
let _ = write!(&mut s, "{}", floats[0]);
|
||||
for pair in floats[1..].chunks(2) {
|
||||
let _ = write!(&mut s, ">{}:{}", pair[0], pair[1] * step_dur);
|
||||
}
|
||||
let _ = write!(&mut s, "{min}^{max}:{attack}:{decay}:{sustain}:{release}");
|
||||
stack.push(Value::Str(s.into(), None));
|
||||
}
|
||||
|
||||
Op::ModEnvAd => {
|
||||
let decay = pop_float(stack)? * ctx.step_duration();
|
||||
let attack = pop_float(stack)? * ctx.step_duration();
|
||||
let max = pop_float(stack)?;
|
||||
let min = pop_float(stack)?;
|
||||
use std::fmt::Write;
|
||||
let mut s = String::new();
|
||||
let _ = write!(&mut s, "{min}^{max}:{attack}:{decay}:0:0");
|
||||
stack.push(Value::Str(s.into(), None));
|
||||
}
|
||||
|
||||
Op::ModEnvAdr => {
|
||||
let release = pop_float(stack)? * ctx.step_duration();
|
||||
let decay = pop_float(stack)? * ctx.step_duration();
|
||||
let attack = pop_float(stack)? * ctx.step_duration();
|
||||
let max = pop_float(stack)?;
|
||||
let min = pop_float(stack)?;
|
||||
use std::fmt::Write;
|
||||
let mut s = String::new();
|
||||
let _ = write!(&mut s, "{min}^{max}:{attack}:{decay}:0:{release}");
|
||||
stack.push(Value::Str(s.into(), None));
|
||||
}
|
||||
|
||||
Op::Lpg => {
|
||||
let depth = pop_float(stack)?.clamp(0.0, 1.0);
|
||||
let max = pop_float(stack)?;
|
||||
let min = pop_float(stack)?;
|
||||
let effective_max = min + (max - min) * depth;
|
||||
let sd = ctx.step_duration();
|
||||
let a = cmd_param_float(cmd, "attack").unwrap_or(0.0) * sd;
|
||||
let d = cmd_param_float(cmd, "decay").unwrap_or(1.0) * sd;
|
||||
let s = cmd_param_float(cmd, "sustain").unwrap_or(0.0);
|
||||
let r = cmd_param_float(cmd, "release").unwrap_or(0.0) * sd;
|
||||
use std::fmt::Write;
|
||||
let mut mod_str = String::new();
|
||||
let _ = write!(&mut mod_str, "{min}^{effective_max}:{a}:{d}:{s}:{r}");
|
||||
cmd.set_param("lpf", Value::Str(mod_str.into(), None));
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -1385,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(),
|
||||
));
|
||||
@@ -1394,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> {
|
||||
@@ -1410,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")
|
||||
@@ -1418,7 +1546,7 @@ impl Forth {
|
||||
.unwrap_or(0);
|
||||
let dev =
|
||||
get_int("dev").map(|d| d.clamp(0, 3) as u8).unwrap_or(0);
|
||||
let delta_suffix = if delta_secs > 0.0 {
|
||||
let delta_suffix = if delta_secs.abs() > 1e-9 {
|
||||
format!("/delta/{delta_secs}")
|
||||
} else {
|
||||
String::new()
|
||||
@@ -1449,7 +1577,7 @@ impl Forth {
|
||||
} else {
|
||||
let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8;
|
||||
let velocity =
|
||||
get_int("velocity").unwrap_or(100).clamp(0, 127) as u8;
|
||||
(get_float("velocity").unwrap_or(0.8) * 127.0).clamp(0.0, 127.0) as u8;
|
||||
let dur = get_float("dur").unwrap_or(1.0);
|
||||
let dur_secs = dur * ctx.step_duration();
|
||||
outputs.push(format!(
|
||||
@@ -1492,6 +1620,47 @@ impl Forth {
|
||||
.unwrap_or(0);
|
||||
stack.push(Value::Int(val as i64, None));
|
||||
}
|
||||
Op::Mark => {
|
||||
marks.push(stack.len());
|
||||
}
|
||||
Op::Count(span) => {
|
||||
let mark = marks.pop().ok_or("count without mark")?;
|
||||
stack.push(Value::Int((stack.len() - mark) as i64, *span));
|
||||
}
|
||||
Op::Index(word_span) => {
|
||||
let idx = pop_int(stack)?;
|
||||
let count = pop_int(stack)? as usize;
|
||||
if count == 0 {
|
||||
return Err("index count must be > 0".into());
|
||||
}
|
||||
let resolved_idx = ((idx % count as i64 + count as i64) % count as i64) as usize;
|
||||
if let Some(span) = word_span {
|
||||
if stack.len() >= count {
|
||||
let start = stack.len() - count;
|
||||
let selected = &stack[start + resolved_idx];
|
||||
record_resolved_from_value(&trace_cell, Some(*span), selected);
|
||||
}
|
||||
}
|
||||
drain_select_run(count, resolved_idx, stack, outputs, cmd)?;
|
||||
}
|
||||
Op::Rec => {
|
||||
let name = pop(stack)?;
|
||||
outputs.push(format!("/doux/rec/{}", name.as_str()?));
|
||||
}
|
||||
Op::Overdub => {
|
||||
let name = pop(stack)?;
|
||||
outputs.push(format!("/doux/rec/{}/overdub/1", name.as_str()?));
|
||||
}
|
||||
Op::Orec => {
|
||||
let orbit = pop(stack)?.as_int()?;
|
||||
let name = pop(stack)?;
|
||||
outputs.push(format!("/doux/rec/{}/orbit/{}", name.as_str()?, orbit));
|
||||
}
|
||||
Op::Odub => {
|
||||
let orbit = pop(stack)?.as_int()?;
|
||||
let name = pop(stack)?;
|
||||
outputs.push(format!("/doux/rec/{}/overdub/1/orbit/{}", name.as_str()?, orbit));
|
||||
}
|
||||
Op::Forget => {
|
||||
let name = pop(stack)?;
|
||||
self.dict.lock().remove(name.as_str()?);
|
||||
@@ -1544,30 +1713,18 @@ fn extract_dev_param(params: &[(&str, Value)]) -> u8 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn cmd_param_float(cmd: &CmdRegister, name: &str) -> Option<f64> {
|
||||
cmd.params()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(k, _)| *k == name)
|
||||
.and_then(|(_, v)| v.as_float().ok())
|
||||
}
|
||||
|
||||
fn is_tempo_scaled_param(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"attack"
|
||||
| "decay"
|
||||
| "release"
|
||||
| "lpa"
|
||||
| "lpd"
|
||||
| "lpr"
|
||||
| "hpa"
|
||||
| "hpd"
|
||||
| "hpr"
|
||||
| "bpa"
|
||||
| "bpd"
|
||||
| "bpr"
|
||||
| "patt"
|
||||
| "pdec"
|
||||
| "prel"
|
||||
| "fma"
|
||||
| "fmd"
|
||||
| "fmr"
|
||||
| "glide"
|
||||
| "chorusdelay"
|
||||
| "duration"
|
||||
"attack" | "decay" | "release" | "envdelay" | "hold" | "chorusdelay" | "gate"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1576,13 +1733,15 @@ fn emit_output(
|
||||
params: &[(&str, String)],
|
||||
step_duration: f64,
|
||||
nudge_secs: f64,
|
||||
sr: f64,
|
||||
outputs: &mut Vec<String>,
|
||||
) {
|
||||
use std::fmt::Write;
|
||||
let mut out = String::with_capacity(128);
|
||||
out.push('/');
|
||||
|
||||
let has_dur = params.iter().any(|(k, _)| *k == "dur");
|
||||
let has_gate = params.iter().any(|(k, _)| *k == "gate");
|
||||
let has_release = params.iter().any(|(k, _)| *k == "release");
|
||||
let delaytime_idx = params.iter().position(|(k, _)| *k == "delaytime");
|
||||
|
||||
if let Some(s) = sound {
|
||||
@@ -1590,6 +1749,9 @@ fn emit_output(
|
||||
}
|
||||
|
||||
for (i, (k, v)) in params.iter().enumerate() {
|
||||
if v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !out.ends_with('/') {
|
||||
out.push('/');
|
||||
}
|
||||
@@ -1607,18 +1769,26 @@ fn emit_output(
|
||||
}
|
||||
}
|
||||
|
||||
if nudge_secs > 0.0 {
|
||||
if nudge_secs.abs() > 1e-9 {
|
||||
if !out.ends_with('/') {
|
||||
out.push('/');
|
||||
}
|
||||
let _ = write!(&mut out, "delta/{nudge_secs}");
|
||||
let delta_ticks = (nudge_secs * sr).round() as i64;
|
||||
let _ = write!(&mut out, "delta/{delta_ticks}");
|
||||
}
|
||||
|
||||
if !has_dur {
|
||||
if !has_gate {
|
||||
if !out.ends_with('/') {
|
||||
out.push('/');
|
||||
}
|
||||
let _ = write!(&mut out, "dur/{}", step_duration * 4.0);
|
||||
let _ = write!(&mut out, "gate/{}", step_duration * 4.0);
|
||||
}
|
||||
|
||||
if !has_release {
|
||||
if !out.ends_with('/') {
|
||||
out.push('/');
|
||||
}
|
||||
let _ = write!(&mut out, "release/{}", 12.0 * step_duration);
|
||||
}
|
||||
|
||||
if sound.is_some() && delaytime_idx.is_none() {
|
||||
@@ -1664,8 +1834,8 @@ fn euclidean_rhythm(k: usize, n: usize, rotation: usize) -> Vec<i64> {
|
||||
groups.into_iter().partition(|g| g[0]);
|
||||
|
||||
for _ in 0..min_count {
|
||||
let mut one = ones.pop().unwrap();
|
||||
one.extend(zeros.pop().unwrap());
|
||||
let mut one = ones.pop().expect("ones sufficient for min_count");
|
||||
one.extend(zeros.pop().expect("zeros sufficient for min_count"));
|
||||
new_groups.push(one);
|
||||
}
|
||||
new_groups.extend(ones);
|
||||
@@ -1726,6 +1896,21 @@ fn pop_bool(stack: &mut Vec<Value>) -> Result<bool, String> {
|
||||
Ok(pop(stack)?.is_truthy())
|
||||
}
|
||||
|
||||
/// Drain the stack, returning non-quotation values.
|
||||
/// Quotations are pushed back onto the stack (transparent).
|
||||
fn drain_skip_quotations(stack: &mut Vec<Value>) -> Vec<Value> {
|
||||
let values = std::mem::take(stack);
|
||||
let mut result = Vec::new();
|
||||
for v in values {
|
||||
if matches!(v, Value::Quotation(..)) {
|
||||
stack.push(v);
|
||||
} else {
|
||||
result.push(v);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn ensure(stack: &[Value], n: usize) -> Result<(), String> {
|
||||
if stack.len() < n {
|
||||
return Err("stack underflow".into());
|
||||
@@ -1741,25 +1926,50 @@ fn float_to_value(result: f64) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn lift_unary<F>(val: Value, f: F) -> Result<Value, String>
|
||||
fn lift_unary<F>(val: &Value, f: F) -> Result<Value, String>
|
||||
where
|
||||
F: Fn(f64) -> f64,
|
||||
F: Fn(f64) -> f64 + Copy,
|
||||
{
|
||||
Ok(float_to_value(f(val.as_float()?)))
|
||||
match val {
|
||||
Value::CycleList(items) => {
|
||||
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x, f)).collect();
|
||||
Ok(Value::CycleList(Arc::from(mapped?)))
|
||||
}
|
||||
v => Ok(float_to_value(f(v.as_float()?))),
|
||||
}
|
||||
}
|
||||
|
||||
fn lift_unary_int<F>(val: Value, f: F) -> Result<Value, String>
|
||||
fn lift_unary_int<F>(val: &Value, f: F) -> Result<Value, String>
|
||||
where
|
||||
F: Fn(i64) -> i64,
|
||||
F: Fn(i64) -> i64 + Copy,
|
||||
{
|
||||
Ok(Value::Int(f(val.as_int()?), None))
|
||||
match val {
|
||||
Value::CycleList(items) => {
|
||||
let mapped: Result<Vec<_>, _> =
|
||||
items.iter().map(|x| lift_unary_int(x, f)).collect();
|
||||
Ok(Value::CycleList(Arc::from(mapped?)))
|
||||
}
|
||||
v => Ok(Value::Int(f(v.as_int()?), None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn lift_binary<F>(a: Value, b: Value, f: F) -> Result<Value, String>
|
||||
fn lift_binary<F>(a: &Value, b: &Value, f: F) -> Result<Value, String>
|
||||
where
|
||||
F: Fn(f64, f64) -> f64,
|
||||
F: Fn(f64, f64) -> f64 + Copy,
|
||||
{
|
||||
Ok(float_to_value(f(a.as_float()?, b.as_float()?)))
|
||||
match (a, b) {
|
||||
(Value::CycleList(items), b) => {
|
||||
let mapped: Result<Vec<_>, _> =
|
||||
items.iter().map(|x| lift_binary(x, b, f)).collect();
|
||||
Ok(Value::CycleList(Arc::from(mapped?)))
|
||||
}
|
||||
(a, Value::CycleList(items)) => {
|
||||
let mapped: Result<Vec<_>, _> =
|
||||
items.iter().map(|x| lift_binary(a, x, f)).collect();
|
||||
Ok(Value::CycleList(Arc::from(mapped?)))
|
||||
}
|
||||
(a, b) => Ok(float_to_value(f(a.as_float()?, b.as_float()?))),
|
||||
}
|
||||
}
|
||||
|
||||
fn binary_op<F>(stack: &mut Vec<Value>, f: F) -> Result<(), String>
|
||||
@@ -1768,7 +1978,7 @@ where
|
||||
{
|
||||
let b = pop(stack)?;
|
||||
let a = pop(stack)?;
|
||||
stack.push(lift_binary(a, b, f)?);
|
||||
stack.push(lift_binary(&a, &b, f)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1787,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())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Word-to-Op translation: maps Forth word names to compiled instructions.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ops::Op;
|
||||
@@ -11,6 +13,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
||||
"dup" => Op::Dup,
|
||||
"dupn" => Op::Dupn,
|
||||
"drop" => Op::Drop,
|
||||
"print" => Op::Print,
|
||||
"swap" => Op::Swap,
|
||||
"over" => Op::Over,
|
||||
"rot" => Op::Rot,
|
||||
@@ -56,7 +59,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
||||
"nand" => Op::Nand,
|
||||
"nor" => Op::Nor,
|
||||
"ifelse" => Op::IfElse,
|
||||
"pick" => Op::Pick,
|
||||
"select" => Op::Pick,
|
||||
"sound" => Op::NewCmd,
|
||||
"." => Op::Emit,
|
||||
"rand" => Op::Rand(None),
|
||||
@@ -67,8 +70,12 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
||||
"pcycle" => Op::PCycle(None),
|
||||
"choose" => Op::Choose(None),
|
||||
"bounce" => Op::Bounce(None),
|
||||
"pbounce" => Op::PBounce(None),
|
||||
"wchoose" => Op::WChoose(None),
|
||||
"every" => Op::Every(None),
|
||||
"except" => Op::Except(None),
|
||||
"every+" => Op::EveryOffset(None),
|
||||
"except+" => Op::ExceptOffset(None),
|
||||
"bjork" => Op::Bjork(None),
|
||||
"pbjork" => Op::PBjork(None),
|
||||
"chance" => Op::ChanceExec(None),
|
||||
@@ -81,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,
|
||||
@@ -94,6 +101,8 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
||||
"loop" => Op::Loop,
|
||||
"oct" => Op::Oct,
|
||||
"clear" => Op::ClearCmd,
|
||||
"all" => Op::EmitAll,
|
||||
"noall" => Op::ClearGlobal,
|
||||
".." => Op::IntRange,
|
||||
".," => Op::StepRange,
|
||||
"gen" => Op::Generate,
|
||||
@@ -101,13 +110,19 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
||||
"euclid" => Op::Euclid,
|
||||
"euclidrot" => Op::EuclidRot,
|
||||
"times" => Op::Times,
|
||||
"map" => Op::Map,
|
||||
"m." => Op::MidiEmit,
|
||||
"ccval" => Op::GetMidiCC,
|
||||
"mclock" => Op::MidiClock,
|
||||
"mstart" => Op::MidiStart,
|
||||
"mstop" => Op::MidiStop,
|
||||
"mcont" => Op::MidiContinue,
|
||||
"rec" => Op::Rec,
|
||||
"overdub" | "dub" => Op::Overdub,
|
||||
"orec" => Op::Orec,
|
||||
"odub" => Op::Odub,
|
||||
"forget" => Op::Forget,
|
||||
"index" => Op::Index(None),
|
||||
"key!" => Op::SetKey,
|
||||
"tp" => Op::Transpose,
|
||||
"inv" => Op::Invert,
|
||||
@@ -121,10 +136,16 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
||||
"slide" => Op::ModSlide(0),
|
||||
"expslide" => Op::ModSlide(1),
|
||||
"sslide" => Op::ModSlide(2),
|
||||
"islide" => Op::ModSlide(3),
|
||||
"oslide" => Op::ModSlide(4),
|
||||
"pslide" => Op::ModSlide(5),
|
||||
"jit" => Op::ModRnd(0),
|
||||
"sjit" => Op::ModRnd(1),
|
||||
"drunk" => Op::ModRnd(2),
|
||||
"env" => Op::ModEnv,
|
||||
"ead" => Op::ModEnvAd,
|
||||
"eadr" => Op::ModEnvAdr,
|
||||
"eadsr" | "env" => Op::ModEnv,
|
||||
"lpg" => Op::Lpg,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -201,9 +222,10 @@ fn attach_span(op: &mut Op, span: SourceSpan) {
|
||||
match op {
|
||||
Op::Rand(s) | Op::ExpRand(s) | Op::LogRand(s) | Op::Coin(s)
|
||||
| Op::Choose(s) | Op::WChoose(s) | Op::Cycle(s) | Op::PCycle(s)
|
||||
| Op::Bounce(s) | Op::ChanceExec(s) | Op::ProbExec(s)
|
||||
| Op::Every(s)
|
||||
| Op::Bjork(s) | Op::PBjork(s) => *s = Some(span),
|
||||
| Op::Bounce(s) | Op::PBounce(s) | Op::ChanceExec(s) | Op::ProbExec(s)
|
||||
| Op::Every(s) | Op::Except(s) | Op::EveryOffset(s) | Op::ExceptOffset(s)
|
||||
| Op::Bjork(s) | Op::PBjork(s)
|
||||
| Op::Count(s) | Op::Index(s) => *s = Some(span),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{Word, WordCompile::*};
|
||||
//! Word metadata for core language primitives (stack, arithmetic, logic, variables, definitions).
|
||||
|
||||
// Stack, Arithmetic, Comparison, Logic, Control, Variables, Definitions
|
||||
use super::{Word, WordCompile::*};
|
||||
pub(super) const WORDS: &[Word] = &[
|
||||
// Stack manipulation
|
||||
Word {
|
||||
@@ -33,6 +33,16 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "print",
|
||||
aliases: &[],
|
||||
category: "Stack",
|
||||
stack: "(x --)",
|
||||
desc: "Print top of stack to footer bar",
|
||||
example: "42 print",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "swap",
|
||||
aliases: &[],
|
||||
@@ -502,17 +512,17 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Logic",
|
||||
stack: "(true-quot false-quot bool --)",
|
||||
desc: "Execute true-quot if true, else false-quot",
|
||||
example: "{ 1 } { 2 } coin ifelse",
|
||||
example: "( 1 ) ( 2 ) coin ifelse",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "pick",
|
||||
name: "select",
|
||||
aliases: &[],
|
||||
category: "Logic",
|
||||
stack: "(..quots n --)",
|
||||
desc: "Execute nth quotation (0-indexed)",
|
||||
example: "{ 1 } { 2 } { 3 } 2 pick => 3",
|
||||
example: "( 1 ) ( 2 ) ( 3 ) 2 select => 3",
|
||||
compile: Simple,
|
||||
varargs: true,
|
||||
},
|
||||
@@ -522,7 +532,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Logic",
|
||||
stack: "(quot bool --)",
|
||||
desc: "Execute quotation if true",
|
||||
example: "{ 2 distort } 0.5 chance ?",
|
||||
example: "( 2 distort ) 0.5 chance ?",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
@@ -532,7 +542,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Logic",
|
||||
stack: "(quot bool --)",
|
||||
desc: "Execute quotation if false",
|
||||
example: "{ 1 distort } 0.5 chance !?",
|
||||
example: "( 1 distort ) 0.5 chance !?",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
@@ -542,7 +552,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Logic",
|
||||
stack: "(quot --)",
|
||||
desc: "Execute quotation unconditionally",
|
||||
example: "{ 2 * } apply",
|
||||
example: "( 2 * ) apply",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
@@ -553,7 +563,17 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Control",
|
||||
stack: "(n quot --)",
|
||||
desc: "Execute quotation n times, @i holds current index",
|
||||
example: "4 { @i . } times => 0 1 2 3",
|
||||
example: "4 ( @i . ) times => 0 1 2 3",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "map",
|
||||
aliases: &[],
|
||||
category: "Control",
|
||||
stack: "(..vals quot -- ..results)",
|
||||
desc: "Apply quotation to each stack element",
|
||||
example: "1 2 3 ( 10 * ) map => 10 20 30",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{Word, WordCompile::*};
|
||||
//! Word metadata for audio effect parameters (filter, envelope, reverb, delay, lo-fi, stereo, mod FX).
|
||||
|
||||
// Filter, Envelope, Reverb, Delay, Lo-fi, Stereo, Mod FX
|
||||
use super::{Word, WordCompile::*};
|
||||
pub(super) const WORDS: &[Word] = &[
|
||||
// Envelope
|
||||
Word {
|
||||
@@ -28,14 +28,14 @@ pub(super) const WORDS: &[Word] = &[
|
||||
aliases: &[],
|
||||
category: "Envelope",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set velocity",
|
||||
example: "100 velocity",
|
||||
desc: "Set velocity (0-1)",
|
||||
example: "0.8 velocity",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "attack",
|
||||
aliases: &["att"],
|
||||
aliases: &["att", "a"],
|
||||
category: "Envelope",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set attack time",
|
||||
@@ -45,7 +45,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
},
|
||||
Word {
|
||||
name: "decay",
|
||||
aliases: &["dec"],
|
||||
aliases: &["dec", "d"],
|
||||
category: "Envelope",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set decay time",
|
||||
@@ -55,7 +55,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
},
|
||||
Word {
|
||||
name: "sustain",
|
||||
aliases: &["sus"],
|
||||
aliases: &["sus", "s"],
|
||||
category: "Envelope",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set sustain level",
|
||||
@@ -65,7 +65,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
},
|
||||
Word {
|
||||
name: "release",
|
||||
aliases: &["rel"],
|
||||
aliases: &["rel", "r"],
|
||||
category: "Envelope",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set release time",
|
||||
@@ -73,6 +73,26 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "envdelay",
|
||||
aliases: &["envdly"],
|
||||
category: "Envelope",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set envelope delay time",
|
||||
example: "0.1 envdelay",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "hold",
|
||||
aliases: &["hld"],
|
||||
category: "Envelope",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set envelope hold time",
|
||||
example: "0.05 hold",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "adsr",
|
||||
aliases: &[],
|
||||
@@ -93,56 +113,6 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "penv",
|
||||
aliases: &[],
|
||||
category: "Envelope",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set pitch envelope",
|
||||
example: "0.5 penv",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "patt",
|
||||
aliases: &[],
|
||||
category: "Envelope",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set pitch attack",
|
||||
example: "0.01 patt",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "pdec",
|
||||
aliases: &[],
|
||||
category: "Envelope",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set pitch decay",
|
||||
example: "0.1 pdec",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "psus",
|
||||
aliases: &[],
|
||||
category: "Envelope",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set pitch sustain",
|
||||
example: "0 psus",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "prel",
|
||||
aliases: &[],
|
||||
category: "Envelope",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set pitch release",
|
||||
example: "0.1 prel",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
// Filter
|
||||
Word {
|
||||
name: "lpf",
|
||||
@@ -164,56 +134,6 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "lpe",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set lowpass envelope",
|
||||
example: "0.5 lpe",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "lpa",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set lowpass attack",
|
||||
example: "0.01 lpa",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "lpd",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set lowpass decay",
|
||||
example: "0.1 lpd",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "lps",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set lowpass sustain",
|
||||
example: "0.5 lps",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "lpr",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set lowpass release",
|
||||
example: "0.3 lpr",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "hpf",
|
||||
aliases: &[],
|
||||
@@ -234,56 +154,6 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "hpe",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set highpass envelope",
|
||||
example: "0.5 hpe",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "hpa",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set highpass attack",
|
||||
example: "0.01 hpa",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "hpd",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set highpass decay",
|
||||
example: "0.1 hpd",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "hps",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set highpass sustain",
|
||||
example: "0.5 hps",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "hpr",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set highpass release",
|
||||
example: "0.3 hpr",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "bpf",
|
||||
aliases: &[],
|
||||
@@ -304,56 +174,6 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "bpe",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set bandpass envelope",
|
||||
example: "0.5 bpe",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "bpa",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set bandpass attack",
|
||||
example: "0.01 bpa",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "bpd",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set bandpass decay",
|
||||
example: "0.1 bpd",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "bps",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set bandpass sustain",
|
||||
example: "0.5 bps",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "bpr",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set bandpass release",
|
||||
example: "0.3 bpr",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "llpf",
|
||||
aliases: &[],
|
||||
@@ -454,6 +274,36 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "eqlofreq",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set low shelf frequency (Hz)",
|
||||
example: "400 eqlofreq",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "eqmidfreq",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set mid peak frequency (Hz)",
|
||||
example: "2000 eqmidfreq",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "eqhifreq",
|
||||
aliases: &[],
|
||||
category: "Filter",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set high shelf frequency (Hz)",
|
||||
example: "8000 eqhifreq",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "tilt",
|
||||
aliases: &[],
|
||||
@@ -959,4 +809,45 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
// Compressor
|
||||
Word {
|
||||
name: "comp",
|
||||
aliases: &[],
|
||||
category: "Compressor",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set sidechain duck amount (0-1)",
|
||||
example: "0.8 comp",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "compattack",
|
||||
aliases: &["cattack"],
|
||||
category: "Compressor",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set compressor attack time in seconds",
|
||||
example: "0.01 compattack",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "comprelease",
|
||||
aliases: &["crelease"],
|
||||
category: "Compressor",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set compressor release time in seconds",
|
||||
example: "0.15 comprelease",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "comporbit",
|
||||
aliases: &["corbit"],
|
||||
category: "Compressor",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set sidechain source orbit",
|
||||
example: "0 comporbit",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! MIDI word definitions: channel, CC, pitch bend, transport, and device routing.
|
||||
|
||||
use super::{Word, WordCompile::*};
|
||||
|
||||
// MIDI
|
||||
pub(super) const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "chan",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Built-in word definitions and lookup for the Forth VM.
|
||||
|
||||
mod compile;
|
||||
mod core;
|
||||
mod effects;
|
||||
@@ -11,6 +13,7 @@ use std::sync::LazyLock;
|
||||
|
||||
pub(crate) use compile::compile_word;
|
||||
|
||||
/// How a word is compiled into ops.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum WordCompile {
|
||||
Simple,
|
||||
@@ -19,6 +22,7 @@ pub enum WordCompile {
|
||||
Probability(f64),
|
||||
}
|
||||
|
||||
/// Metadata for a built-in Forth word.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Word {
|
||||
pub name: &'static str,
|
||||
@@ -31,6 +35,7 @@ pub struct Word {
|
||||
pub varargs: bool,
|
||||
}
|
||||
|
||||
/// All built-in words, aggregated from every category module.
|
||||
pub static WORDS: LazyLock<Vec<Word>> = LazyLock::new(|| {
|
||||
let mut words = Vec::new();
|
||||
words.extend_from_slice(self::core::WORDS);
|
||||
@@ -42,6 +47,7 @@ pub static WORDS: LazyLock<Vec<Word>> = LazyLock::new(|| {
|
||||
words
|
||||
});
|
||||
|
||||
/// Index mapping word names and aliases to their definitions.
|
||||
static WORD_MAP: LazyLock<HashMap<&'static str, &'static Word>> = LazyLock::new(|| {
|
||||
let mut map = HashMap::with_capacity(WORDS.len() * 2);
|
||||
for word in WORDS.iter() {
|
||||
@@ -53,6 +59,7 @@ static WORD_MAP: LazyLock<HashMap<&'static str, &'static Word>> = LazyLock::new(
|
||||
map
|
||||
});
|
||||
|
||||
/// Find a word by name or alias.
|
||||
pub fn lookup_word(name: &str) -> Option<&'static Word> {
|
||||
WORD_MAP.get(name).copied()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Word definitions for music theory, harmony, and chord construction.
|
||||
|
||||
use super::{Word, WordCompile::*};
|
||||
|
||||
// Music, Chord
|
||||
pub(super) const WORDS: &[Word] = &[
|
||||
// Music
|
||||
Word {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Word metadata for sequencing: probability, timing, context queries, generators.
|
||||
|
||||
use super::{Word, WordCompile::*};
|
||||
|
||||
// Time, Context, Probability, Generator, Desktop
|
||||
pub(super) const WORDS: &[Word] = &[
|
||||
// Probability
|
||||
Word {
|
||||
@@ -59,7 +60,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Probability",
|
||||
stack: "(quot prob --)",
|
||||
desc: "Execute quotation with probability (0.0-1.0)",
|
||||
example: "{ 2 distort } 0.75 chance",
|
||||
example: "( 2 distort ) 0.75 chance",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
@@ -69,7 +70,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Probability",
|
||||
stack: "(quot pct --)",
|
||||
desc: "Execute quotation with probability (0-100)",
|
||||
example: "{ 2 distort } 75 prob",
|
||||
example: "( 2 distort ) 75 prob",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
@@ -113,6 +114,26 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Simple,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "pbounce",
|
||||
aliases: &[],
|
||||
category: "Probability",
|
||||
stack: "(v1..vn n -- selected)",
|
||||
desc: "Ping-pong cycle through n items by pattern iteration",
|
||||
example: "60 64 67 72 4 pbounce",
|
||||
compile: Simple,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "index",
|
||||
aliases: &[],
|
||||
category: "Probability",
|
||||
stack: "(v1..vn n idx -- selected)",
|
||||
desc: "Select item at explicit index",
|
||||
example: "[ c4 e4 g4 ] step index",
|
||||
compile: Simple,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "wchoose",
|
||||
aliases: &[],
|
||||
@@ -129,7 +150,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Probability",
|
||||
stack: "(quot --)",
|
||||
desc: "Always execute quotation",
|
||||
example: "{ 2 distort } always",
|
||||
example: "( 2 distort ) always",
|
||||
compile: Probability(1.0),
|
||||
varargs: false,
|
||||
},
|
||||
@@ -139,7 +160,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Probability",
|
||||
stack: "(quot --)",
|
||||
desc: "Never execute quotation",
|
||||
example: "{ 2 distort } never",
|
||||
example: "( 2 distort ) never",
|
||||
compile: Probability(0.0),
|
||||
varargs: false,
|
||||
},
|
||||
@@ -149,7 +170,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Probability",
|
||||
stack: "(quot --)",
|
||||
desc: "Execute quotation 75% of the time",
|
||||
example: "{ 2 distort } often",
|
||||
example: "( 2 distort ) often",
|
||||
compile: Probability(0.75),
|
||||
varargs: false,
|
||||
},
|
||||
@@ -159,7 +180,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Probability",
|
||||
stack: "(quot --)",
|
||||
desc: "Execute quotation 50% of the time",
|
||||
example: "{ 2 distort } sometimes",
|
||||
example: "( 2 distort ) sometimes",
|
||||
compile: Probability(0.5),
|
||||
varargs: false,
|
||||
},
|
||||
@@ -169,7 +190,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Probability",
|
||||
stack: "(quot --)",
|
||||
desc: "Execute quotation 25% of the time",
|
||||
example: "{ 2 distort } rarely",
|
||||
example: "( 2 distort ) rarely",
|
||||
compile: Probability(0.25),
|
||||
varargs: false,
|
||||
},
|
||||
@@ -179,7 +200,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Probability",
|
||||
stack: "(quot --)",
|
||||
desc: "Execute quotation 10% of the time",
|
||||
example: "{ 2 distort } almostNever",
|
||||
example: "( 2 distort ) almostNever",
|
||||
compile: Probability(0.1),
|
||||
varargs: false,
|
||||
},
|
||||
@@ -189,7 +210,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Probability",
|
||||
stack: "(quot --)",
|
||||
desc: "Execute quotation 90% of the time",
|
||||
example: "{ 2 distort } almostAlways",
|
||||
example: "( 2 distort ) almostAlways",
|
||||
compile: Probability(0.9),
|
||||
varargs: false,
|
||||
},
|
||||
@@ -200,7 +221,37 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Time",
|
||||
stack: "(quot n --)",
|
||||
desc: "Execute quotation every nth iteration",
|
||||
example: "{ 2 distort } 4 every",
|
||||
example: "( 2 distort ) 4 every",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "except",
|
||||
aliases: &[],
|
||||
category: "Time",
|
||||
stack: "(quot n --)",
|
||||
desc: "Execute quotation on all iterations except every nth",
|
||||
example: "( 2 distort ) 4 except",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "every+",
|
||||
aliases: &[],
|
||||
category: "Time",
|
||||
stack: "(quot n offset --)",
|
||||
desc: "Execute quotation every nth iteration with phase offset",
|
||||
example: "( snare ) 4 2 every+ => fires at iter 2, 6, 10...",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "except+",
|
||||
aliases: &[],
|
||||
category: "Time",
|
||||
stack: "(quot n offset --)",
|
||||
desc: "Skip quotation every nth iteration with phase offset",
|
||||
example: "( snare ) 4 2 except+ => skips at iter 2, 6, 10...",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
@@ -210,7 +261,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Time",
|
||||
stack: "(quot k n --)",
|
||||
desc: "Execute quotation using Euclidean distribution over step runs",
|
||||
example: "{ 2 distort } 3 8 bjork",
|
||||
example: "( 2 distort ) 3 8 bjork",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
@@ -220,7 +271,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Time",
|
||||
stack: "(quot k n --)",
|
||||
desc: "Execute quotation using Euclidean distribution over pattern iterations",
|
||||
example: "{ 2 distort } 3 8 pbjork",
|
||||
example: "( 2 distort ) 3 8 pbjork",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
@@ -229,8 +280,8 @@ pub(super) const WORDS: &[Word] = &[
|
||||
aliases: &[],
|
||||
category: "Time",
|
||||
stack: "(n --)",
|
||||
desc: "Fit sample to n beats",
|
||||
example: "\"break\" s 4 loop @",
|
||||
desc: "Fit sample to n steps",
|
||||
example: "\"break\" s 16 loop @",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
@@ -258,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,
|
||||
},
|
||||
@@ -405,7 +456,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Desktop",
|
||||
stack: "(-- bool)",
|
||||
desc: "1 when mouse button held, 0 otherwise",
|
||||
example: "mdown { \"crash\" s . } ?",
|
||||
example: "mdown ( \"crash\" s . ) ?",
|
||||
compile: Context("mdown"),
|
||||
varargs: false,
|
||||
},
|
||||
@@ -436,7 +487,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
category: "Generator",
|
||||
stack: "(quot n -- results...)",
|
||||
desc: "Execute quotation n times, push all results",
|
||||
example: "{ 1 6 rand } 4 gen => 4 random values",
|
||||
example: "( 1 6 rand ) 4 gen => 4 random values",
|
||||
compile: Simple,
|
||||
varargs: true,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Word metadata for sound commands, sample/oscillator params, FM, modulation, and LFO.
|
||||
|
||||
use super::{Word, WordCompile::*};
|
||||
|
||||
// Sound, Oscillator, Sample, Wavetable, FM, Modulation, LFO
|
||||
pub(super) const WORDS: &[Word] = &[
|
||||
// Sound
|
||||
Word {
|
||||
name: "sound",
|
||||
aliases: &["s"],
|
||||
aliases: &["snd"],
|
||||
category: "Sound",
|
||||
stack: "(name --)",
|
||||
desc: "Begin sound command",
|
||||
@@ -23,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: &[],
|
||||
@@ -43,6 +34,67 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "all",
|
||||
aliases: &[],
|
||||
category: "Sound",
|
||||
stack: "(--)",
|
||||
desc: "Apply current params to all sounds",
|
||||
example: "500 lpf 0.5 verb all",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "noall",
|
||||
aliases: &[],
|
||||
category: "Sound",
|
||||
stack: "(--)",
|
||||
desc: "Clear global params",
|
||||
example: "noall",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
// Recording
|
||||
Word {
|
||||
name: "rec",
|
||||
aliases: &[],
|
||||
category: "Sound",
|
||||
stack: "(name --)",
|
||||
desc: "Toggle recording audio output to named sample",
|
||||
example: "\"loop1\" rec",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "overdub",
|
||||
aliases: &["dub"],
|
||||
category: "Sound",
|
||||
stack: "(name --)",
|
||||
desc: "Toggle overdub recording onto existing named sample",
|
||||
example: "\"loop1\" overdub",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "orec",
|
||||
aliases: &[],
|
||||
category: "Sound",
|
||||
stack: "(name orbit --)",
|
||||
desc: "Toggle recording a single orbit into named sample",
|
||||
example: "\"drums\" 0 orec",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "odub",
|
||||
aliases: &[],
|
||||
category: "Sound",
|
||||
stack: "(name orbit --)",
|
||||
desc: "Toggle overdub recording a single orbit onto named sample",
|
||||
example: "\"drums\" 0 odub",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
// Sample
|
||||
Word {
|
||||
name: "bank",
|
||||
@@ -64,22 +116,12 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "repeat",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set repeat count",
|
||||
example: "4 repeat",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "dur",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set duration",
|
||||
desc: "Set MIDI note duration (for audio, use gate)",
|
||||
example: "0.5 dur",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
@@ -89,7 +131,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set gate time",
|
||||
desc: "Set gate duration (total note length, 0 = infinite sustain)",
|
||||
example: "0.8 gate",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
@@ -104,6 +146,16 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "stretch",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(v.. --)",
|
||||
desc: "Time stretch factor (pitch-independent)",
|
||||
example: "2 stretch",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "begin",
|
||||
aliases: &[],
|
||||
@@ -124,6 +176,26 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "slice",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(v.. --)",
|
||||
desc: "Divide sample into N equal slices",
|
||||
example: r#""break" s 8 slice 3 pick ."#,
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "pick",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(v.. --)",
|
||||
desc: "Select which slice to play (0-indexed, wraps)",
|
||||
example: r#""break" s 8 slice 3 pick ."#,
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "voice",
|
||||
aliases: &[],
|
||||
@@ -154,6 +226,16 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "inchan",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(v.. --)",
|
||||
desc: "Select input channel for live input (0-indexed)",
|
||||
example: "0 inchan",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "cut",
|
||||
aliases: &[],
|
||||
@@ -195,16 +277,6 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "glide",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set glide/portamento",
|
||||
example: "0.1 glide",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "pw",
|
||||
aliases: &[],
|
||||
@@ -270,7 +342,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set harmonics (mutable only)",
|
||||
desc: "Set harmonics (add source)",
|
||||
example: "4 harmonics",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
@@ -280,7 +352,7 @@ pub(super) const WORDS: &[Word] = &[
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set timbre (mutable only)",
|
||||
desc: "Set timbre (add source)",
|
||||
example: "0.5 timbre",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
@@ -290,11 +362,21 @@ pub(super) const WORDS: &[Word] = &[
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set morph (mutable only)",
|
||||
desc: "Set morph (add source)",
|
||||
example: "0.5 morph",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "partials",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set number of active harmonics (add source only)",
|
||||
example: "16 partials",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "coarse",
|
||||
aliases: &[],
|
||||
@@ -366,36 +448,6 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "scanlfo",
|
||||
aliases: &[],
|
||||
category: "Wavetable",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set scan LFO rate (Hz)",
|
||||
example: "0.2 scanlfo",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "scandepth",
|
||||
aliases: &[],
|
||||
category: "Wavetable",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set scan LFO depth (0-1)",
|
||||
example: "0.4 scandepth",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "scanshape",
|
||||
aliases: &[],
|
||||
category: "Wavetable",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set scan LFO shape (sine/tri/saw/square/sh)",
|
||||
example: "\"tri\" scanshape",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
// FM
|
||||
Word {
|
||||
name: "fm",
|
||||
@@ -427,56 +479,6 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fme",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM envelope",
|
||||
example: "0.5 fme",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fma",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM attack",
|
||||
example: "0.01 fma",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fmd",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM decay",
|
||||
example: "0.1 fmd",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fms",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM sustain",
|
||||
example: "0.5 fms",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fmr",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM release",
|
||||
example: "0.1 fmr",
|
||||
compile: Param,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fm2",
|
||||
aliases: &[],
|
||||
@@ -750,6 +752,36 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "islide",
|
||||
aliases: &[],
|
||||
category: "Audio Modulation",
|
||||
stack: "(start end dur -- str)",
|
||||
desc: "Swell transition (slow start, fast finish): start>end:duri",
|
||||
example: "200 4000 1 islide lpf",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "oslide",
|
||||
aliases: &[],
|
||||
category: "Audio Modulation",
|
||||
stack: "(start end dur -- str)",
|
||||
desc: "Pluck transition (fast attack, slow settle): start>end:duro",
|
||||
example: "0 1 0.5 oslide gain",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "pslide",
|
||||
aliases: &[],
|
||||
category: "Audio Modulation",
|
||||
stack: "(start end dur -- str)",
|
||||
desc: "Stair transition (8 discrete steps): start>end:durp",
|
||||
example: "0 1 2 pslide gain",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "jit",
|
||||
aliases: &[],
|
||||
@@ -780,13 +812,53 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "ead",
|
||||
aliases: &[],
|
||||
category: "Audio Modulation",
|
||||
stack: "(min max a d -- str)",
|
||||
desc: "Percussive envelope mod: min^max:a:d:0:0",
|
||||
example: "200 8000 0.01 0.1 ead lpf",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "eadr",
|
||||
aliases: &[],
|
||||
category: "Audio Modulation",
|
||||
stack: "(min max a d r -- str)",
|
||||
desc: "Percussive envelope mod with release: min^max:a:d:0:r",
|
||||
example: "200 8000 0.01 0.1 0.3 eadr lpf",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "eadsr",
|
||||
aliases: &[],
|
||||
category: "Audio Modulation",
|
||||
stack: "(min max a d s r -- str)",
|
||||
desc: "ADSR envelope mod: min^max:a:d:s:r",
|
||||
example: "200 8000 0.01 0.1 0.5 0.3 eadsr lpf",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "env",
|
||||
aliases: &[],
|
||||
category: "Audio Modulation",
|
||||
stack: "(start t1 d1 ... -- str)",
|
||||
desc: "Multi-segment envelope: start>t1:d1>...",
|
||||
example: "0 1 0.01 0.7 0.1 0 2 env gain",
|
||||
stack: "(min max a d s r -- str)",
|
||||
desc: "DAHDSR envelope modulation: min^max:a:d:s:r",
|
||||
example: "200 8000 0.01 0.1 0.5 0.3 env lpf",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "lpg",
|
||||
aliases: &[],
|
||||
category: "Audio Modulation",
|
||||
stack: "(min max depth --)",
|
||||
desc: "Low pass gate: pairs amp envelope with lpf modulation",
|
||||
example: "0.01 0.1 ad 200 8000 1 lpg .",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
|
||||
15
crates/markdown/README.md
Normal file
15
crates/markdown/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# cagire-markdown
|
||||
|
||||
Markdown parser and renderer that produces ratatui-styled lines. Used for the built-in help/documentation views.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `parser` | Markdown-to-styled-lines conversion |
|
||||
| `highlighter` | `CodeHighlighter` trait for syntax highlighting in fenced code blocks |
|
||||
| `theme` | Color mappings for markdown elements |
|
||||
|
||||
## Key Trait
|
||||
|
||||
- **`CodeHighlighter`** — Implement to provide language-specific syntax highlighting. Returns `Vec<(Style, String)>` per line.
|
||||
@@ -1,9 +1,13 @@
|
||||
//! Syntax highlighting trait for fenced code blocks in markdown.
|
||||
|
||||
use ratatui::style::Style;
|
||||
|
||||
/// Produce styled spans from a single line of source code.
|
||||
pub trait CodeHighlighter {
|
||||
fn highlight(&self, line: &str) -> Vec<(Style, String)>;
|
||||
}
|
||||
|
||||
/// Pass-through highlighter that applies no styling.
|
||||
pub struct NoHighlight;
|
||||
|
||||
impl CodeHighlighter for NoHighlight {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Parse markdown into styled ratatui lines with pluggable syntax highlighting.
|
||||
|
||||
mod highlighter;
|
||||
mod parser;
|
||||
mod theme;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Parse markdown text into styled ratatui lines with syntax-highlighted code blocks.
|
||||
|
||||
use minimad::{Composite, CompositeStyle, Compound, Line, TableRow};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line as RLine, Span};
|
||||
@@ -5,17 +7,20 @@ use ratatui::text::{Line as RLine, Span};
|
||||
use crate::highlighter::CodeHighlighter;
|
||||
use crate::theme::MarkdownTheme;
|
||||
|
||||
/// Span of lines within a parsed document that form a fenced code block.
|
||||
pub struct CodeBlock {
|
||||
pub start_line: usize,
|
||||
pub end_line: usize,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// Result of parsing a markdown string: styled lines and extracted code blocks.
|
||||
pub struct ParsedMarkdown {
|
||||
pub lines: Vec<RLine<'static>>,
|
||||
pub code_blocks: Vec<CodeBlock>,
|
||||
}
|
||||
|
||||
/// Parse markdown text into themed, syntax-highlighted ratatui lines.
|
||||
pub fn parse<T: MarkdownTheme, H: CodeHighlighter>(
|
||||
md: &str,
|
||||
theme: &T,
|
||||
@@ -44,7 +49,7 @@ pub fn parse<T: MarkdownTheme, H: CodeHighlighter>(
|
||||
let close_block = |start: Option<usize>,
|
||||
source: &mut Vec<String>,
|
||||
blocks: &mut Vec<CodeBlock>,
|
||||
lines: &Vec<RLine<'static>>| {
|
||||
lines: &[RLine<'static>]| {
|
||||
if let Some(start) = start {
|
||||
blocks.push(CodeBlock {
|
||||
start_line: start,
|
||||
@@ -118,7 +123,7 @@ pub fn parse<T: MarkdownTheme, H: CodeHighlighter>(
|
||||
ParsedMarkdown { lines, code_blocks }
|
||||
}
|
||||
|
||||
pub fn preprocess_markdown(md: &str) -> String {
|
||||
fn preprocess_markdown(md: &str) -> String {
|
||||
let mut out = String::with_capacity(md.len());
|
||||
for line in md.lines() {
|
||||
let line = convert_dash_lists(line);
|
||||
@@ -162,7 +167,7 @@ pub fn preprocess_markdown(md: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn convert_dash_lists(line: &str) -> String {
|
||||
fn convert_dash_lists(line: &str) -> String {
|
||||
let trimmed = line.trim_start();
|
||||
if let Some(rest) = trimmed.strip_prefix("- ") {
|
||||
let indent = line.len() - trimmed.len();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//! Style provider trait for markdown rendering.
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
/// Style provider for each markdown element type.
|
||||
pub trait MarkdownTheme {
|
||||
fn h1(&self) -> Style;
|
||||
fn h2(&self) -> Style;
|
||||
@@ -16,6 +19,7 @@ pub trait MarkdownTheme {
|
||||
fn table_row_odd(&self) -> Color;
|
||||
}
|
||||
|
||||
/// Fallback theme with hardcoded terminal colors, used in tests.
|
||||
pub struct DefaultTheme;
|
||||
|
||||
impl MarkdownTheme for DefaultTheme {
|
||||
|
||||
@@ -10,3 +10,9 @@ description = "Project data structures for cagire sequencer"
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rmp-serde = "1"
|
||||
brotli = "7"
|
||||
base64 = "0.22"
|
||||
|
||||
[dev-dependencies]
|
||||
flate2 = "1"
|
||||
|
||||
22
crates/project/README.md
Normal file
22
crates/project/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# cagire-project
|
||||
|
||||
Project data model and persistence for Cagire.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `project` | `Project`, `Bank`, `Pattern`, `Step` structs and constants |
|
||||
| `file` | File I/O (save/load) |
|
||||
| `share` | Project sharing/export |
|
||||
|
||||
## Key Types
|
||||
|
||||
- **`Project`** — Top-level container: banks of patterns
|
||||
- **`Bank`** — Collection of patterns
|
||||
- **`Pattern`** — Sequence of steps with metadata
|
||||
- **`Step`** — Single step holding a Forth script
|
||||
|
||||
## Constants
|
||||
|
||||
`MAX_BANKS=32`, `MAX_PATTERNS=32`, `MAX_STEPS=1024`
|
||||
@@ -1,15 +1,17 @@
|
||||
//! JSON-based project file persistence with versioned format.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::project::{Bank, Project};
|
||||
use crate::project::{Bank, PatternSpeed, Project};
|
||||
|
||||
const VERSION: u8 = 1;
|
||||
pub const EXTENSION: &str = "cagire";
|
||||
const EXTENSION: &str = "cagire";
|
||||
|
||||
pub fn ensure_extension(path: &Path) -> PathBuf {
|
||||
fn ensure_extension(path: &Path) -> PathBuf {
|
||||
if path.extension().map(|e| e == EXTENSION).unwrap_or(false) {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
@@ -29,6 +31,24 @@ struct ProjectFile {
|
||||
playing_patterns: Vec<(usize, usize)>,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
prelude: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
script: String,
|
||||
#[serde(default, skip_serializing_if = "is_default_speed")]
|
||||
script_speed: PatternSpeed,
|
||||
#[serde(default = "default_script_length", skip_serializing_if = "is_default_script_length")]
|
||||
script_length: usize,
|
||||
}
|
||||
|
||||
fn is_default_speed(s: &PatternSpeed) -> bool {
|
||||
*s == PatternSpeed::default()
|
||||
}
|
||||
|
||||
fn default_script_length() -> usize {
|
||||
16
|
||||
}
|
||||
|
||||
fn is_default_script_length(n: &usize) -> bool {
|
||||
*n == default_script_length()
|
||||
}
|
||||
|
||||
fn default_tempo() -> f64 {
|
||||
@@ -44,6 +64,9 @@ impl From<&Project> for ProjectFile {
|
||||
tempo: project.tempo,
|
||||
playing_patterns: project.playing_patterns.clone(),
|
||||
prelude: project.prelude.clone(),
|
||||
script: project.script.clone(),
|
||||
script_speed: project.script_speed,
|
||||
script_length: project.script_length,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,12 +79,16 @@ impl From<ProjectFile> for Project {
|
||||
tempo: file.tempo,
|
||||
playing_patterns: file.playing_patterns,
|
||||
prelude: file.prelude,
|
||||
script: file.script,
|
||||
script_speed: file.script_speed,
|
||||
script_length: file.script_length,
|
||||
};
|
||||
project.normalize();
|
||||
project
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned by project save/load operations.
|
||||
#[derive(Debug)]
|
||||
pub enum FileError {
|
||||
Io(io::Error),
|
||||
@@ -91,6 +118,7 @@ impl From<serde_json::Error> for FileError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a project to disk as pretty-printed JSON, returning the final path.
|
||||
pub fn save(project: &Project, path: &Path) -> Result<PathBuf, FileError> {
|
||||
let path = ensure_extension(path);
|
||||
let file = ProjectFile::from(project);
|
||||
@@ -99,11 +127,13 @@ pub fn save(project: &Project, path: &Path) -> Result<PathBuf, FileError> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Read a project from a `.cagire` file on disk.
|
||||
pub fn load(path: &Path) -> Result<Project, FileError> {
|
||||
let json = fs::read_to_string(path)?;
|
||||
load_str(&json)
|
||||
}
|
||||
|
||||
/// Parse a project from a JSON string.
|
||||
pub fn load_str(json: &str) -> Result<Project, FileError> {
|
||||
let file: ProjectFile = serde_json::from_str(json)?;
|
||||
if file.version > VERSION {
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
mod file;
|
||||
mod project;
|
||||
pub mod share;
|
||||
|
||||
/// Maximum number of banks in a project.
|
||||
pub const MAX_BANKS: usize = 32;
|
||||
/// Maximum number of patterns per bank.
|
||||
pub const MAX_PATTERNS: usize = 32;
|
||||
/// Maximum number of steps per pattern.
|
||||
pub const MAX_STEPS: usize = 1024;
|
||||
/// Default pattern length in steps.
|
||||
pub const DEFAULT_LENGTH: usize = 16;
|
||||
|
||||
pub use file::{load, load_str, save, FileError};
|
||||
pub use project::{Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project, Step, SyncMode};
|
||||
pub use project::{Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project, Step};
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS};
|
||||
|
||||
/// Speed multiplier for a pattern, expressed as a rational fraction.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PatternSpeed {
|
||||
pub num: u8,
|
||||
@@ -37,10 +38,12 @@ impl PatternSpeed {
|
||||
Self::OCTO,
|
||||
];
|
||||
|
||||
/// Return the speed as a floating-point multiplier.
|
||||
pub fn multiplier(&self) -> f64 {
|
||||
self.num as f64 / self.denom as f64
|
||||
}
|
||||
|
||||
/// Format as a human-readable label (e.g. "2x", "1/4x").
|
||||
pub fn label(&self) -> String {
|
||||
if self.denom == 1 {
|
||||
format!("{}x", self.num)
|
||||
@@ -49,6 +52,7 @@ impl PatternSpeed {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the next faster preset, or self if already at maximum.
|
||||
pub fn next(&self) -> Self {
|
||||
let current = self.multiplier();
|
||||
Self::PRESETS
|
||||
@@ -58,6 +62,7 @@ impl PatternSpeed {
|
||||
.unwrap_or(*self)
|
||||
}
|
||||
|
||||
/// Return the next slower preset, or self if already at minimum.
|
||||
pub fn prev(&self) -> Self {
|
||||
let current = self.multiplier();
|
||||
Self::PRESETS
|
||||
@@ -68,6 +73,7 @@ impl PatternSpeed {
|
||||
.unwrap_or(*self)
|
||||
}
|
||||
|
||||
/// Parse a speed label like "2x" or "1/4x" into a `PatternSpeed`.
|
||||
pub fn from_label(s: &str) -> Option<Self> {
|
||||
let s = s.trim().trim_end_matches('x');
|
||||
if let Some((num, denom)) = s.split_once('/') {
|
||||
@@ -139,6 +145,7 @@ impl<'de> Deserialize<'de> for PatternSpeed {
|
||||
}
|
||||
}
|
||||
|
||||
/// Quantization grid for launching patterns.
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub enum LaunchQuantization {
|
||||
Immediate,
|
||||
@@ -151,6 +158,7 @@ pub enum LaunchQuantization {
|
||||
}
|
||||
|
||||
impl LaunchQuantization {
|
||||
/// Human-readable label for display.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Immediate => "Immediate",
|
||||
@@ -162,6 +170,18 @@ impl LaunchQuantization {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn short_label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Immediate => "Imm",
|
||||
Self::Beat => "Bt",
|
||||
Self::Bar => "1B",
|
||||
Self::Bars2 => "2B",
|
||||
Self::Bars4 => "4B",
|
||||
Self::Bars8 => "8B",
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle to the next longer quantization, clamped at `Bars8`.
|
||||
pub fn next(&self) -> Self {
|
||||
match self {
|
||||
Self::Immediate => Self::Beat,
|
||||
@@ -173,6 +193,7 @@ impl LaunchQuantization {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle to the next shorter quantization, clamped at `Immediate`.
|
||||
pub fn prev(&self) -> Self {
|
||||
match self {
|
||||
Self::Immediate => Self::Immediate,
|
||||
@@ -185,29 +206,7 @@ impl LaunchQuantization {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub enum SyncMode {
|
||||
#[default]
|
||||
Reset,
|
||||
PhaseLock,
|
||||
}
|
||||
|
||||
impl SyncMode {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Reset => "Reset",
|
||||
Self::PhaseLock => "Phase-Lock",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&self) -> Self {
|
||||
match self {
|
||||
Self::Reset => Self::PhaseLock,
|
||||
Self::PhaseLock => Self::Reset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What happens when a pattern finishes: loop, stop, or chain to another.
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub enum FollowUp {
|
||||
#[default]
|
||||
@@ -217,6 +216,7 @@ pub enum FollowUp {
|
||||
}
|
||||
|
||||
impl FollowUp {
|
||||
/// Human-readable label for display.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Loop => "Loop",
|
||||
@@ -225,6 +225,7 @@ impl FollowUp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle forward through follow-up modes.
|
||||
pub fn next_mode(&self) -> Self {
|
||||
match self {
|
||||
Self::Loop => Self::Stop,
|
||||
@@ -233,6 +234,7 @@ impl FollowUp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle backward through follow-up modes.
|
||||
pub fn prev_mode(&self) -> Self {
|
||||
match self {
|
||||
Self::Loop => Self::Chain { bank: 0, pattern: 0 },
|
||||
@@ -246,6 +248,7 @@ fn is_default_follow_up(f: &FollowUp) -> bool {
|
||||
*f == FollowUp::default()
|
||||
}
|
||||
|
||||
/// Single step in a pattern, holding a Forth script and optional metadata.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Step {
|
||||
pub active: bool,
|
||||
@@ -257,10 +260,12 @@ pub struct Step {
|
||||
}
|
||||
|
||||
impl Step {
|
||||
/// True if all fields are at their default values.
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.active && self.script.is_empty() && self.source.is_none() && self.name.is_none()
|
||||
}
|
||||
|
||||
/// True if the script is non-empty.
|
||||
pub fn has_content(&self) -> bool {
|
||||
!self.script.is_empty()
|
||||
}
|
||||
@@ -277,14 +282,15 @@ impl Default for Step {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequence of steps with playback settings (speed, quantization, follow-up).
|
||||
#[derive(Clone)]
|
||||
pub struct Pattern {
|
||||
pub steps: Vec<Step>,
|
||||
pub length: usize,
|
||||
pub speed: PatternSpeed,
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub quantization: LaunchQuantization,
|
||||
pub sync_mode: SyncMode,
|
||||
pub follow_up: FollowUp,
|
||||
}
|
||||
|
||||
@@ -317,10 +323,10 @@ struct SparsePattern {
|
||||
speed: PatternSpeed,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "is_default_quantization")]
|
||||
quantization: LaunchQuantization,
|
||||
#[serde(default, skip_serializing_if = "is_default_sync_mode")]
|
||||
sync_mode: SyncMode,
|
||||
#[serde(default, skip_serializing_if = "is_default_follow_up")]
|
||||
follow_up: FollowUp,
|
||||
}
|
||||
@@ -329,10 +335,6 @@ fn is_default_quantization(q: &LaunchQuantization) -> bool {
|
||||
*q == LaunchQuantization::default()
|
||||
}
|
||||
|
||||
fn is_default_sync_mode(s: &SyncMode) -> bool {
|
||||
*s == SyncMode::default()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LegacyPattern {
|
||||
steps: Vec<Step>,
|
||||
@@ -342,9 +344,9 @@ struct LegacyPattern {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
quantization: LaunchQuantization,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
sync_mode: SyncMode,
|
||||
quantization: LaunchQuantization,
|
||||
#[serde(default)]
|
||||
follow_up: FollowUp,
|
||||
}
|
||||
@@ -370,8 +372,8 @@ impl Serialize for Pattern {
|
||||
length: self.length,
|
||||
speed: self.speed,
|
||||
name: self.name.clone(),
|
||||
description: self.description.clone(),
|
||||
quantization: self.quantization,
|
||||
sync_mode: self.sync_mode,
|
||||
follow_up: self.follow_up,
|
||||
};
|
||||
sparse.serialize(serializer)
|
||||
@@ -405,8 +407,8 @@ impl<'de> Deserialize<'de> for Pattern {
|
||||
length: sparse.length,
|
||||
speed: sparse.speed,
|
||||
name: sparse.name,
|
||||
description: sparse.description,
|
||||
quantization: sparse.quantization,
|
||||
sync_mode: sparse.sync_mode,
|
||||
follow_up: sparse.follow_up,
|
||||
})
|
||||
}
|
||||
@@ -415,8 +417,8 @@ impl<'de> Deserialize<'de> for Pattern {
|
||||
length: legacy.length,
|
||||
speed: legacy.speed,
|
||||
name: legacy.name,
|
||||
description: legacy.description,
|
||||
quantization: legacy.quantization,
|
||||
sync_mode: legacy.sync_mode,
|
||||
follow_up: legacy.follow_up,
|
||||
}),
|
||||
}
|
||||
@@ -430,22 +432,25 @@ impl Default for Pattern {
|
||||
length: DEFAULT_LENGTH,
|
||||
speed: PatternSpeed::default(),
|
||||
name: None,
|
||||
description: None,
|
||||
quantization: LaunchQuantization::default(),
|
||||
sync_mode: SyncMode::default(),
|
||||
follow_up: FollowUp::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
/// Borrow a step by index.
|
||||
pub fn step(&self, index: usize) -> Option<&Step> {
|
||||
self.steps.get(index)
|
||||
}
|
||||
|
||||
/// Mutably borrow a step by index.
|
||||
pub fn step_mut(&mut self, index: usize) -> Option<&mut Step> {
|
||||
self.steps.get_mut(index)
|
||||
}
|
||||
|
||||
/// Set the active length, clamped to `[1, MAX_STEPS]`.
|
||||
pub fn set_length(&mut self, length: usize) {
|
||||
let length = length.clamp(1, MAX_STEPS);
|
||||
while self.steps.len() < length {
|
||||
@@ -454,6 +459,7 @@ impl Pattern {
|
||||
self.length = length;
|
||||
}
|
||||
|
||||
/// Follow the source chain from `index` to find the originating step.
|
||||
pub fn resolve_source(&self, index: usize) -> usize {
|
||||
let mut current = index;
|
||||
for _ in 0..self.steps.len() {
|
||||
@@ -470,28 +476,33 @@ impl Pattern {
|
||||
index
|
||||
}
|
||||
|
||||
/// Return the script at the resolved source of `index`.
|
||||
pub fn resolve_script(&self, index: usize) -> Option<&str> {
|
||||
let source_idx = self.resolve_source(index);
|
||||
self.steps.get(source_idx).map(|s| s.script.as_str())
|
||||
}
|
||||
|
||||
/// Count active-length steps that have a script or a source reference.
|
||||
pub fn content_step_count(&self) -> usize {
|
||||
self.steps[..self.length]
|
||||
.iter()
|
||||
.filter(|s| s.has_content() || s.source.is_some())
|
||||
.count()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Collection of patterns forming a bank.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Bank {
|
||||
pub patterns: Vec<Pattern>,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub prelude: String,
|
||||
}
|
||||
|
||||
impl Bank {
|
||||
/// Count patterns that contain at least one non-empty step.
|
||||
pub fn content_pattern_count(&self) -> usize {
|
||||
self.patterns
|
||||
.iter()
|
||||
@@ -505,10 +516,12 @@ impl Default for Bank {
|
||||
Self {
|
||||
patterns: (0..MAX_PATTERNS).map(|_| Pattern::default()).collect(),
|
||||
name: None,
|
||||
prelude: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level project: banks, tempo, sample paths, and prelude script.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub banks: Vec<Bank>,
|
||||
@@ -520,12 +533,22 @@ pub struct Project {
|
||||
pub playing_patterns: Vec<(usize, usize)>,
|
||||
#[serde(default)]
|
||||
pub prelude: String,
|
||||
#[serde(default)]
|
||||
pub script: String,
|
||||
#[serde(default)]
|
||||
pub script_speed: PatternSpeed,
|
||||
#[serde(default = "default_script_length")]
|
||||
pub script_length: usize,
|
||||
}
|
||||
|
||||
fn default_tempo() -> f64 {
|
||||
120.0
|
||||
}
|
||||
|
||||
fn default_script_length() -> usize {
|
||||
16
|
||||
}
|
||||
|
||||
impl Default for Project {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -534,19 +557,25 @@ impl Default for Project {
|
||||
tempo: default_tempo(),
|
||||
playing_patterns: Vec::new(),
|
||||
prelude: String::new(),
|
||||
script: String::new(),
|
||||
script_speed: PatternSpeed::default(),
|
||||
script_length: default_script_length(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Borrow a pattern by bank and pattern index.
|
||||
pub fn pattern_at(&self, bank: usize, pattern: usize) -> &Pattern {
|
||||
&self.banks[bank].patterns[pattern]
|
||||
}
|
||||
|
||||
/// Mutably borrow a pattern by bank and pattern index.
|
||||
pub fn pattern_at_mut(&mut self, bank: usize, pattern: usize) -> &mut Pattern {
|
||||
&mut self.banks[bank].patterns[pattern]
|
||||
}
|
||||
|
||||
/// Pad banks, patterns, and steps to their maximum sizes after deserialization.
|
||||
pub fn normalize(&mut self) {
|
||||
self.banks.resize_with(MAX_BANKS, Bank::default);
|
||||
for bank in &mut self.banks {
|
||||
|
||||
237
crates/project/src/share.rs
Normal file
237
crates/project/src/share.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
//! Pattern and project sharing via compact text strings.
|
||||
//!
|
||||
//! Export: data → MessagePack → Brotli → base64 URL-safe → prefix
|
||||
//! Import: strip prefix → base64 decode → Brotli decompress → MessagePack → data
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
|
||||
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 {
|
||||
InvalidPrefix,
|
||||
Base64(base64::DecodeError),
|
||||
Decompress(std::io::Error),
|
||||
Deserialize(rmp_serde::decode::Error),
|
||||
Serialize(rmp_serde::encode::Error),
|
||||
Compress(std::io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ShareError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidPrefix => write!(f, "missing cgr:/cgrb: prefix"),
|
||||
Self::Base64(e) => write!(f, "base64: {e}"),
|
||||
Self::Decompress(e) => write!(f, "decompress: {e}"),
|
||||
Self::Deserialize(e) => write!(f, "deserialize: {e}"),
|
||||
Self::Serialize(e) => write!(f, "serialize: {e}"),
|
||||
Self::Compress(e) => write!(f, "compress: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compress(data: &[u8]) -> Result<Vec<u8>, ShareError> {
|
||||
let mut output = Vec::new();
|
||||
let params = brotli::enc::BrotliEncoderParams {
|
||||
quality: 11,
|
||||
lgwin: 22,
|
||||
lgblock: 0,
|
||||
..Default::default()
|
||||
};
|
||||
brotli::BrotliCompress(&mut &data[..], &mut output, ¶ms).map_err(ShareError::Compress)?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn decompress(data: &[u8]) -> Result<Vec<u8>, ShareError> {
|
||||
let mut output = Vec::new();
|
||||
brotli::BrotliDecompress(&mut &data[..], &mut output).map_err(ShareError::Decompress)?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn encode<T: serde::Serialize>(value: &T, prefix: &str) -> Result<String, ShareError> {
|
||||
let packed = rmp_serde::to_vec_named(value).map_err(ShareError::Serialize)?;
|
||||
let compressed = compress(&packed)?;
|
||||
let encoded = URL_SAFE_NO_PAD.encode(&compressed);
|
||||
Ok(format!("{prefix}{encoded}"))
|
||||
}
|
||||
|
||||
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)?;
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// Encode a pattern as a shareable `cgr:` string.
|
||||
pub fn export(pattern: &Pattern) -> Result<String, ShareError> {
|
||||
encode(pattern, PATTERN_PREFIX)
|
||||
}
|
||||
|
||||
/// Decode a `cgr:` string back into a pattern.
|
||||
pub fn import(text: &str) -> Result<Pattern, ShareError> {
|
||||
decode(text, PATTERN_PREFIX)
|
||||
}
|
||||
|
||||
/// Encode a bank as a shareable `cgrb:` string.
|
||||
pub fn export_bank(bank: &Bank) -> Result<String, ShareError> {
|
||||
encode(bank, BANK_PREFIX)
|
||||
}
|
||||
|
||||
/// Decode a `cgrb:` string back into a bank.
|
||||
pub fn import_bank(text: &str) -> Result<Bank, ShareError> {
|
||||
decode(text, BANK_PREFIX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Step;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_empty() {
|
||||
let pattern = Pattern::default();
|
||||
let encoded = export(&pattern).expect("export pattern");
|
||||
assert!(encoded.starts_with("cgr:"));
|
||||
let decoded = import(&encoded).expect("import pattern");
|
||||
assert_eq!(decoded.length, pattern.length);
|
||||
assert_eq!(decoded.steps.len(), pattern.steps.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_with_steps() {
|
||||
let mut pattern = Pattern::default();
|
||||
pattern.steps[0] = Step {
|
||||
active: true,
|
||||
script: "kick 60 note".to_string(),
|
||||
source: None,
|
||||
name: Some("kick".to_string()),
|
||||
};
|
||||
pattern.steps[1] = Step {
|
||||
active: false,
|
||||
script: "snare".to_string(),
|
||||
source: None,
|
||||
name: None,
|
||||
};
|
||||
pattern.steps[3] = Step {
|
||||
active: true,
|
||||
script: String::new(),
|
||||
source: Some(0),
|
||||
name: None,
|
||||
};
|
||||
pattern.length = 8;
|
||||
pattern.name = Some("Test".to_string());
|
||||
|
||||
let encoded = export(&pattern).expect("export pattern");
|
||||
let decoded = import(&encoded).expect("import pattern");
|
||||
|
||||
assert_eq!(decoded.length, 8);
|
||||
assert_eq!(decoded.name.as_deref(), Some("Test"));
|
||||
assert_eq!(decoded.steps[0].script, "kick 60 note");
|
||||
assert_eq!(decoded.steps[0].name.as_deref(), Some("kick"));
|
||||
assert!(!decoded.steps[1].active);
|
||||
assert_eq!(decoded.steps[1].script, "snare");
|
||||
assert_eq!(decoded.steps[3].source, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_prefix() {
|
||||
assert!(matches!(import("xxx:abc"), Err(ShareError::InvalidPrefix)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_base64() {
|
||||
assert!(import("cgr:not-valid-data").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_trimming() {
|
||||
let pattern = Pattern::default();
|
||||
let encoded = export(&pattern).expect("export pattern");
|
||||
let padded = format!(" {encoded} \n");
|
||||
let decoded = import(&padded).expect("import padded pattern");
|
||||
assert_eq!(decoded.length, pattern.length);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msgpack_brotli_smaller_than_json_deflate() {
|
||||
let mut pattern = Pattern::default();
|
||||
for i in 0..16 {
|
||||
pattern.steps[i] = Step {
|
||||
active: true,
|
||||
script: format!("kick {i} note 0.5 gate"),
|
||||
source: None,
|
||||
name: Some(format!("step_{i}")),
|
||||
};
|
||||
}
|
||||
pattern.length = 16;
|
||||
|
||||
// Current (msgpack+brotli)
|
||||
let new_encoded = export(&pattern).expect("export pattern");
|
||||
|
||||
// Old pipeline (json+deflate) for comparison
|
||||
use std::io::Write;
|
||||
let json = serde_json::to_vec(&pattern).expect("serialize json");
|
||||
let mut encoder =
|
||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best());
|
||||
encoder.write_all(&json).expect("write to encoder");
|
||||
let old_compressed = encoder.finish().expect("finish encoder");
|
||||
let old_encoded = format!("cgr:{}", URL_SAFE_NO_PAD.encode(&old_compressed));
|
||||
|
||||
assert!(
|
||||
new_encoded.len() < old_encoded.len(),
|
||||
"msgpack+brotli ({}) should be smaller than json+deflate ({})",
|
||||
new_encoded.len(),
|
||||
old_encoded.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_bank() {
|
||||
let mut bank = Bank::default();
|
||||
bank.patterns[0].steps[0] = Step {
|
||||
active: true,
|
||||
script: "kick 60 note".to_string(),
|
||||
source: None,
|
||||
name: Some("kick".to_string()),
|
||||
};
|
||||
bank.patterns[0].length = 8;
|
||||
bank.name = Some("Drums".to_string());
|
||||
|
||||
let encoded = export_bank(&bank).expect("export bank");
|
||||
assert!(encoded.starts_with("cgrb:"));
|
||||
let decoded = import_bank(&encoded).expect("import bank");
|
||||
|
||||
assert_eq!(decoded.name.as_deref(), Some("Drums"));
|
||||
assert_eq!(decoded.patterns[0].length, 8);
|
||||
assert_eq!(decoded.patterns[0].steps[0].script, "kick 60 note");
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,4 @@ description = "TUI components for cagire sequencer"
|
||||
rand = "0.8"
|
||||
ratatui = "0.30"
|
||||
regex = "1"
|
||||
tui-textarea = { git = "https://github.com/phsym/tui-textarea", branch = "main", features = ["search"] }
|
||||
tui-textarea = { git = "https://github.com/phsym/tui-textarea", rev = "e2ec4d3", features = ["search"] }
|
||||
|
||||
25
crates/ratatui/README.md
Normal file
25
crates/ratatui/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# cagire-ratatui
|
||||
|
||||
TUI widget library and theme system for Cagire.
|
||||
|
||||
## Widgets
|
||||
|
||||
`category_list`, `confirm`, `editor`, `file_browser`, `hint_bar`, `lissajous`, `list_select`, `modal`, `nav_minimap`, `props_form`, `sample_browser`, `scope`, `scroll_indicators`, `search_bar`, `section_header`, `sparkles`, `spectrum`, `text_input`, `vu_meter`, `waveform`
|
||||
|
||||
## Theme System
|
||||
|
||||
The `theme/` module provides a palette-based theming system using Oklab color space.
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `mod` | `THEMES` array, `CURRENT_THEME` thread-local, `get()`/`set()` |
|
||||
| `palette` | `Palette` (14 fields), color manipulation helpers (`shift`, `mix`, `tint_bg`, ...) |
|
||||
| `build` | Derives ~190 `ThemeColors` fields from a `Palette` |
|
||||
| `transform` | HSV-based hue rotation for generated palettes |
|
||||
|
||||
25 built-in themes.
|
||||
|
||||
## Key Types
|
||||
|
||||
- **`Palette`** — 14-field color definition, input to theme generation
|
||||
- **`ThemeColors`** — ~190 derived semantic colors used throughout the UI
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Collapsible categorized list widget with section headers.
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem};
|
||||
@@ -5,17 +7,20 @@ use ratatui::Frame;
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// Entry in a category list: either a section header or a leaf item.
|
||||
pub struct CategoryItem<'a> {
|
||||
pub label: &'a str,
|
||||
pub is_section: bool,
|
||||
pub collapsed: bool,
|
||||
}
|
||||
|
||||
/// What is currently selected: a leaf item or a section header.
|
||||
pub enum Selection {
|
||||
Item(usize),
|
||||
Section(usize),
|
||||
}
|
||||
|
||||
/// Scrollable list with collapsible section headers.
|
||||
pub struct CategoryList<'a> {
|
||||
items: &'a [CategoryItem<'a>],
|
||||
selection: Selection,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Yes/No confirmation dialog widget.
|
||||
|
||||
use crate::theme;
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
@@ -7,6 +9,7 @@ use ratatui::Frame;
|
||||
|
||||
use super::ModalFrame;
|
||||
|
||||
/// Modal dialog with Yes/No buttons.
|
||||
pub struct ConfirmModal<'a> {
|
||||
title: &'a str,
|
||||
message: &'a str,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
//! Script editor widget with completion, search, and sample finder popups.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::theme;
|
||||
use ratatui::{
|
||||
@@ -10,8 +13,10 @@ use ratatui::{
|
||||
};
|
||||
use tui_textarea::TextArea;
|
||||
|
||||
/// Callback that syntax-highlights a single line, returning styled spans (bool = annotation).
|
||||
pub type Highlighter<'a> = &'a dyn Fn(usize, &str) -> Vec<(Style, String, bool)>;
|
||||
|
||||
/// Metadata for a single autocomplete entry.
|
||||
#[derive(Clone)]
|
||||
pub struct CompletionCandidate {
|
||||
pub name: String,
|
||||
@@ -21,7 +26,7 @@ pub struct CompletionCandidate {
|
||||
}
|
||||
|
||||
struct CompletionState {
|
||||
candidates: Vec<CompletionCandidate>,
|
||||
candidates: Arc<[CompletionCandidate]>,
|
||||
matches: Vec<usize>,
|
||||
cursor: usize,
|
||||
prefix: String,
|
||||
@@ -33,7 +38,7 @@ struct CompletionState {
|
||||
impl CompletionState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
candidates: Vec::new(),
|
||||
candidates: Arc::from([]),
|
||||
matches: Vec::new(),
|
||||
cursor: 0,
|
||||
prefix: String::new(),
|
||||
@@ -78,6 +83,7 @@ impl SearchState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-line text editor backed by tui_textarea.
|
||||
pub struct Editor {
|
||||
text: TextArea<'static>,
|
||||
completion: CompletionState,
|
||||
@@ -99,6 +105,14 @@ impl Editor {
|
||||
self.text.is_selecting()
|
||||
}
|
||||
|
||||
pub fn move_cursor_to(&mut self, row: u16, col: u16) {
|
||||
self.text.move_cursor(tui_textarea::CursorMove::Jump(row, col));
|
||||
}
|
||||
|
||||
pub fn scroll_offset(&self) -> u16 {
|
||||
self.scroll_offset.get()
|
||||
}
|
||||
|
||||
pub fn copy(&mut self) {
|
||||
self.text.copy();
|
||||
}
|
||||
@@ -111,6 +125,14 @@ impl Editor {
|
||||
self.text.paste()
|
||||
}
|
||||
|
||||
pub fn yank_text(&self) -> String {
|
||||
self.text.yank_text()
|
||||
}
|
||||
|
||||
pub fn set_yank_text(&mut self, text: impl Into<String>) {
|
||||
self.text.set_yank_text(text);
|
||||
}
|
||||
|
||||
pub fn select_all(&mut self) {
|
||||
self.text.select_all();
|
||||
}
|
||||
@@ -138,7 +160,11 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub fn set_content(&mut self, lines: Vec<String>) {
|
||||
let yank = self.text.yank_text();
|
||||
self.text = TextArea::new(lines);
|
||||
if !yank.is_empty() {
|
||||
self.text.set_yank_text(yank);
|
||||
}
|
||||
self.completion.active = false;
|
||||
self.sample_finder.active = false;
|
||||
self.search.query.clear();
|
||||
@@ -146,7 +172,7 @@ impl Editor {
|
||||
self.scroll_offset.set(0);
|
||||
}
|
||||
|
||||
pub fn set_candidates(&mut self, candidates: Vec<CompletionCandidate>) {
|
||||
pub fn set_candidates(&mut self, candidates: Arc<[CompletionCandidate]>) {
|
||||
self.completion.candidates = candidates;
|
||||
}
|
||||
|
||||
@@ -462,7 +488,7 @@ impl Editor {
|
||||
if is_cursor {
|
||||
cursor_style
|
||||
} else if is_selected {
|
||||
base_style.bg(selection_style.bg.unwrap())
|
||||
base_style.bg(selection_style.bg.expect("selection style has bg"))
|
||||
} else {
|
||||
base_style
|
||||
}
|
||||
@@ -682,6 +708,7 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Score a fuzzy match of `query` against `target`. Lower is better; `None` if no match.
|
||||
pub fn fuzzy_match(query: &str, target: &str) -> Option<usize> {
|
||||
let target_lower: Vec<char> = target.to_lowercase().chars().collect();
|
||||
let query_lower: Vec<char> = query.to_lowercase().chars().collect();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! File/directory browser modal widget.
|
||||
|
||||
use crate::theme;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
@@ -7,15 +9,19 @@ use ratatui::Frame;
|
||||
|
||||
use super::ModalFrame;
|
||||
|
||||
/// Modal listing files and directories with a filter input line.
|
||||
pub struct FileBrowserModal<'a> {
|
||||
title: &'a str,
|
||||
input: &'a str,
|
||||
entries: &'a [(String, bool, bool)],
|
||||
audio_counts: &'a [Option<usize>],
|
||||
selected: usize,
|
||||
scroll_offset: usize,
|
||||
border_color: Option<Color>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
hints: Option<Line<'a>>,
|
||||
color_path: bool,
|
||||
}
|
||||
|
||||
impl<'a> FileBrowserModal<'a> {
|
||||
@@ -24,11 +30,14 @@ impl<'a> FileBrowserModal<'a> {
|
||||
title,
|
||||
input,
|
||||
entries,
|
||||
audio_counts: &[],
|
||||
selected: 0,
|
||||
scroll_offset: 0,
|
||||
border_color: None,
|
||||
width: 60,
|
||||
height: 16,
|
||||
hints: None,
|
||||
color_path: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +66,21 @@ impl<'a> FileBrowserModal<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn hints(mut self, hints: Line<'a>) -> Self {
|
||||
self.hints = Some(hints);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn audio_counts(mut self, counts: &'a [Option<usize>]) -> Self {
|
||||
self.audio_counts = counts;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn color_path(mut self) -> Self {
|
||||
self.color_path = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
|
||||
let colors = theme::get();
|
||||
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
|
||||
@@ -67,37 +91,61 @@ impl<'a> FileBrowserModal<'a> {
|
||||
.border_color(border_color)
|
||||
.render_centered(frame, term);
|
||||
|
||||
let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
|
||||
let has_hints = self.hints.is_some();
|
||||
let constraints = if has_hints {
|
||||
vec![
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
]
|
||||
} else {
|
||||
vec![Constraint::Length(1), Constraint::Min(1)]
|
||||
};
|
||||
let rows = Layout::vertical(constraints).split(inner);
|
||||
|
||||
// Input line
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
let input_spans = if self.color_path {
|
||||
let (path_part, filter_part) = match self.input.rfind('/') {
|
||||
Some(pos) => (&self.input[..=pos], &self.input[pos + 1..]),
|
||||
None => ("", self.input),
|
||||
};
|
||||
vec![
|
||||
Span::raw("> "),
|
||||
Span::styled(path_part.to_string(), Style::new().fg(colors.browser.directory)),
|
||||
Span::styled(filter_part.to_string(), Style::new().fg(colors.input.text)),
|
||||
Span::styled("█", Style::new().fg(colors.input.cursor)),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Span::raw("> "),
|
||||
Span::styled(self.input, Style::new().fg(colors.input.text)),
|
||||
Span::styled("█", Style::new().fg(colors.input.cursor)),
|
||||
])),
|
||||
rows[0],
|
||||
);
|
||||
]
|
||||
};
|
||||
frame.render_widget(Paragraph::new(Line::from(input_spans)), rows[0]);
|
||||
|
||||
// Hints bar
|
||||
if let Some(hints) = self.hints {
|
||||
let hint_row = rows[2];
|
||||
frame.render_widget(
|
||||
Paragraph::new(hints).alignment(ratatui::layout::Alignment::Right),
|
||||
hint_row,
|
||||
);
|
||||
}
|
||||
|
||||
// Entries list
|
||||
let visible_height = rows[1].height as usize;
|
||||
let visible_entries = self
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(self.scroll_offset)
|
||||
.take(visible_height);
|
||||
|
||||
let lines: Vec<Line> = visible_entries
|
||||
.enumerate()
|
||||
.map(|(i, (name, is_dir, is_cagire))| {
|
||||
let abs_idx = i + self.scroll_offset;
|
||||
.map(|(abs_idx, (name, is_dir, is_cagire))| {
|
||||
let is_selected = abs_idx == self.selected;
|
||||
let prefix = if is_selected { "> " } else { " " };
|
||||
let display = if *is_dir {
|
||||
format!("{prefix}{name}/")
|
||||
} else {
|
||||
format!("{prefix}{name}")
|
||||
};
|
||||
let color = if is_selected {
|
||||
colors.browser.selected
|
||||
} else if *is_dir {
|
||||
@@ -107,7 +155,21 @@ impl<'a> FileBrowserModal<'a> {
|
||||
} else {
|
||||
colors.browser.file
|
||||
};
|
||||
Line::from(Span::styled(display, Style::new().fg(color)))
|
||||
let display = if *is_dir {
|
||||
format!("{prefix}{name}/")
|
||||
} else {
|
||||
format!("{prefix}{name}")
|
||||
};
|
||||
let mut spans = vec![Span::styled(display, Style::new().fg(color))];
|
||||
if *is_dir && name != ".." {
|
||||
if let Some(Some(count)) = self.audio_counts.get(abs_idx) {
|
||||
spans.push(Span::styled(
|
||||
format!(" ({count})"),
|
||||
Style::new().fg(colors.browser.file),
|
||||
));
|
||||
}
|
||||
}
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Bottom-bar keyboard hint renderer.
|
||||
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::style::Style;
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// Build a styled line of key/action pairs for the hint bar.
|
||||
pub fn hint_line(pairs: &[(&str, &str)]) -> Line<'static> {
|
||||
let theme = theme::get();
|
||||
let key_style = Style::default().fg(theme.hint.key);
|
||||
|
||||
@@ -38,7 +38,7 @@ pub use scroll_indicators::{render_scroll_indicators, IndicatorAlign};
|
||||
pub use search_bar::render_search_bar;
|
||||
pub use section_header::render_section_header;
|
||||
pub use sparkles::Sparkles;
|
||||
pub use spectrum::Spectrum;
|
||||
pub use spectrum::{Spectrum, SpectrumStyle};
|
||||
pub use text_input::TextInputModal;
|
||||
pub use vu_meter::VuMeter;
|
||||
pub use waveform::Waveform;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Lissajous XY oscilloscope widget using braille characters.
|
||||
|
||||
use crate::theme;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -7,12 +9,22 @@ use std::cell::RefCell;
|
||||
|
||||
thread_local! {
|
||||
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
||||
static TRAIL: RefCell<TrailState> = const { RefCell::new(TrailState { fine_w: 0, fine_h: 0, heat: Vec::new() }) };
|
||||
}
|
||||
|
||||
struct TrailState {
|
||||
fine_w: usize,
|
||||
fine_h: usize,
|
||||
heat: Vec<f32>,
|
||||
}
|
||||
|
||||
/// XY oscilloscope plotting left vs right channels as a Lissajous curve.
|
||||
pub struct Lissajous<'a> {
|
||||
left: &'a [f32],
|
||||
right: &'a [f32],
|
||||
color: Option<Color>,
|
||||
gain: f32,
|
||||
trails: bool,
|
||||
}
|
||||
|
||||
impl<'a> Lissajous<'a> {
|
||||
@@ -21,13 +33,25 @@ impl<'a> Lissajous<'a> {
|
||||
left,
|
||||
right,
|
||||
color: None,
|
||||
gain: 1.0,
|
||||
trails: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trails(mut self, enabled: bool) -> Self {
|
||||
self.trails = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn color(mut self, c: Color) -> Self {
|
||||
self.color = Some(c);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn gain(mut self, g: f32) -> Self {
|
||||
self.gain = g;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Lissajous<'_> {
|
||||
@@ -36,6 +60,16 @@ impl Widget for Lissajous<'_> {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.trails {
|
||||
self.render_trails(area, buf);
|
||||
} else {
|
||||
self.render_normal(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Lissajous<'_> {
|
||||
fn render_normal(self, area: Rect, buf: &mut Buffer) {
|
||||
let color = self.color.unwrap_or_else(|| theme::get().meter.low);
|
||||
let width = area.width as usize;
|
||||
let height = area.height as usize;
|
||||
@@ -43,14 +77,6 @@ impl Widget for Lissajous<'_> {
|
||||
let fine_height = height * 4;
|
||||
let len = self.left.len().min(self.right.len());
|
||||
|
||||
let peak = self
|
||||
.left
|
||||
.iter()
|
||||
.chain(self.right.iter())
|
||||
.map(|s| s.abs())
|
||||
.fold(0.0f32, f32::max);
|
||||
let gain = if peak > 0.001 { 1.0 / peak } else { 1.0 };
|
||||
|
||||
PATTERNS.with(|p| {
|
||||
let mut patterns = p.borrow_mut();
|
||||
let size = width * height;
|
||||
@@ -58,10 +84,9 @@ impl Widget for Lissajous<'_> {
|
||||
patterns.resize(size, 0);
|
||||
|
||||
for i in 0..len {
|
||||
let l = (self.left[i] * gain).clamp(-1.0, 1.0);
|
||||
let r = (self.right[i] * gain).clamp(-1.0, 1.0);
|
||||
let l = (self.left[i] * self.gain).clamp(-1.0, 1.0);
|
||||
let r = (self.right[i] * self.gain).clamp(-1.0, 1.0);
|
||||
|
||||
// X = right channel, Y = left channel (inverted so up = positive)
|
||||
let fine_x = ((r + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
|
||||
let fine_y = ((1.0 - l) * 0.5 * (fine_height - 1) as f32).round() as usize;
|
||||
let fine_x = fine_x.min(fine_width - 1);
|
||||
@@ -72,19 +97,7 @@ impl Widget for Lissajous<'_> {
|
||||
let dot_x = fine_x % 2;
|
||||
let dot_y = fine_y % 4;
|
||||
|
||||
let bit = match (dot_x, dot_y) {
|
||||
(0, 0) => 0x01,
|
||||
(0, 1) => 0x02,
|
||||
(0, 2) => 0x04,
|
||||
(0, 3) => 0x40,
|
||||
(1, 0) => 0x08,
|
||||
(1, 1) => 0x10,
|
||||
(1, 2) => 0x20,
|
||||
(1, 3) => 0x80,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
patterns[char_y * width + char_x] |= bit;
|
||||
patterns[char_y * width + char_x] |= braille_bit(dot_x, dot_y);
|
||||
}
|
||||
|
||||
for cy in 0..height {
|
||||
@@ -100,4 +113,122 @@ impl Widget for Lissajous<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_trails(self, area: Rect, buf: &mut Buffer) {
|
||||
let theme = theme::get();
|
||||
let width = area.width as usize;
|
||||
let height = area.height as usize;
|
||||
let fine_w = width * 2;
|
||||
let fine_h = height * 4;
|
||||
let len = self.left.len().min(self.right.len());
|
||||
|
||||
TRAIL.with(|t| {
|
||||
let mut trail = t.borrow_mut();
|
||||
|
||||
// Reset if dimensions changed
|
||||
if trail.fine_w != fine_w || trail.fine_h != fine_h {
|
||||
trail.fine_w = fine_w;
|
||||
trail.fine_h = fine_h;
|
||||
trail.heat.clear();
|
||||
trail.heat.resize(fine_w * fine_h, 0.0);
|
||||
}
|
||||
|
||||
// Decay existing heat
|
||||
for h in trail.heat.iter_mut() {
|
||||
*h *= 0.85;
|
||||
}
|
||||
|
||||
// Plot new sample points
|
||||
for i in 0..len {
|
||||
let l = (self.left[i] * self.gain).clamp(-1.0, 1.0);
|
||||
let r = (self.right[i] * self.gain).clamp(-1.0, 1.0);
|
||||
|
||||
let fx = ((r + 1.0) * 0.5 * (fine_w - 1) as f32).round() as usize;
|
||||
let fy = ((1.0 - l) * 0.5 * (fine_h - 1) as f32).round() as usize;
|
||||
let fx = fx.min(fine_w - 1);
|
||||
let fy = fy.min(fine_h - 1);
|
||||
|
||||
trail.heat[fy * fine_w + fx] = 1.0;
|
||||
}
|
||||
|
||||
// Convert heat map to braille
|
||||
PATTERNS.with(|p| {
|
||||
let mut patterns = p.borrow_mut();
|
||||
patterns.clear();
|
||||
patterns.resize(width * height, 0);
|
||||
|
||||
// Track brightest color per cell
|
||||
let mut colors: Vec<Option<Color>> = vec![None; width * height];
|
||||
|
||||
for fy in 0..fine_h {
|
||||
for fx in 0..fine_w {
|
||||
let h = trail.heat[fy * fine_w + fx];
|
||||
if h < 0.05 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cx = fx / 2;
|
||||
let cy = fy / 4;
|
||||
let dx = fx % 2;
|
||||
let dy = fy % 4;
|
||||
|
||||
let idx = cy * width + cx;
|
||||
patterns[idx] |= braille_bit(dx, dy);
|
||||
|
||||
let dot_color = if h > 0.7 {
|
||||
theme.meter.high
|
||||
} else if h > 0.25 {
|
||||
theme.meter.mid
|
||||
} else {
|
||||
theme.meter.low
|
||||
};
|
||||
|
||||
let replace = match colors[idx] {
|
||||
None => true,
|
||||
Some(cur) => {
|
||||
rank_color(dot_color, &theme) > rank_color(cur, &theme)
|
||||
}
|
||||
};
|
||||
if replace {
|
||||
colors[idx] = Some(dot_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for cy in 0..height {
|
||||
for cx in 0..width {
|
||||
let idx = cy * width + cx;
|
||||
let pattern = patterns[idx];
|
||||
if pattern != 0 {
|
||||
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
|
||||
let color = colors[idx].unwrap_or(theme.meter.low);
|
||||
buf[(area.x + cx as u16, area.y + cy as u16)]
|
||||
.set_char(ch)
|
||||
.set_fg(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn braille_bit(dot_x: usize, dot_y: usize) -> u8 {
|
||||
match (dot_x, dot_y) {
|
||||
(0, 0) => 0x01,
|
||||
(0, 1) => 0x02,
|
||||
(0, 2) => 0x04,
|
||||
(0, 3) => 0x40,
|
||||
(1, 0) => 0x08,
|
||||
(1, 1) => 0x10,
|
||||
(1, 2) => 0x20,
|
||||
(1, 3) => 0x80,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rank_color(c: Color, theme: &crate::theme::ThemeColors) -> u8 {
|
||||
if c == theme.meter.high { 2 }
|
||||
else if c == theme.meter.mid { 1 }
|
||||
else { 0 }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Scrollable single-select list widget with cursor highlight.
|
||||
|
||||
use crate::theme;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
@@ -5,6 +7,7 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Scrollable list with a highlighted cursor and selected-item marker.
|
||||
pub struct ListSelect<'a> {
|
||||
items: &'a [String],
|
||||
selected: usize,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Centered modal frame with border and title.
|
||||
|
||||
use crate::theme;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Centered modal overlay with titled border.
|
||||
pub struct ModalFrame<'a> {
|
||||
title: &'a str,
|
||||
width: u16,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Page navigation minimap showing a 3x2 grid of tiles.
|
||||
|
||||
use crate::theme;
|
||||
use ratatui::layout::{Alignment, Rect};
|
||||
use ratatui::style::Style;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Vertical label/value property form renderer.
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::widgets::Paragraph;
|
||||
@@ -5,6 +7,7 @@ use ratatui::Frame;
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// Render a vertical list of label/value pairs with selection highlight.
|
||||
pub fn render_props_form(frame: &mut Frame, area: Rect, fields: &[(&str, &str, bool)]) {
|
||||
let theme = theme::get();
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
//! Tree-view sample browser with search filtering.
|
||||
|
||||
use crate::theme;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Node type in the sample tree.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum TreeLineKind {
|
||||
Root { expanded: bool },
|
||||
@@ -12,6 +15,7 @@ pub enum TreeLineKind {
|
||||
File,
|
||||
}
|
||||
|
||||
/// A single row in the sample browser tree.
|
||||
#[derive(Clone)]
|
||||
pub struct TreeLine {
|
||||
pub depth: u8,
|
||||
@@ -19,8 +23,10 @@ pub struct TreeLine {
|
||||
pub label: String,
|
||||
pub folder: String,
|
||||
pub index: usize,
|
||||
pub child_count: usize,
|
||||
}
|
||||
|
||||
/// Tree-view browser for navigating sample folders.
|
||||
pub struct SampleBrowser<'a> {
|
||||
entries: &'a [TreeLine],
|
||||
cursor: usize,
|
||||
@@ -111,13 +117,13 @@ impl<'a> SampleBrowser<'a> {
|
||||
fn render_tree(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
|
||||
let height = area.height as usize;
|
||||
if self.entries.is_empty() {
|
||||
let msg = if self.search_query.is_empty() {
|
||||
"No samples loaded"
|
||||
if self.search_query.is_empty() {
|
||||
self.render_empty_guide(frame, area, colors);
|
||||
} else {
|
||||
"No matches"
|
||||
};
|
||||
let line = Line::from(Span::styled(msg, Style::new().fg(colors.browser.empty_text)));
|
||||
frame.render_widget(Paragraph::new(vec![line]), area);
|
||||
let line =
|
||||
Line::from(Span::styled("No matches", Style::new().fg(colors.browser.empty_text)));
|
||||
frame.render_widget(Paragraph::new(vec![line]), area);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,10 +137,10 @@ impl<'a> SampleBrowser<'a> {
|
||||
|
||||
let (icon, icon_color) = match entry.kind {
|
||||
TreeLineKind::Root { expanded: true } | TreeLineKind::Folder { expanded: true } => {
|
||||
("\u{25BC} ", colors.browser.folder_icon)
|
||||
("\u{2212} ", colors.browser.folder_icon)
|
||||
}
|
||||
TreeLineKind::Root { expanded: false }
|
||||
| TreeLineKind::Folder { expanded: false } => ("\u{25B6} ", colors.browser.folder_icon),
|
||||
| TreeLineKind::Folder { expanded: false } => ("+ ", colors.browser.folder_icon),
|
||||
TreeLineKind::File => ("\u{266A} ", colors.browser.file_icon),
|
||||
};
|
||||
|
||||
@@ -158,15 +164,43 @@ impl<'a> SampleBrowser<'a> {
|
||||
Style::new().fg(icon_color)
|
||||
};
|
||||
|
||||
let prefix_width = indent.len() + 2; // indent + icon
|
||||
let suffix = match entry.kind {
|
||||
TreeLineKind::File => format!(" {}", entry.index),
|
||||
TreeLineKind::Root { expanded: false }
|
||||
| TreeLineKind::Folder { expanded: false }
|
||||
if entry.child_count > 0 =>
|
||||
{
|
||||
format!(" ({})", entry.child_count)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
let max_label = (area.width as usize)
|
||||
.saturating_sub(prefix_width)
|
||||
.saturating_sub(suffix.len());
|
||||
let label: std::borrow::Cow<str> = if entry.label.len() > max_label && max_label > 1 {
|
||||
let truncated: String = entry.label.chars().take(max_label - 1).collect();
|
||||
format!("{}\u{2026}", truncated).into()
|
||||
} else {
|
||||
(&entry.label).into()
|
||||
};
|
||||
|
||||
let mut spans = vec![
|
||||
Span::raw(indent),
|
||||
Span::styled(icon, icon_style),
|
||||
Span::styled(&entry.label, label_style),
|
||||
Span::styled(label, label_style),
|
||||
];
|
||||
|
||||
if matches!(entry.kind, TreeLineKind::File) {
|
||||
let idx_style = Style::new().fg(colors.browser.empty_text);
|
||||
spans.push(Span::styled(format!(" {}", entry.index), idx_style));
|
||||
match entry.kind {
|
||||
TreeLineKind::File => {
|
||||
let idx_style = Style::new().fg(colors.browser.empty_text);
|
||||
spans.push(Span::styled(suffix, idx_style));
|
||||
}
|
||||
_ if !suffix.is_empty() => {
|
||||
let dim_style = Style::new().fg(colors.browser.empty_text);
|
||||
spans.push(Span::styled(suffix, dim_style));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
lines.push(Line::from(spans));
|
||||
@@ -174,4 +208,47 @@ impl<'a> SampleBrowser<'a> {
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
fn render_empty_guide(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
|
||||
let muted = Style::new().fg(colors.browser.empty_text);
|
||||
let heading = Style::new().fg(colors.ui.text_primary);
|
||||
let key = Style::new().fg(colors.hint.key);
|
||||
let desc = Style::new().fg(colors.hint.text);
|
||||
let code = Style::new().fg(colors.ui.accent);
|
||||
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(" No samples loaded.", muted)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" Load from the Engine page:", heading)),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" F6 ", key),
|
||||
Span::styled("Go to Engine page", desc),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" A ", key),
|
||||
Span::styled("Add a sample folder", desc),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" Organize samples like this:", heading)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" samples/", code)),
|
||||
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} kick/", code)),
|
||||
Line::from(Span::styled(" \u{2502} \u{2514}\u{2500}\u{2500} kick.wav", code)),
|
||||
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} snare/", code)),
|
||||
Line::from(Span::styled(" \u{2502} \u{2514}\u{2500}\u{2500} snare.wav", code)),
|
||||
Line::from(Span::styled(" \u{2514}\u{2500}\u{2500} hats/", code)),
|
||||
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} closed.wav", code)),
|
||||
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} open.wav", code)),
|
||||
Line::from(Span::styled(" \u{2514}\u{2500}\u{2500} pedal.wav", code)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" Folders become Forth words:", heading)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" kick sound .", code)),
|
||||
Line::from(Span::styled(" hats sound 2 n .", code)),
|
||||
Line::from(Span::styled(" snare sound 0.5 speed .", code)),
|
||||
];
|
||||
|
||||
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Oscilloscope waveform widget using braille characters.
|
||||
|
||||
use crate::theme;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -9,12 +11,14 @@ thread_local! {
|
||||
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Rendering direction for the oscilloscope.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Orientation {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// Single-channel oscilloscope using braille dot plotting.
|
||||
pub struct Scope<'a> {
|
||||
data: &'a [f32],
|
||||
orientation: Orientation,
|
||||
@@ -41,6 +45,11 @@ impl<'a> Scope<'a> {
|
||||
self.color = Some(c);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn gain(mut self, g: f32) -> Self {
|
||||
self.gain = g;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Scope<'_> {
|
||||
@@ -66,9 +75,6 @@ fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, g
|
||||
let fine_width = width * 2;
|
||||
let fine_height = height * 4;
|
||||
|
||||
let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
|
||||
let auto_gain = if peak > 0.001 { gain / peak } else { gain };
|
||||
|
||||
PATTERNS.with(|p| {
|
||||
let mut patterns = p.borrow_mut();
|
||||
let size = width * height;
|
||||
@@ -77,7 +83,7 @@ fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, g
|
||||
|
||||
for fine_x in 0..fine_width {
|
||||
let sample_idx = (fine_x * data.len()) / fine_width;
|
||||
let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * auto_gain).clamp(-1.0, 1.0);
|
||||
let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0);
|
||||
|
||||
let fine_y = ((1.0 - sample) * 0.5 * (fine_height - 1) as f32).round() as usize;
|
||||
let fine_y = fine_y.min(fine_height - 1);
|
||||
@@ -122,9 +128,6 @@ fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gai
|
||||
let fine_width = width * 2;
|
||||
let fine_height = height * 4;
|
||||
|
||||
let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
|
||||
let auto_gain = if peak > 0.001 { gain / peak } else { gain };
|
||||
|
||||
PATTERNS.with(|p| {
|
||||
let mut patterns = p.borrow_mut();
|
||||
let size = width * height;
|
||||
@@ -133,7 +136,7 @@ fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gai
|
||||
|
||||
for fine_y in 0..fine_height {
|
||||
let sample_idx = (fine_y * data.len()) / fine_height;
|
||||
let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * auto_gain).clamp(-1.0, 1.0);
|
||||
let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0);
|
||||
|
||||
let fine_x = ((sample + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
|
||||
let fine_x = fine_x.min(fine_width - 1);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
//! Up/down arrow scroll indicators for bounded lists.
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Horizontal alignment for scroll indicators.
|
||||
pub enum IndicatorAlign {
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Render up/down scroll arrows when content overflows.
|
||||
pub fn render_scroll_indicators(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Inline search bar with active/inactive styling.
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -6,6 +8,7 @@ use ratatui::Frame;
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// Render a `/query` search bar.
|
||||
pub fn render_search_bar(frame: &mut Frame, area: Rect, query: &str, active: bool) {
|
||||
let theme = theme::get();
|
||||
let style = if active {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Section header with horizontal divider for engine-view panels.
|
||||
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::widgets::Paragraph;
|
||||
@@ -5,6 +7,7 @@ use ratatui::Frame;
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// Render a section title with a horizontal divider below it.
|
||||
pub fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let [header_area, divider_area] =
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Decorative particle effect using random Unicode glyphs.
|
||||
|
||||
use crate::theme;
|
||||
use rand::Rng;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -14,6 +16,7 @@ struct Sparkle {
|
||||
life: u8,
|
||||
}
|
||||
|
||||
/// Animated sparkle particles for visual flair.
|
||||
#[derive(Default)]
|
||||
pub struct Sparkles {
|
||||
sparkles: Vec<Sparkle>,
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
//! 32-band frequency spectrum display with optional peak hold.
|
||||
|
||||
use crate::theme;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::cell::RefCell;
|
||||
|
||||
const BLOCKS: [char; 8] = ['\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}'];
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SpectrumStyle {
|
||||
#[default]
|
||||
Bars,
|
||||
Line,
|
||||
Filled,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static PEAKS: RefCell<[f32; 32]> = const { RefCell::new([0.0; 32]) };
|
||||
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// 32-band spectrum analyzer using block characters.
|
||||
pub struct Spectrum<'a> {
|
||||
data: &'a [f32; 32],
|
||||
gain: f32,
|
||||
style: SpectrumStyle,
|
||||
peaks: bool,
|
||||
}
|
||||
|
||||
impl<'a> Spectrum<'a> {
|
||||
pub fn new(data: &'a [f32; 32]) -> Self {
|
||||
Self { data }
|
||||
Self {
|
||||
data,
|
||||
gain: 1.0,
|
||||
style: SpectrumStyle::Bars,
|
||||
peaks: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gain(mut self, g: f32) -> Self {
|
||||
self.gain = g;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn style(mut self, s: SpectrumStyle) -> Self {
|
||||
self.style = s;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn peaks(mut self, enabled: bool) -> Self {
|
||||
self.peaks = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,45 +62,177 @@ impl Widget for Spectrum<'_> {
|
||||
return;
|
||||
}
|
||||
|
||||
let colors = theme::get();
|
||||
let height = area.height as f32;
|
||||
let base = area.width as usize / 32;
|
||||
let remainder = area.width as usize % 32;
|
||||
if base == 0 && remainder == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut x_start = area.x;
|
||||
for (band, &mag) in self.data.iter().enumerate() {
|
||||
let w = base + if band < remainder { 1 } else { 0 };
|
||||
if w == 0 {
|
||||
continue;
|
||||
}
|
||||
let bar_height = mag * height;
|
||||
let full_cells = bar_height as usize;
|
||||
let frac = bar_height - full_cells as f32;
|
||||
let frac_idx = (frac * 8.0) as usize;
|
||||
|
||||
for row in 0..area.height as usize {
|
||||
let y = area.y + area.height - 1 - row as u16;
|
||||
let ratio = row as f32 / area.height as f32;
|
||||
let color = if ratio < 0.33 {
|
||||
Color::Rgb(colors.meter.low_rgb.0, colors.meter.low_rgb.1, colors.meter.low_rgb.2)
|
||||
} else if ratio < 0.66 {
|
||||
Color::Rgb(colors.meter.mid_rgb.0, colors.meter.mid_rgb.1, colors.meter.mid_rgb.2)
|
||||
} else {
|
||||
Color::Rgb(colors.meter.high_rgb.0, colors.meter.high_rgb.1, colors.meter.high_rgb.2)
|
||||
};
|
||||
for dx in 0..w as u16 {
|
||||
let x = x_start + dx;
|
||||
if row < full_cells {
|
||||
buf[(x, y)].set_char(BLOCKS[7]).set_fg(color);
|
||||
} else if row == full_cells && frac_idx > 0 {
|
||||
buf[(x, y)].set_char(BLOCKS[frac_idx - 1]).set_fg(color);
|
||||
// Update peak hold state
|
||||
let peak_values = if self.peaks {
|
||||
Some(PEAKS.with(|p| {
|
||||
let mut peaks = p.borrow_mut();
|
||||
for (i, &mag) in self.data.iter().enumerate() {
|
||||
let v = (mag * self.gain).min(1.0);
|
||||
if v >= peaks[i] {
|
||||
peaks[i] = v;
|
||||
} else {
|
||||
peaks[i] = (peaks[i] - 0.02).max(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
x_start += w as u16;
|
||||
*peaks
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match self.style {
|
||||
SpectrumStyle::Bars => render_bars(self.data, area, buf, self.gain, peak_values.as_ref()),
|
||||
SpectrumStyle::Line => render_braille(self.data, area, buf, self.gain, false, peak_values.as_ref()),
|
||||
SpectrumStyle::Filled => render_braille(self.data, area, buf, self.gain, true, peak_values.as_ref()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn band_color(ratio: f32, colors: &theme::ThemeColors) -> Color {
|
||||
if ratio < 0.33 {
|
||||
Color::Rgb(colors.meter.low_rgb.0, colors.meter.low_rgb.1, colors.meter.low_rgb.2)
|
||||
} else if ratio < 0.66 {
|
||||
Color::Rgb(colors.meter.mid_rgb.0, colors.meter.mid_rgb.1, colors.meter.mid_rgb.2)
|
||||
} else {
|
||||
Color::Rgb(colors.meter.high_rgb.0, colors.meter.high_rgb.1, colors.meter.high_rgb.2)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_bars(data: &[f32; 32], area: Rect, buf: &mut Buffer, gain: f32, peaks: Option<&[f32; 32]>) {
|
||||
let colors = theme::get();
|
||||
let height = area.height as f32;
|
||||
let base = area.width as usize / 32;
|
||||
let remainder = area.width as usize % 32;
|
||||
if base == 0 && remainder == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut x_start = area.x;
|
||||
for (band, &mag) in data.iter().enumerate() {
|
||||
let w = base + if band < remainder { 1 } else { 0 };
|
||||
if w == 0 {
|
||||
continue;
|
||||
}
|
||||
let bar_height = (mag * gain).min(1.0) * height;
|
||||
let full_cells = bar_height as usize;
|
||||
let frac = bar_height - full_cells as f32;
|
||||
let frac_idx = (frac * 8.0) as usize;
|
||||
|
||||
// Peak hold row
|
||||
let peak_row = peaks.map(|p| {
|
||||
let ph = p[band] * height;
|
||||
let row = (height - ph).max(0.0) as usize;
|
||||
row.min(area.height as usize - 1)
|
||||
});
|
||||
|
||||
for row in 0..area.height as usize {
|
||||
let y = area.y + area.height - 1 - row as u16;
|
||||
let ratio = row as f32 / area.height as f32;
|
||||
let color = band_color(ratio, &colors);
|
||||
|
||||
for dx in 0..w as u16 {
|
||||
let x = x_start + dx;
|
||||
if row < full_cells {
|
||||
buf[(x, y)].set_char(BLOCKS[7]).set_fg(color);
|
||||
} else if row == full_cells && frac_idx > 0 {
|
||||
buf[(x, y)].set_char(BLOCKS[frac_idx - 1]).set_fg(color);
|
||||
} else if let Some(pr) = peak_row {
|
||||
// peak_row is from top (0 = top), row is from bottom
|
||||
let from_top = area.height as usize - 1 - row;
|
||||
if from_top == pr {
|
||||
buf[(x, y)].set_char('─').set_fg(colors.meter.high);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
x_start += w as u16;
|
||||
}
|
||||
}
|
||||
|
||||
fn render_braille(
|
||||
data: &[f32; 32],
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
gain: f32,
|
||||
filled: bool,
|
||||
peaks: Option<&[f32; 32]>,
|
||||
) {
|
||||
let colors = theme::get();
|
||||
let width = area.width as usize;
|
||||
let height = area.height as usize;
|
||||
let fine_w = width * 2;
|
||||
let fine_h = height * 4;
|
||||
|
||||
PATTERNS.with(|p| {
|
||||
let mut patterns = p.borrow_mut();
|
||||
patterns.clear();
|
||||
patterns.resize(width * height, 0);
|
||||
|
||||
// Interpolate 32 bands across fine_w columns
|
||||
for fx in 0..fine_w {
|
||||
let band_f = fx as f32 * 31.0 / (fine_w - 1).max(1) as f32;
|
||||
let lo = band_f as usize;
|
||||
let hi = (lo + 1).min(31);
|
||||
let t = band_f - lo as f32;
|
||||
let mag = ((data[lo] * (1.0 - t) + data[hi] * t) * gain).min(1.0);
|
||||
let fy = ((1.0 - mag) * (fine_h - 1) as f32).round() as usize;
|
||||
let fy = fy.min(fine_h - 1);
|
||||
|
||||
if filled {
|
||||
for y in fy..fine_h {
|
||||
let cy = y / 4;
|
||||
let dy = y % 4;
|
||||
let cx = fx / 2;
|
||||
let dx = fx % 2;
|
||||
patterns[cy * width + cx] |= braille_bit(dx, dy);
|
||||
}
|
||||
} else {
|
||||
let cy = fy / 4;
|
||||
let dy = fy % 4;
|
||||
let cx = fx / 2;
|
||||
let dx = fx % 2;
|
||||
patterns[cy * width + cx] |= braille_bit(dx, dy);
|
||||
}
|
||||
|
||||
// Peak dots
|
||||
if let Some(pk) = peaks {
|
||||
let pv = (pk[lo] * (1.0 - t) + pk[hi] * t).min(1.0);
|
||||
let py = ((1.0 - pv) * (fine_h - 1) as f32).round() as usize;
|
||||
let py = py.min(fine_h - 1);
|
||||
let cy = py / 4;
|
||||
let dy = py % 4;
|
||||
let cx = fx / 2;
|
||||
let dx = fx % 2;
|
||||
patterns[cy * width + cx] |= braille_bit(dx, dy);
|
||||
}
|
||||
}
|
||||
|
||||
for cy in 0..height {
|
||||
for cx in 0..width {
|
||||
let pattern = patterns[cy * width + cx];
|
||||
if pattern != 0 {
|
||||
let ratio = 1.0 - (cy as f32 / height as f32);
|
||||
let color = band_color(ratio, &colors);
|
||||
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
|
||||
buf[(area.x + cx as u16, area.y + cy as u16)]
|
||||
.set_char(ch)
|
||||
.set_fg(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn braille_bit(dot_x: usize, dot_y: usize) -> u8 {
|
||||
match (dot_x, dot_y) {
|
||||
(0, 0) => 0x01,
|
||||
(0, 1) => 0x02,
|
||||
(0, 2) => 0x04,
|
||||
(0, 3) => 0x40,
|
||||
(1, 0) => 0x08,
|
||||
(1, 1) => 0x10,
|
||||
(1, 2) => 0x20,
|
||||
(1, 3) => 0x80,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Single-line text input modal with optional hint.
|
||||
|
||||
use crate::theme;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
@@ -7,6 +9,7 @@ use ratatui::Frame;
|
||||
|
||||
use super::ModalFrame;
|
||||
|
||||
/// Modal dialog with a single-line text input.
|
||||
pub struct TextInputModal<'a> {
|
||||
title: &'a str,
|
||||
input: &'a str,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Derive [`ThemeColors`] from a [`Palette`].
|
||||
|
||||
use super::*;
|
||||
use super::palette::{Palette, Rgb, darken, mid, rgb, tint};
|
||||
|
||||
/// Build a complete [`ThemeColors`] from a [`Palette`].
|
||||
pub fn build(p: &Palette) -> ThemeColors {
|
||||
let darker_bg = darken(p.bg, 0.15);
|
||||
|
||||
@@ -55,6 +58,7 @@ pub fn build(p: &Palette) -> ThemeColors {
|
||||
header: HeaderColors {
|
||||
tempo_bg: rgb(tint(p.bg, p.tempo_color, 0.30)),
|
||||
tempo_fg: rgb(p.tempo_color),
|
||||
beat_bg: rgb(tint(p.bg, p.tempo_color, 0.45)),
|
||||
bank_bg: rgb(tint(p.bg, p.bank_color, 0.25)),
|
||||
bank_fg: rgb(p.bank_color),
|
||||
pattern_bg: rgb(tint(p.bg, p.pattern_color, 0.25)),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Catppuccin Latte palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Catppuccin Mocha palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Dracula palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Eden palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Ember palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
41
crates/ratatui/src/theme/everforest.rs
Normal file
41
crates/ratatui/src/theme/everforest.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Everforest palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
Palette {
|
||||
bg: (45, 53, 59),
|
||||
surface: (52, 62, 68),
|
||||
surface2: (68, 80, 86),
|
||||
fg: (211, 198, 170),
|
||||
fg_dim: (135, 131, 116),
|
||||
fg_muted: (80, 80, 68),
|
||||
accent: (167, 192, 128),
|
||||
red: (230, 126, 128),
|
||||
green: (167, 192, 128),
|
||||
yellow: (219, 188, 127),
|
||||
blue: (127, 187, 179),
|
||||
purple: (214, 153, 182),
|
||||
cyan: (131, 192, 146),
|
||||
orange: (230, 152, 117),
|
||||
tempo_color: (214, 153, 182),
|
||||
bank_color: (127, 187, 179),
|
||||
pattern_color: (131, 192, 146),
|
||||
title_accent: (167, 192, 128),
|
||||
title_author: (127, 187, 179),
|
||||
secondary: (230, 152, 117),
|
||||
link_bright: [
|
||||
(167, 192, 128), (214, 153, 182), (230, 152, 117),
|
||||
(127, 187, 179), (219, 188, 127),
|
||||
],
|
||||
link_dim: [
|
||||
(56, 66, 46), (70, 52, 62), (72, 52, 42),
|
||||
(44, 64, 60), (70, 62, 44),
|
||||
],
|
||||
sparkle: [
|
||||
(167, 192, 128), (230, 152, 117), (131, 192, 146),
|
||||
(214, 153, 182), (219, 188, 127),
|
||||
],
|
||||
meter: [(148, 172, 110), (200, 170, 108), (210, 108, 110)],
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Fairyfloss palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
41
crates/ratatui/src/theme/fauve.rs
Normal file
41
crates/ratatui/src/theme/fauve.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Fauve palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
Palette {
|
||||
bg: (28, 22, 18),
|
||||
surface: (42, 33, 26),
|
||||
surface2: (58, 46, 36),
|
||||
fg: (240, 228, 210),
|
||||
fg_dim: (170, 150, 130),
|
||||
fg_muted: (100, 82, 66),
|
||||
accent: (230, 60, 20),
|
||||
red: (220, 38, 32),
|
||||
green: (30, 170, 80),
|
||||
yellow: (255, 210, 0),
|
||||
blue: (20, 80, 200),
|
||||
purple: (170, 40, 150),
|
||||
cyan: (0, 150, 180),
|
||||
orange: (240, 120, 0),
|
||||
tempo_color: (230, 60, 20),
|
||||
bank_color: (20, 80, 200),
|
||||
pattern_color: (0, 150, 180),
|
||||
title_accent: (230, 60, 20),
|
||||
title_author: (20, 80, 200),
|
||||
secondary: (170, 40, 150),
|
||||
link_bright: [
|
||||
(230, 60, 20), (20, 80, 200), (240, 120, 0),
|
||||
(0, 150, 180), (30, 170, 80),
|
||||
],
|
||||
link_dim: [
|
||||
(72, 24, 10), (10, 28, 65), (76, 40, 6),
|
||||
(6, 48, 58), (14, 54, 28),
|
||||
],
|
||||
sparkle: [
|
||||
(230, 60, 20), (255, 210, 0), (30, 170, 80),
|
||||
(20, 80, 200), (170, 40, 150),
|
||||
],
|
||||
meter: [(26, 152, 72), (235, 190, 0), (200, 34, 28)],
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Georges palette (C64 colors on black).
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
// C64 palette on pure black
|
||||
pub fn palette() -> Palette {
|
||||
Palette {
|
||||
bg: (0, 0, 0),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Gruvbox Dark palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Hot Dog Stand palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
41
crates/ratatui/src/theme/iceberg.rs
Normal file
41
crates/ratatui/src/theme/iceberg.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Iceberg palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
Palette {
|
||||
bg: (22, 24, 33),
|
||||
surface: (30, 33, 46),
|
||||
surface2: (45, 48, 64),
|
||||
fg: (198, 200, 209),
|
||||
fg_dim: (109, 112, 126),
|
||||
fg_muted: (64, 66, 78),
|
||||
accent: (132, 160, 198),
|
||||
red: (226, 120, 120),
|
||||
green: (180, 190, 130),
|
||||
yellow: (226, 164, 120),
|
||||
blue: (132, 160, 198),
|
||||
purple: (160, 147, 199),
|
||||
cyan: (137, 184, 194),
|
||||
orange: (226, 164, 120),
|
||||
tempo_color: (160, 147, 199),
|
||||
bank_color: (132, 160, 198),
|
||||
pattern_color: (137, 184, 194),
|
||||
title_accent: (132, 160, 198),
|
||||
title_author: (160, 147, 199),
|
||||
secondary: (226, 164, 120),
|
||||
link_bright: [
|
||||
(132, 160, 198), (160, 147, 199), (226, 164, 120),
|
||||
(137, 184, 194), (180, 190, 130),
|
||||
],
|
||||
link_dim: [
|
||||
(45, 55, 70), (55, 50, 68), (70, 55, 42),
|
||||
(46, 62, 66), (58, 62, 44),
|
||||
],
|
||||
sparkle: [
|
||||
(132, 160, 198), (226, 164, 120), (180, 190, 130),
|
||||
(160, 147, 199), (226, 120, 120),
|
||||
],
|
||||
meter: [(160, 175, 115), (210, 150, 105), (200, 105, 105)],
|
||||
}
|
||||
}
|
||||
41
crates/ratatui/src/theme/jaipur.rs
Normal file
41
crates/ratatui/src/theme/jaipur.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Jaipur palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
Palette {
|
||||
bg: (30, 24, 22),
|
||||
surface: (44, 36, 32),
|
||||
surface2: (60, 48, 42),
|
||||
fg: (238, 222, 200),
|
||||
fg_dim: (165, 145, 125),
|
||||
fg_muted: (95, 78, 65),
|
||||
accent: (210, 90, 100),
|
||||
red: (200, 44, 52),
|
||||
green: (30, 160, 120),
|
||||
yellow: (240, 180, 20),
|
||||
blue: (60, 60, 180),
|
||||
purple: (150, 50, 120),
|
||||
cyan: (0, 155, 155),
|
||||
orange: (220, 120, 50),
|
||||
tempo_color: (210, 90, 100),
|
||||
bank_color: (60, 60, 180),
|
||||
pattern_color: (0, 155, 155),
|
||||
title_accent: (210, 90, 100),
|
||||
title_author: (60, 60, 180),
|
||||
secondary: (220, 120, 50),
|
||||
link_bright: [
|
||||
(210, 90, 100), (60, 60, 180), (220, 120, 50),
|
||||
(0, 155, 155), (30, 160, 120),
|
||||
],
|
||||
link_dim: [
|
||||
(66, 30, 34), (22, 22, 58), (70, 40, 18),
|
||||
(6, 48, 48), (12, 50, 38),
|
||||
],
|
||||
sparkle: [
|
||||
(210, 90, 100), (240, 180, 20), (30, 160, 120),
|
||||
(60, 60, 180), (150, 50, 120),
|
||||
],
|
||||
meter: [(26, 144, 106), (222, 164, 18), (184, 40, 46)],
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Kanagawa palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Letz Light palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -8,30 +8,38 @@ mod catppuccin_mocha;
|
||||
mod dracula;
|
||||
mod eden;
|
||||
mod ember;
|
||||
mod everforest;
|
||||
mod georges;
|
||||
mod fairyfloss;
|
||||
mod gruvbox_dark;
|
||||
mod hot_dog_stand;
|
||||
mod iceberg;
|
||||
mod jaipur;
|
||||
mod kanagawa;
|
||||
mod letz_light;
|
||||
mod monochrome_black;
|
||||
mod monochrome_white;
|
||||
mod monokai;
|
||||
mod nord;
|
||||
mod fauve;
|
||||
mod pitch_black;
|
||||
mod tropicalia;
|
||||
mod rose_pine;
|
||||
mod tokyo_night;
|
||||
pub mod transform;
|
||||
|
||||
use ratatui::style::Color;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Entry in the theme registry: id, display label, and palette constructor.
|
||||
pub struct ThemeEntry {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub palette: fn() -> palette::Palette,
|
||||
}
|
||||
|
||||
/// All available themes.
|
||||
pub const THEMES: &[ThemeEntry] = &[
|
||||
ThemeEntry { id: "CatppuccinMocha", label: "Catppuccin Mocha", palette: catppuccin_mocha::palette },
|
||||
ThemeEntry { id: "CatppuccinLatte", label: "Catppuccin Latte", palette: catppuccin_latte::palette },
|
||||
@@ -51,20 +59,28 @@ pub const THEMES: &[ThemeEntry] = &[
|
||||
ThemeEntry { id: "Ember", label: "Ember", palette: ember::palette },
|
||||
ThemeEntry { id: "Eden", label: "Eden", palette: eden::palette },
|
||||
ThemeEntry { id: "Georges", label: "Georges", palette: georges::palette },
|
||||
ThemeEntry { id: "Iceberg", label: "Iceberg", palette: iceberg::palette },
|
||||
ThemeEntry { id: "Everforest", label: "Everforest", palette: everforest::palette },
|
||||
ThemeEntry { id: "Fauve", label: "Fauve", palette: fauve::palette },
|
||||
ThemeEntry { id: "Tropicalia", label: "Tropicalia", palette: tropicalia::palette },
|
||||
ThemeEntry { id: "Jaipur", label: "Jaipur", palette: jaipur::palette },
|
||||
];
|
||||
|
||||
thread_local! {
|
||||
static CURRENT_THEME: RefCell<ThemeColors> = RefCell::new(build::build(&(THEMES[0].palette)()));
|
||||
static CURRENT_THEME: RefCell<Rc<ThemeColors>> = RefCell::new(Rc::new(build::build(&(THEMES[0].palette)())));
|
||||
}
|
||||
|
||||
pub fn get() -> ThemeColors {
|
||||
CURRENT_THEME.with(|t| t.borrow().clone())
|
||||
/// Return the current thread-local theme (cheap Rc clone, not a deep copy).
|
||||
pub fn get() -> Rc<ThemeColors> {
|
||||
CURRENT_THEME.with(|t| Rc::clone(&t.borrow()))
|
||||
}
|
||||
|
||||
/// Set the current thread-local theme.
|
||||
pub fn set(theme: ThemeColors) {
|
||||
CURRENT_THEME.with(|t| *t.borrow_mut() = theme);
|
||||
CURRENT_THEME.with(|t| *t.borrow_mut() = Rc::new(theme));
|
||||
}
|
||||
|
||||
/// Complete set of resolved colors for all UI components.
|
||||
#[derive(Clone)]
|
||||
pub struct ThemeColors {
|
||||
pub ui: UiColors,
|
||||
@@ -95,6 +111,7 @@ pub struct ThemeColors {
|
||||
pub confirm: ConfirmColors,
|
||||
}
|
||||
|
||||
/// Core UI colors: background, text, borders.
|
||||
#[derive(Clone)]
|
||||
pub struct UiColors {
|
||||
pub bg: Color,
|
||||
@@ -109,6 +126,7 @@ pub struct UiColors {
|
||||
pub surface: Color,
|
||||
}
|
||||
|
||||
/// Playback status bar colors.
|
||||
#[derive(Clone)]
|
||||
pub struct StatusColors {
|
||||
pub playing_bg: Color,
|
||||
@@ -120,6 +138,7 @@ pub struct StatusColors {
|
||||
pub fill_bg: Color,
|
||||
}
|
||||
|
||||
/// Step grid selection and cursor colors.
|
||||
#[derive(Clone)]
|
||||
pub struct SelectionColors {
|
||||
pub cursor_bg: Color,
|
||||
@@ -133,6 +152,7 @@ pub struct SelectionColors {
|
||||
pub in_range: Color,
|
||||
}
|
||||
|
||||
/// Step tile colors for various states.
|
||||
#[derive(Clone)]
|
||||
pub struct TileColors {
|
||||
pub playing_active_bg: Color,
|
||||
@@ -150,10 +170,12 @@ pub struct TileColors {
|
||||
pub link_dim: [(u8, u8, u8); 5],
|
||||
}
|
||||
|
||||
/// Top header bar segment colors.
|
||||
#[derive(Clone)]
|
||||
pub struct HeaderColors {
|
||||
pub tempo_bg: Color,
|
||||
pub tempo_fg: Color,
|
||||
pub beat_bg: Color,
|
||||
pub bank_bg: Color,
|
||||
pub bank_fg: Color,
|
||||
pub pattern_bg: Color,
|
||||
@@ -162,6 +184,7 @@ pub struct HeaderColors {
|
||||
pub stats_fg: Color,
|
||||
}
|
||||
|
||||
/// Modal dialog border colors.
|
||||
#[derive(Clone)]
|
||||
pub struct ModalColors {
|
||||
pub border: Color,
|
||||
@@ -175,6 +198,7 @@ pub struct ModalColors {
|
||||
pub preview: Color,
|
||||
}
|
||||
|
||||
/// Flash notification colors.
|
||||
#[derive(Clone)]
|
||||
pub struct FlashColors {
|
||||
pub error_bg: Color,
|
||||
@@ -185,6 +209,7 @@ pub struct FlashColors {
|
||||
pub info_fg: Color,
|
||||
}
|
||||
|
||||
/// Pattern list row state colors.
|
||||
#[derive(Clone)]
|
||||
pub struct ListColors {
|
||||
pub playing_bg: Color,
|
||||
@@ -203,6 +228,7 @@ pub struct ListColors {
|
||||
pub soloed_fg: Color,
|
||||
}
|
||||
|
||||
/// Ableton Link status indicator colors.
|
||||
#[derive(Clone)]
|
||||
pub struct LinkStatusColors {
|
||||
pub disabled: Color,
|
||||
@@ -210,6 +236,7 @@ pub struct LinkStatusColors {
|
||||
pub listening: Color,
|
||||
}
|
||||
|
||||
/// Syntax highlighting (fg, bg) pairs per token category.
|
||||
#[derive(Clone)]
|
||||
pub struct SyntaxColors {
|
||||
pub gap_bg: Color,
|
||||
@@ -234,30 +261,35 @@ pub struct SyntaxColors {
|
||||
pub default: (Color, Color),
|
||||
}
|
||||
|
||||
/// Alternating table row colors.
|
||||
#[derive(Clone)]
|
||||
pub struct TableColors {
|
||||
pub row_even: Color,
|
||||
pub row_odd: Color,
|
||||
}
|
||||
|
||||
/// Value display colors.
|
||||
#[derive(Clone)]
|
||||
pub struct ValuesColors {
|
||||
pub tempo: Color,
|
||||
pub value: Color,
|
||||
}
|
||||
|
||||
/// Keyboard hint key/text colors.
|
||||
#[derive(Clone)]
|
||||
pub struct HintColors {
|
||||
pub key: Color,
|
||||
pub text: Color,
|
||||
}
|
||||
|
||||
/// View badge pill colors.
|
||||
#[derive(Clone)]
|
||||
pub struct ViewBadgeColors {
|
||||
pub bg: Color,
|
||||
pub fg: Color,
|
||||
}
|
||||
|
||||
/// Navigation minimap tile colors.
|
||||
#[derive(Clone)]
|
||||
pub struct NavColors {
|
||||
pub selected_bg: Color,
|
||||
@@ -266,6 +298,7 @@ pub struct NavColors {
|
||||
pub unselected_fg: Color,
|
||||
}
|
||||
|
||||
/// Script editor colors.
|
||||
#[derive(Clone)]
|
||||
pub struct EditorWidgetColors {
|
||||
pub cursor_bg: Color,
|
||||
@@ -277,6 +310,7 @@ pub struct EditorWidgetColors {
|
||||
pub completion_example: Color,
|
||||
}
|
||||
|
||||
/// File and sample browser colors.
|
||||
#[derive(Clone)]
|
||||
pub struct BrowserColors {
|
||||
pub directory: Color,
|
||||
@@ -291,6 +325,7 @@ pub struct BrowserColors {
|
||||
pub empty_text: Color,
|
||||
}
|
||||
|
||||
/// Text input field colors.
|
||||
#[derive(Clone)]
|
||||
pub struct InputColors {
|
||||
pub text: Color,
|
||||
@@ -298,6 +333,7 @@ pub struct InputColors {
|
||||
pub hint: Color,
|
||||
}
|
||||
|
||||
/// Search bar and match highlight colors.
|
||||
#[derive(Clone)]
|
||||
pub struct SearchColors {
|
||||
pub active: Color,
|
||||
@@ -306,6 +342,7 @@ pub struct SearchColors {
|
||||
pub match_fg: Color,
|
||||
}
|
||||
|
||||
/// Markdown renderer colors.
|
||||
#[derive(Clone)]
|
||||
pub struct MarkdownColors {
|
||||
pub h1: Color,
|
||||
@@ -320,6 +357,7 @@ pub struct MarkdownColors {
|
||||
pub list: Color,
|
||||
}
|
||||
|
||||
/// Engine view panel colors.
|
||||
#[derive(Clone)]
|
||||
pub struct EngineColors {
|
||||
pub header: Color,
|
||||
@@ -342,6 +380,7 @@ pub struct EngineColors {
|
||||
pub hint_inactive: Color,
|
||||
}
|
||||
|
||||
/// Dictionary view colors.
|
||||
#[derive(Clone)]
|
||||
pub struct DictColors {
|
||||
pub word_name: Color,
|
||||
@@ -359,6 +398,7 @@ pub struct DictColors {
|
||||
pub header_desc: Color,
|
||||
}
|
||||
|
||||
/// Title screen colors.
|
||||
#[derive(Clone)]
|
||||
pub struct TitleColors {
|
||||
pub big_title: Color,
|
||||
@@ -369,6 +409,7 @@ pub struct TitleColors {
|
||||
pub subtitle: Color,
|
||||
}
|
||||
|
||||
/// VU meter and spectrum level colors.
|
||||
#[derive(Clone)]
|
||||
pub struct MeterColors {
|
||||
pub low: Color,
|
||||
@@ -379,11 +420,13 @@ pub struct MeterColors {
|
||||
pub high_rgb: (u8, u8, u8),
|
||||
}
|
||||
|
||||
/// Sparkle particle colors.
|
||||
#[derive(Clone)]
|
||||
pub struct SparkleColors {
|
||||
pub colors: [(u8, u8, u8); 5],
|
||||
}
|
||||
|
||||
/// Confirm dialog colors.
|
||||
#[derive(Clone)]
|
||||
pub struct ConfirmColors {
|
||||
pub border: Color,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Monochrome (black background) palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Monochrome (white background) palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Monokai palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Nord palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Palette definition and color mixing utilities.
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
/// RGB color triple.
|
||||
pub type Rgb = (u8, u8, u8);
|
||||
|
||||
/// Base color palette that themes are derived from.
|
||||
pub struct Palette {
|
||||
// Core
|
||||
pub bg: Rgb,
|
||||
@@ -33,10 +37,12 @@ pub struct Palette {
|
||||
pub meter: [Rgb; 3],
|
||||
}
|
||||
|
||||
/// Convert an RGB triple to a ratatui [`Color`].
|
||||
pub fn rgb(c: Rgb) -> Color {
|
||||
Color::Rgb(c.0, c.1, c.2)
|
||||
}
|
||||
|
||||
/// Blend `bg` toward `accent` by `amount` (0.0–1.0).
|
||||
pub fn tint(bg: Rgb, accent: Rgb, amount: f32) -> Rgb {
|
||||
let mix = |b: u8, a: u8| -> u8 {
|
||||
let v = b as f32 + (a as f32 - b as f32) * amount;
|
||||
@@ -45,10 +51,12 @@ pub fn tint(bg: Rgb, accent: Rgb, amount: f32) -> Rgb {
|
||||
(mix(bg.0, accent.0), mix(bg.1, accent.1), mix(bg.2, accent.2))
|
||||
}
|
||||
|
||||
/// Linearly interpolate between two colors.
|
||||
pub fn mid(a: Rgb, b: Rgb, t: f32) -> Rgb {
|
||||
tint(a, b, t)
|
||||
}
|
||||
|
||||
/// Darken a color by reducing brightness.
|
||||
pub fn darken(c: Rgb, amount: f32) -> Rgb {
|
||||
let d = |v: u8| -> u8 { (v as f32 * (1.0 - amount)).clamp(0.0, 255.0) as u8 };
|
||||
(d(c.0), d(c.1), d(c.2))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Pitch Black palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Rose Pine palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Tokyo Night palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Hue rotation for palette-wide color transforms.
|
||||
|
||||
use super::palette::{Palette, Rgb};
|
||||
use super::build::build;
|
||||
use super::ThemeColors;
|
||||
@@ -62,6 +64,7 @@ fn rotate3(arr: [Rgb; 3], d: f32) -> [Rgb; 3] {
|
||||
[rotate(arr[0], d), rotate(arr[1], d), rotate(arr[2], d)]
|
||||
}
|
||||
|
||||
/// Build a [`ThemeColors`] with all palette hues rotated by `degrees`.
|
||||
pub fn rotate_palette(palette: &Palette, degrees: f32) -> ThemeColors {
|
||||
if degrees == 0.0 {
|
||||
return build(palette);
|
||||
|
||||
41
crates/ratatui/src/theme/tropicalia.rs
Normal file
41
crates/ratatui/src/theme/tropicalia.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Tropicalia palette.
|
||||
|
||||
use super::palette::Palette;
|
||||
|
||||
pub fn palette() -> Palette {
|
||||
Palette {
|
||||
bg: (20, 26, 22),
|
||||
surface: (30, 40, 34),
|
||||
surface2: (44, 56, 48),
|
||||
fg: (235, 225, 200),
|
||||
fg_dim: (155, 145, 120),
|
||||
fg_muted: (85, 80, 62),
|
||||
accent: (230, 50, 120),
|
||||
red: (240, 70, 70),
|
||||
green: (80, 200, 50),
|
||||
yellow: (255, 195, 0),
|
||||
blue: (0, 160, 200),
|
||||
purple: (180, 60, 180),
|
||||
cyan: (0, 200, 170),
|
||||
orange: (255, 140, 30),
|
||||
tempo_color: (230, 50, 120),
|
||||
bank_color: (0, 160, 200),
|
||||
pattern_color: (0, 200, 170),
|
||||
title_accent: (230, 50, 120),
|
||||
title_author: (0, 160, 200),
|
||||
secondary: (255, 140, 30),
|
||||
link_bright: [
|
||||
(230, 50, 120), (0, 160, 200), (255, 140, 30),
|
||||
(0, 200, 170), (80, 200, 50),
|
||||
],
|
||||
link_dim: [
|
||||
(72, 20, 40), (6, 50, 64), (80, 44, 12),
|
||||
(6, 62, 54), (26, 62, 18),
|
||||
],
|
||||
sparkle: [
|
||||
(230, 50, 120), (255, 195, 0), (80, 200, 50),
|
||||
(0, 160, 200), (180, 60, 180),
|
||||
],
|
||||
meter: [(70, 182, 44), (236, 178, 0), (220, 62, 62)],
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Stereo VU meter with dB-scaled level display.
|
||||
|
||||
use crate::theme;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -8,6 +10,7 @@ const DB_MIN: f32 = -48.0;
|
||||
const DB_MAX: f32 = 3.0;
|
||||
const DB_RANGE: f32 = DB_MAX - DB_MIN;
|
||||
|
||||
/// Stereo VU meter displaying left/right levels in dB.
|
||||
pub struct VuMeter {
|
||||
left: f32,
|
||||
right: f32,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Filled waveform display using braille characters.
|
||||
|
||||
use crate::scope::Orientation;
|
||||
use crate::theme;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -10,6 +12,7 @@ thread_local! {
|
||||
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Filled waveform renderer using braille dot plotting.
|
||||
pub struct Waveform<'a> {
|
||||
data: &'a [f32],
|
||||
orientation: Orientation,
|
||||
@@ -81,9 +84,6 @@ fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, g
|
||||
let fine_height = height * 4;
|
||||
let len = data.len();
|
||||
|
||||
let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
|
||||
let auto_gain = if peak > 0.001 { gain / peak } else { gain };
|
||||
|
||||
PATTERNS.with(|p| {
|
||||
let mut patterns = p.borrow_mut();
|
||||
patterns.clear();
|
||||
@@ -97,7 +97,7 @@ fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, g
|
||||
let mut min_s = f32::MAX;
|
||||
let mut max_s = f32::MIN;
|
||||
for &s in slice {
|
||||
let s = (s * auto_gain).clamp(-1.0, 1.0);
|
||||
let s = (s * gain).clamp(-1.0, 1.0);
|
||||
if s < min_s {
|
||||
min_s = s;
|
||||
}
|
||||
@@ -142,9 +142,6 @@ fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gai
|
||||
let fine_height = height * 4;
|
||||
let len = data.len();
|
||||
|
||||
let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
|
||||
let auto_gain = if peak > 0.001 { gain / peak } else { gain };
|
||||
|
||||
PATTERNS.with(|p| {
|
||||
let mut patterns = p.borrow_mut();
|
||||
patterns.clear();
|
||||
@@ -158,7 +155,7 @@ fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gai
|
||||
let mut min_s = f32::MAX;
|
||||
let mut max_s = f32::MIN;
|
||||
for &s in slice {
|
||||
let s = (s * auto_gain).clamp(-1.0, 1.0);
|
||||
let s = (s * gain).clamp(-1.0, 1.0);
|
||||
if s < min_s {
|
||||
min_s = s;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
8376
demos/02.cagire
8376
demos/02.cagire
File diff suppressed because it is too large
Load Diff
8371
demos/03.cagire
8371
demos/03.cagire
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,8 @@ All time values are in **steps**, just like `attack`, `decay`, and `release`. At
|
||||
Oscillate a parameter between two values.
|
||||
|
||||
```forth
|
||||
saw s 200 4000 4 lfo lpf . ( sweep filter over 4 steps )
|
||||
saw s 0.3 0.7 2 tlfo pan . ( triangle pan over 2 steps )
|
||||
saw snd 200 4000 4 lfo lpf . ( sweep filter over 4 steps )
|
||||
saw snd 0.3 0.7 2 tlfo pan . ( triangle pan over 2 steps )
|
||||
```
|
||||
|
||||
| Word | Shape | Output |
|
||||
@@ -27,8 +27,8 @@ Stack effect: `( min max period -- str )`
|
||||
Transition from one value to another over a duration.
|
||||
|
||||
```forth
|
||||
saw s 0 1 0.5 slide gain . ( fade in over half a step )
|
||||
saw s 200 4000 8 sslide lpf . ( smooth sweep over 8 steps )
|
||||
saw snd 0 1 0.5 slide gain . ( fade in over half a step )
|
||||
saw snd 200 4000 8 sslide lpf . ( smooth sweep over 8 steps )
|
||||
```
|
||||
|
||||
| Word | Curve | Output |
|
||||
@@ -44,9 +44,9 @@ Stack effect: `( start end dur -- str )`
|
||||
Randomize a parameter within a range, retriggering at a given period.
|
||||
|
||||
```forth
|
||||
saw s 200 4000 2 jit lpf . ( new random value every 2 steps )
|
||||
saw s 200 4000 2 sjit lpf . ( same but smoothly interpolated )
|
||||
saw s 200 4000 1 drunk lpf . ( random walk, each step )
|
||||
saw snd 200 4000 2 jit lpf . ( new random value every 2 steps )
|
||||
saw snd 200 4000 2 sjit lpf . ( same but smoothly interpolated )
|
||||
saw snd 200 4000 1 drunk lpf . ( random walk, each step )
|
||||
```
|
||||
|
||||
| Word | Behavior | Output |
|
||||
@@ -57,26 +57,62 @@ saw s 200 4000 1 drunk lpf . ( random walk, each step )
|
||||
|
||||
Stack effect: `( min max period -- str )`
|
||||
|
||||
## Envelopes
|
||||
## Envelope Modulation
|
||||
|
||||
Define a multi-segment envelope for a parameter. Provide a start value, then pairs of target and duration.
|
||||
Apply an envelope to any parameter. The `env` word is the complete form: it sweeps from `min` to `max` following a full attack, decay, sustain, release shape. All times are in steps.
|
||||
|
||||
```forth
|
||||
saw s 0 1 0.1 0.7 0.5 0 8 env gain .
|
||||
saw snd 200 8000 0.01 0.1 0.5 0.3 env lpf .
|
||||
```
|
||||
|
||||
This creates: start at `0`, rise to `1` in `0.1` steps, drop to `0.7` in `0.5` steps, fall to `0` in `8` steps.
|
||||
Stack effect: `( min max attack decay sustain release -- str )`
|
||||
|
||||
Stack effect: `( start target1 dur1 [target2 dur2 ...] -- str )`
|
||||
This is the building block. From it, three shorthands drop the parameters you don't need:
|
||||
|
||||
| Word | Stack | What it does |
|
||||
|------|-------|-------------|
|
||||
| `env` | `( min max a d s r -- str )` | Full envelope (attack, decay, sustain, release) |
|
||||
| `eadr` | `( min max a d r -- str )` | No sustain (sustain = 0) |
|
||||
| `ead` | `( min max a d -- str )` | Percussive (sustain = 0, release = 0) |
|
||||
|
||||
`eadsr` is an alias for `env`.
|
||||
|
||||
```forth
|
||||
saw snd 200 8000 0.01 0.3 ead lpf . ( percussive filter pluck )
|
||||
saw snd 0 5 0.01 0.1 0.3 eadr fm . ( FM depth with release tail )
|
||||
saw snd 200 8000 0.01 0.1 0.5 0.3 env lpf . ( full ADSR on filter )
|
||||
```
|
||||
|
||||
These work on any parameter — `lpf`, `fm`, `gain`, `pan`, `freq`, anything that accepts a value.
|
||||
|
||||
## Low Pass Gate
|
||||
|
||||
The `lpg` word couples the amplitude envelope with a lowpass filter. Set your amp envelope first with `ad` or `adsr`, then `lpg` mirrors it to `lpf`.
|
||||
|
||||
```forth
|
||||
saw snd 0.01 0.1 ad 200 8000 1 lpg . ( percussive LPG )
|
||||
saw snd 0.01 0.1 0.5 0.3 adsr 200 4000 1 lpg . ( sustained LPG )
|
||||
```
|
||||
|
||||
Stack effect: `( min max depth -- )`
|
||||
|
||||
- `min`/`max` — filter frequency range in Hz
|
||||
- `depth` — 0 to 1, scales the filter range (1 = full, 0.5 = halfway)
|
||||
|
||||
```forth
|
||||
saw snd 0.01 0.5 ad 200 8000 0.3 lpg . ( subtle LPG, filter barely opens )
|
||||
```
|
||||
|
||||
`lpg` reads `attack`, `decay`, `sustain`, and `release` from the current sound. If none are set, it defaults to a short percussive shape.
|
||||
|
||||
## Combining
|
||||
|
||||
Modulation words return strings, so they compose naturally with the rest of the language. Use them anywhere a parameter value is expected.
|
||||
|
||||
```forth
|
||||
saw s
|
||||
saw snd
|
||||
200 4000 4 lfo lpf
|
||||
0.3 0.7 8 tlfo pan
|
||||
0 1 0.1 0.7 0.5 0 8 env gain
|
||||
0 1 0.01 0.1 ead gain
|
||||
.
|
||||
```
|
||||
|
||||
@@ -57,29 +57,15 @@ The `ftype` parameter sets the filter slope (rolloff steepness).
|
||||
saw 800 lpf 3 ftype . ( 48 dB/oct lowpass )
|
||||
```
|
||||
|
||||
## Filter Envelope
|
||||
## Filter Envelope Modulation
|
||||
|
||||
Filters can be modulated by an ADSR envelope. The envelope multiplies the base cutoff:
|
||||
|
||||
```
|
||||
final_cutoff = lpf + (lpe × envelope × lpf)
|
||||
```
|
||||
|
||||
When the envelope is at 1.0 and `lpe` is 1.0, the cutoff doubles. When the envelope is at 0, the cutoff equals `lpf`.
|
||||
Use the `env` word to apply a DAHDSR envelope to any filter cutoff:
|
||||
|
||||
```forth
|
||||
saw 200 lpf 2 lpe 0.01 lpa 0.3 lpd . ( cutoff sweeps from 600 Hz down to 200 Hz )
|
||||
saw 200 8000 0.01 0.3 0.5 0.3 env lpf . ( cutoff sweeps from 200 to 8000 Hz )
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `lpe` | Envelope depth (multiplier, 1.0 = double cutoff at peak) |
|
||||
| `lpa` | Attack time in seconds |
|
||||
| `lpd` | Decay time in seconds |
|
||||
| `lps` | Sustain level (0-1) |
|
||||
| `lpr` | Release time in seconds |
|
||||
|
||||
The same pattern works for highpass (`hpe`, `hpa`, etc.) and bandpass (`bpe`, `bpa`, etc.).
|
||||
The same works for highpass and bandpass: `env hpf`, `env bpf`.
|
||||
|
||||
## Ladder Filters
|
||||
|
||||
@@ -100,7 +86,7 @@ saw 1000 lbpf 0.8 lbpq . ( ladder bandpass )
|
||||
| `lbpf` | Hz | Ladder bandpass cutoff |
|
||||
| `lbpq` | 0-1 | Ladder bandpass resonance |
|
||||
|
||||
Ladder filters share the lowpass envelope parameters (`lpe`, `lpa`, etc.).
|
||||
Ladder filter cutoffs can also be modulated with `env`, `lfo`, `slide`, etc.
|
||||
|
||||
## EQ
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Cagire includes an audio engine called `Doux`. No external software is needed to
|
||||
When you write a Forth script and emit (`.`), the script produces a command string. This command travels to the audio engine, which interprets it and creates a voice. The voice plays until its envelope finishes or until it is killed by another voice. You can also spawn infinite voices, but you will need to manage their lifecycle manually, otherwise they will never stop.
|
||||
|
||||
```forth
|
||||
saw s c4 note 0.8 gain 0.3 verb .
|
||||
saw snd c4 note 0.8 gain 0.3 verb .
|
||||
```
|
||||
|
||||
## Voices
|
||||
@@ -24,7 +24,7 @@ Press `r` on the Engine page to reset the peak counter.
|
||||
After selecting a sound source, you add parameters. Each parameter word takes a value from the stack and stores it in the command register:
|
||||
|
||||
```forth
|
||||
saw s
|
||||
saw snd
|
||||
c4 note ;; pitch
|
||||
0.5 gain ;; volume
|
||||
0.1 attack ;; envelope attack time
|
||||
@@ -35,6 +35,41 @@ saw s
|
||||
|
||||
Parameters can appear in any order. They accumulate until you emit. You can clear the register using the `clear` word.
|
||||
|
||||
## Global Parameters
|
||||
|
||||
Use `all` to apply parameters globally. Global parameters persist across all patterns and steps until cleared with `noall`. They work both prospectively (before sounds) and retroactively (after sounds):
|
||||
|
||||
```forth
|
||||
;; Prospective: set params before emitting
|
||||
500 lpf 0.5 verb all
|
||||
kick snd 60 note . ;; gets lpf=500 verb=0.5
|
||||
hat snd 70 note . ;; gets lpf=500 verb=0.5
|
||||
```
|
||||
|
||||
```forth
|
||||
;; Retroactive: patch already-emitted sounds
|
||||
kick snd 60 note .
|
||||
hat snd 70 note .
|
||||
500 lpf 0.5 verb all ;; both outputs get lpf and verb
|
||||
```
|
||||
|
||||
Per-sound parameters override global ones:
|
||||
|
||||
```forth
|
||||
500 lpf all
|
||||
kick snd 2000 lpf . ;; lpf=2000 (per-sound wins)
|
||||
hat snd . ;; lpf=500 (global)
|
||||
```
|
||||
|
||||
Use `noall` to clear global parameters:
|
||||
|
||||
```forth
|
||||
500 lpf all
|
||||
kick snd . ;; gets lpf
|
||||
noall
|
||||
hat snd . ;; no lpf
|
||||
```
|
||||
|
||||
## Controlling Existing Voices
|
||||
|
||||
You can emit without a sound name. In this case, no new voice is created. Instead, the parameters are sent to control an existing voice. Use `voice` with an ID to target a specific voice:
|
||||
|
||||
@@ -16,34 +16,6 @@ saw 5 vib 0.5 vibmod . ( 5 Hz, 0.5 semitone depth )
|
||||
| `vibmod` | semitones | Modulation depth |
|
||||
| `vibshape` | shape | LFO waveform (sine, tri, saw, square) |
|
||||
|
||||
## Pitch Envelope
|
||||
|
||||
The pitch envelope applies an ADSR to the oscillator frequency.
|
||||
|
||||
```forth
|
||||
sine 100 freq 24 penv 0.001 patt 0.1 pdec .
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `penv` | Envelope depth in semitones |
|
||||
| `patt` | Attack time in seconds |
|
||||
| `pdec` | Decay time in seconds |
|
||||
| `psus` | Sustain level (0-1) |
|
||||
| `prel` | Release time in seconds |
|
||||
|
||||
## Glide
|
||||
|
||||
Glide interpolates between pitch changes over time.
|
||||
|
||||
```forth
|
||||
saw c4 0.1 glide . ( 100ms glide )
|
||||
```
|
||||
|
||||
| Parameter | Range | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `glide` | seconds | Glide time |
|
||||
|
||||
## FM Synthesis
|
||||
|
||||
FM modulates the carrier frequency with a modulator oscillator.
|
||||
@@ -58,7 +30,7 @@ sine 440 freq 2 fm 2 fmh . ( modulator at 2× carrier frequency )
|
||||
| `fmh` | ratio | Harmonic ratio (modulator / carrier) |
|
||||
| `fmshape` | shape | Modulator waveform |
|
||||
|
||||
FM has its own envelope (`fme`, `fma`, `fmd`, `fms`, `fmr`).
|
||||
Use `env` to apply a DAHDSR envelope to FM depth: `0 5 0.01 0.1 0.3 0.5 env fm`.
|
||||
|
||||
## Amplitude Modulation
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user