Merge branch 'release' of github.com:skykooler/Lightningbeam into release

This commit is contained in:
Skyler Lehmkuhl 2026-06-02 13:11:15 -04:00
commit 73d5d554c7
941 changed files with 225641 additions and 4333 deletions

425
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,425 @@
name: Build & Package
on:
workflow_dispatch:
push:
branches:
- release
jobs:
build:
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- platform: ubuntu-22.04
target: ''
artifact-name: linux-x86_64
- platform: macos-latest
target: ''
artifact-name: macos-arm64
- platform: macos-latest
target: x86_64-apple-darwin
artifact-name: macos-x86_64
- platform: windows-latest
target: ''
artifact-name: windows-x86_64
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Verify submodules
shell: bash
run: |
echo "=== Submodule status ==="
git submodule status --recursive
echo "=== NeuralAudio deps ==="
ls -la vendor/NeuralAudio/deps/
ls vendor/NeuralAudio/deps/RTNeural/CMakeLists.txt
ls vendor/NeuralAudio/deps/math_approx/CMakeLists.txt
- name: Clone egui fork
run: git clone --depth 1 -b ibus-wayland-fix https://git.skyler.io/skyler/egui.git ../egui-fork
env:
GIT_LFS_SKIP_SMUDGE: "1"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target != '' && matrix.target || '' }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './lightningbeam-ui -> target'
key: ${{ matrix.artifact-name }}-v2
# ── Linux dependencies ──
- name: Install dependencies (Linux)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential pkg-config clang nasm cmake \
libasound2-dev libwayland-dev libwayland-cursor0 \
libx11-dev libxkbcommon-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libxdo-dev libglib2.0-dev libgtk-3-dev libvulkan-dev \
yasm libx264-dev libx265-dev libvpx-dev libmp3lame-dev libopus-dev \
libpulse-dev squashfs-tools dpkg rpm
- name: Install cargo packaging tools (Linux)
if: matrix.platform == 'ubuntu-22.04'
uses: taiki-e/install-action@v2
with:
tool: cargo-deb,cargo-generate-rpm
# ── macOS dependencies ──
- name: Install dependencies (macOS)
if: startsWith(matrix.platform, 'macos')
run: brew install nasm cmake create-dmg
# ── Windows dependencies ──
- name: Install dependencies (Windows)
if: matrix.platform == 'windows-latest'
shell: pwsh
run: choco install cmake llvm --installargs 'ADD_CMAKE_TO_PATH=System' -y
# ── Common build steps ──
- name: Extract version
id: version
shell: bash
run: |
VERSION=$(grep '^version' lightningbeam-ui/lightningbeam-editor/Cargo.toml | sed 's/.*"\(.*\)"/\1/')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Enable FFmpeg build from source (Linux/macOS)
if: matrix.platform != 'windows-latest'
shell: bash
run: |
sed -i.bak 's/ffmpeg-next = { version = "8.0", features = \["static"\] }/ffmpeg-next = { version = "8.0", features = ["build", "static"] }/' lightningbeam-ui/lightningbeam-editor/Cargo.toml
- name: Setup FFmpeg (Windows)
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
# Download FFmpeg 8.0 shared+dev build (headers + import libs + DLLs)
$url = "https://github.com/GyanD/codexffmpeg/releases/download/8.0/ffmpeg-8.0-full_build-shared.7z"
Invoke-WebRequest -Uri $url -OutFile ffmpeg.7z
7z x ffmpeg.7z -offmpeg-win
$ffmpegDir = (Get-ChildItem -Path ffmpeg-win -Directory | Select-Object -First 1).FullName
echo "FFMPEG_DIR=$ffmpegDir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# LLVM/libclang for bindgen
echo "LIBCLANG_PATH=C:\Program Files\LLVM\bin" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Setup icons
shell: bash
run: |
mkdir -p lightningbeam-ui/lightningbeam-editor/assets/icons
cp -f src-tauri/icons/32x32.png lightningbeam-ui/lightningbeam-editor/assets/icons/
cp -f src-tauri/icons/128x128.png lightningbeam-ui/lightningbeam-editor/assets/icons/
cp -f src-tauri/icons/icon.png lightningbeam-ui/lightningbeam-editor/assets/icons/256x256.png
- name: Stage factory presets
shell: bash
run: |
mkdir -p lightningbeam-ui/lightningbeam-editor/assets/presets
cp -r src/assets/instruments/* lightningbeam-ui/lightningbeam-editor/assets/presets/
# Remove empty category dirs and README
find lightningbeam-ui/lightningbeam-editor/assets/presets -maxdepth 1 -type d -empty -delete
rm -f lightningbeam-ui/lightningbeam-editor/assets/presets/README.md
- name: Inject preset entries into RPM metadata (Linux)
if: matrix.platform == 'ubuntu-22.04'
shell: bash
run: |
cd lightningbeam-ui
find lightningbeam-editor/assets/presets -type f | sort | while read -r f; do
rel="${f#lightningbeam-editor/}"
dest="/usr/share/lightningbeam-editor/presets/${f#lightningbeam-editor/assets/presets/}"
printf '\n[[package.metadata.generate-rpm.assets]]\nsource = "%s"\ndest = "%s"\nmode = "644"\n' "$rel" "$dest" >> lightningbeam-editor/Cargo.toml
done
- name: Fix FFmpeg cross-compile OS detection (macOS x86_64)
if: matrix.target == 'x86_64-apple-darwin'
shell: bash
run: |
# ffmpeg-sys-next passes --target-os=macos to FFmpeg configure, but FFmpeg
# expects --target-os=darwin. Fetch crates, then patch the build script.
cd lightningbeam-ui
cargo fetch
BUILDRS=$(find $HOME/.cargo/registry/src -path '*/ffmpeg-sys-next-*/build.rs' | head -1)
echo "Patching $BUILDRS to fix macos -> darwin mapping"
# Add "macos" => "darwin" to the match in get_ffmpeg_target_os()
sed -i.bak 's/"ios" => "darwin"/"ios" | "macos" => "darwin"/' "$BUILDRS"
grep -A4 'fn get_ffmpeg_target_os' "$BUILDRS"
echo "MACOSX_DEPLOYMENT_TARGET=11.0" >> "$GITHUB_ENV"
- name: Build release binary
shell: bash
env:
FFMPEG_STATIC: "1"
run: |
cd lightningbeam-ui
TARGET_FLAG=""
if [ -n "${{ matrix.target }}" ]; then
TARGET_FLAG="--target ${{ matrix.target }}"
fi
cargo build --release --bin lightningbeam-editor $TARGET_FLAG
- name: Copy cross-compiled binary to release dir
if: matrix.target != ''
shell: bash
run: |
mkdir -p lightningbeam-ui/target/release
cp lightningbeam-ui/target/${{ matrix.target }}/release/lightningbeam-editor lightningbeam-ui/target/release/
# ── Stage presets next to binary for packaging ──
- name: Stage presets in target dir
shell: bash
run: |
mkdir -p lightningbeam-ui/target/release/presets
cp -r lightningbeam-ui/lightningbeam-editor/assets/presets/* lightningbeam-ui/target/release/presets/
# ══════════════════════════════════════════════
# Linux Packaging
# ══════════════════════════════════════════════
- name: Build .deb package
if: matrix.platform == 'ubuntu-22.04'
shell: bash
run: |
cd lightningbeam-ui
cargo deb -p lightningbeam-editor --no-build --no-strip
# Inject factory presets into .deb (cargo-deb doesn't handle recursive dirs well)
DEB=$(ls target/debian/*.deb | head -1)
WORK=$(mktemp -d)
dpkg-deb -R "$DEB" "$WORK"
mkdir -p "$WORK/usr/share/lightningbeam-editor/presets"
cp -r lightningbeam-editor/assets/presets/* "$WORK/usr/share/lightningbeam-editor/presets/"
dpkg-deb -b "$WORK" "$DEB"
rm -rf "$WORK"
- name: Build .rpm package
if: matrix.platform == 'ubuntu-22.04'
shell: bash
run: |
cd lightningbeam-ui
cargo generate-rpm -p lightningbeam-editor
- name: Build AppImage
if: matrix.platform == 'ubuntu-22.04'
shell: bash
run: |
cd lightningbeam-ui
VERSION="${{ steps.version.outputs.version }}"
APPDIR=/tmp/AppDir
ASSETS=lightningbeam-editor/assets
rm -rf "$APPDIR"
mkdir -p "$APPDIR/usr/bin"
mkdir -p "$APPDIR/usr/bin/presets"
mkdir -p "$APPDIR/usr/share/applications"
mkdir -p "$APPDIR/usr/share/metainfo"
mkdir -p "$APPDIR/usr/share/icons/hicolor/32x32/apps"
mkdir -p "$APPDIR/usr/share/icons/hicolor/128x128/apps"
mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps"
cp target/release/lightningbeam-editor "$APPDIR/usr/bin/"
cp -r lightningbeam-editor/assets/presets/* "$APPDIR/usr/bin/presets/"
cp "$ASSETS/com.lightningbeam.editor.desktop" "$APPDIR/usr/share/applications/"
cp "$ASSETS/com.lightningbeam.editor.appdata.xml" "$APPDIR/usr/share/metainfo/"
cp "$ASSETS/icons/32x32.png" "$APPDIR/usr/share/icons/hicolor/32x32/apps/lightningbeam-editor.png"
cp "$ASSETS/icons/128x128.png" "$APPDIR/usr/share/icons/hicolor/128x128/apps/lightningbeam-editor.png"
cp "$ASSETS/icons/256x256.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/lightningbeam-editor.png"
ln -sf usr/share/icons/hicolor/256x256/apps/lightningbeam-editor.png "$APPDIR/lightningbeam-editor.png"
ln -sf usr/share/applications/com.lightningbeam.editor.desktop "$APPDIR/lightningbeam-editor.desktop"
printf '#!/bin/bash\nSELF=$(readlink -f "$0")\nHERE=${SELF%%/*}\nexport XDG_DATA_DIRS="${HERE}/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"\nexec "${HERE}/usr/bin/lightningbeam-editor" "$@"\n' > "$APPDIR/AppRun"
chmod +x "$APPDIR/AppRun"
# Download AppImage runtime
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/runtime-x86_64" \
-O /tmp/appimage-runtime
chmod +x /tmp/appimage-runtime
# Build squashfs and concatenate
mksquashfs "$APPDIR" /tmp/appimage.squashfs \
-root-owned -noappend -no-exports -no-xattrs \
-comp gzip -b 131072
cat /tmp/appimage-runtime /tmp/appimage.squashfs \
> "Lightningbeam_Editor-${VERSION}-x86_64.AppImage"
chmod +x "Lightningbeam_Editor-${VERSION}-x86_64.AppImage"
- name: Upload .deb
if: matrix.platform == 'ubuntu-22.04'
uses: actions/upload-artifact@v4
with:
name: deb-package
path: lightningbeam-ui/target/debian/*.deb
if-no-files-found: error
- name: Upload .rpm
if: matrix.platform == 'ubuntu-22.04'
uses: actions/upload-artifact@v4
with:
name: rpm-package
path: lightningbeam-ui/target/generate-rpm/*.rpm
if-no-files-found: error
- name: Upload AppImage
if: matrix.platform == 'ubuntu-22.04'
uses: actions/upload-artifact@v4
with:
name: appimage
path: lightningbeam-ui/Lightningbeam_Editor-*.AppImage
if-no-files-found: error
# ══════════════════════════════════════════════
# macOS Packaging
# ══════════════════════════════════════════════
- name: Create macOS .app bundle
if: startsWith(matrix.platform, 'macos')
shell: bash
run: |
VERSION="${{ steps.version.outputs.version }}"
APP="Lightningbeam Editor.app"
mkdir -p "$APP/Contents/MacOS"
mkdir -p "$APP/Contents/Resources/presets"
cp lightningbeam-ui/target/release/lightningbeam-editor "$APP/Contents/MacOS/"
cp src-tauri/icons/icon.icns "$APP/Contents/Resources/lightningbeam-editor.icns"
cp -r lightningbeam-ui/lightningbeam-editor/assets/presets/* "$APP/Contents/Resources/presets/"
cat > "$APP/Contents/Info.plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Lightningbeam Editor</string>
<key>CFBundleDisplayName</key>
<string>Lightningbeam Editor</string>
<key>CFBundleIdentifier</key>
<string>com.lightningbeam.editor</string>
<key>CFBundleVersion</key>
<string>${VERSION}</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>lightningbeam-editor</string>
<key>CFBundleIconFile</key>
<string>lightningbeam-editor</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
EOF
- name: Create macOS .dmg
if: startsWith(matrix.platform, 'macos')
shell: bash
run: |
VERSION="${{ steps.version.outputs.version }}"
ARCH="${{ matrix.target == 'x86_64-apple-darwin' && 'x86_64' || 'arm64' }}"
DMG_NAME="Lightningbeam_Editor-${VERSION}-macOS-${ARCH}.dmg"
create-dmg \
--volname "Lightningbeam Editor" \
--window-pos 200 120 \
--window-size 600 400 \
--icon-size 100 \
--icon "Lightningbeam Editor.app" 175 190 \
--app-drop-link 425 190 \
"$DMG_NAME" \
"Lightningbeam Editor.app" || true
# create-dmg returns non-zero if codesigning is skipped, but the .dmg is still valid
- name: Upload .dmg
if: startsWith(matrix.platform, 'macos')
uses: actions/upload-artifact@v4
with:
name: dmg-${{ matrix.artifact-name }}
path: Lightningbeam_Editor-*.dmg
if-no-files-found: error
# ══════════════════════════════════════════════
# Windows Packaging
# ══════════════════════════════════════════════
- name: Create Windows .zip
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
$VERSION = "${{ steps.version.outputs.version }}"
$DIST = "Lightningbeam_Editor-${VERSION}-Windows-x86_64"
New-Item -ItemType Directory -Force -Path $DIST
Copy-Item "lightningbeam-ui/target/release/lightningbeam-editor.exe" "$DIST/"
Copy-Item -Recurse "lightningbeam-ui/target/release/presets" "$DIST/presets"
# Bundle FFmpeg DLLs (shared build)
Copy-Item "$env:FFMPEG_DIR\bin\*.dll" "$DIST/"
Compress-Archive -Path $DIST -DestinationPath "${DIST}.zip"
- name: Upload .zip
if: matrix.platform == 'windows-latest'
uses: actions/upload-artifact@v4
with:
name: windows-zip
path: Lightningbeam_Editor-*.zip
if-no-files-found: error
release:
needs: build
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
lightningbeam-ui/lightningbeam-editor/Cargo.toml
Changelog.md
- name: Extract version
id: version
run: |
VERSION=$(grep '^version' lightningbeam-ui/lightningbeam-editor/Cargo.toml | sed 's/.*"\(.*\)"/\1/')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Extract release notes
id: notes
uses: sean0x42/markdown-extract@v2.1.0
with:
pattern: "${{ steps.version.outputs.version }}:"
file: Changelog.md
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: List artifacts
run: ls -lhR dist/
- name: Create draft release
uses: softprops/action-gh-release@v2
with:
tag_name: "v${{ steps.version.outputs.version }}"
name: "Lightningbeam v${{ steps.version.outputs.version }}"
body: ${{ steps.notes.outputs.markdown }}
draft: true
prerelease: true
files: dist/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,150 +0,0 @@
name: 'publish'
on:
workflow_dispatch:
push:
branches:
- release
jobs:
extract-changelog:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set version for changelog extraction
shell: bash
run: |
# Read the version from src-tauri/tauri.conf.json
VERSION=$(jq -r '.version' src-tauri/tauri.conf.json)
# Set the version in the environment variable
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Extract release notes from Changelog.md
id: changelog
uses: sean0x42/markdown-extract@v2.1.0
with:
pattern: "${{ env.VERSION }}:" # Look for the version header (e.g., # 0.6.15-alpha:)
file: Changelog.md
- name: Set markdown output
id: set-markdown-output
run: |
echo 'RELEASE_NOTES<<EOF' >> $GITHUB_OUTPUT
echo "${{ steps.changelog.outputs.markdown }}" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
publish-tauri:
needs: extract-changelog
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest' # for Arm based macs (M1 and above).
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest' # for Intel based macs.
args: '--target x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Debug the extracted release notes
run: |
echo "Extracted Release Notes: ${{ needs.extract-changelog.outputs.RELEASE_NOTES }}"
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above.
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install jq on Windows
if: matrix.platform == 'windows-latest'
run: |
choco install jq
- name: Set version for all platforms
shell: bash
run: |
# Read the version from src-tauri/tauri.conf.json
VERSION=$(jq -r '.version' src-tauri/tauri.conf.json)
# Set the version in the environment variable
echo "VERSION=$VERSION" >> $GITHUB_ENV
if: matrix.platform != 'windows-latest'
- name: Set version for Windows build
if: matrix.platform == 'windows-latest' # Only run on Windows
shell: pwsh # Use PowerShell on Windows runners
run: |
# Read the version from src-tauri/tauri.conf.json
$tauriConf = Get-Content src-tauri/tauri.conf.json | ConvertFrom-Json
$VERSION = $tauriConf.version
# Replace '-alpha' with '-0' and '-beta' with '-1' for Windows version
if ($VERSION -match "-alpha") {
$WINDOWS_VERSION = $VERSION -replace "-alpha", "-1"
} elseif ($VERSION -match "-beta") {
$WINDOWS_VERSION = $VERSION -replace "-beta", "-2"
} else {
$WINDOWS_VERSION = $VERSION
}
Copy-Item src-tauri/tauri.conf.json -Destination src-tauri/tauri.windows.conf.json
# Modify the version in tauri.windows.conf.json
(Get-Content src-tauri/tauri.windows.conf.json) | ForEach-Object {
$_ -replace '"version": ".*"', ('"version": "' + $WINDOWS_VERSION + '"')
} | Set-Content src-tauri/tauri.windows.conf.json
echo "VERSION=$VERSION" >> $env:GITHUB_ENV
- name: Print contents of tauri.windows.conf.json (Windows)
if: matrix.platform == 'windows-latest' # Only run on Windows
shell: pwsh
run: |
Write-Host "Contents of src-tauri/tauri.windows.conf.json:"
Get-Content src-tauri/tauri.windows.conf.json
- name: setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9.1.2
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'pnpm' # Set this to npm, yarn or pnpm.
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable # Set this to dtolnay/rust-toolchain@nightly
with:
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
- name: install frontend dependencies
# If you don't have `beforeBuildCommand` configured you may want to build your frontend here too.
run: pnpm install # change this to npm or pnpm depending on which one you use.
- name: Create Release with Tauri Action
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_NOTES: ${{ needs.extract-changelog.outputs.RELEASE_NOTES }}
with:
tagName: "app-v${{ env.VERSION }}" # Use the original version tag for the release
releaseName: "Lightningbeam v${{ env.VERSION }}"
releaseBody: "${{ needs.extract-changelog.outputs.RELEASE_NOTES }}"
releaseDraft: true # Set to true if you want the release to be a draft
prerelease: true
args: ${{ matrix.args }}

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Git
.gitignore
# Build
src-tauri/gen
src-tauri/target
lightningbeam-core/target
daw-backend/target
target/
# Packaging build artifacts
packaging/output/
packaging/AppDir/
packaging/squashfs-root/
# Wrapper script (generated, not needed with static FFmpeg)
lightningbeam-ui/lightningbeam-editor/debian/

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "vendor/NeuralAudio"]
path = vendor/NeuralAudio
url = https://github.com/mikeoliphant/NeuralAudio.git

538
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,538 @@
# Lightningbeam Architecture
This document provides a comprehensive overview of Lightningbeam's architecture, design decisions, and component interactions.
## Table of Contents
- [System Overview](#system-overview)
- [Technology Stack](#technology-stack)
- [Component Architecture](#component-architecture)
- [Data Flow](#data-flow)
- [Rendering Pipeline](#rendering-pipeline)
- [Audio Architecture](#audio-architecture)
- [Key Design Decisions](#key-design-decisions)
- [Directory Structure](#directory-structure)
## System Overview
Lightningbeam is a 2D multimedia editor combining vector animation, audio production, and video editing. The application is built as a pure Rust desktop application using immediate-mode GUI (egui) with GPU-accelerated vector rendering (Vello).
### High-Level Architecture
```
┌────────────────────────────────────────────────────────────┐
│ Lightningbeam Editor │
│ (egui UI) │
├────────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Stage │ │ Timeline │ │ Asset │ │ Info │ │
│ │ Pane │ │ Pane │ │ Library │ │ Panel │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Lightningbeam Core (Data Model) │ │
│ │ Document, Layers, Clips, Actions, Undo/Redo │ │
│ └──────────────────────────────────────────────────────┘ │
├────────────────────────────────────────────────────────────┤
│ Rendering & Audio │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Vello + wgpu │ │ daw-backend │ │
│ │ (GPU Rendering) │ │ (Audio Engine) │ │
│ └──────────────────┘ └──────────────────┘ │
└────────────────────────────────────────────────────────────┘
↓ ↓
┌─────────┐ ┌─────────┐
│ GPU │ │ cpal │
│ (Vulkan │ │ (Audio │
│ /Metal) │ │ I/O) │
└─────────┘ └─────────┘
```
### Migration from Tauri/JavaScript
Lightningbeam is undergoing a rewrite from a Tauri/JavaScript prototype to pure Rust. The original architecture hit IPC bandwidth limitations when streaming decoded video frames. The new Rust UI eliminates this bottleneck by handling all rendering natively.
**Current Status**: Active development on the `rust-ui` branch. Core UI, tools, and undo system are implemented. Audio integration in progress.
## Technology Stack
### UI Framework
- **egui 0.33.3**: Immediate-mode GUI framework
- **eframe 0.33.3**: Application framework wrapping egui
- **winit 0.30**: Cross-platform windowing
### GPU Rendering
- **Vello (git main)**: GPU-accelerated 2D vector graphics using compute shaders
- **wgpu 27**: Low-level GPU API (Vulkan/Metal backend)
- **kurbo 0.12**: 2D curve and shape primitives
- **peniko 0.5**: Color and brush definitions
### Audio Engine
- **daw-backend**: Custom real-time audio engine
- **cpal 0.15**: Cross-platform audio I/O
- **symphonia 0.5**: Audio decoding (MP3, FLAC, WAV, Ogg, etc.)
- **rtrb 0.3**: Lock-free ringbuffers for audio thread communication
- **dasp**: Audio graph processing
### Video
- **FFmpeg**: Video encoding/decoding (via ffmpeg-next)
### Serialization
- **serde**: Document serialization
- **serde_json**: JSON format
## Component Architecture
### 1. Lightningbeam Core (`lightningbeam-core/`)
The core crate contains the data model and business logic, independent of UI framework.
**Key Types:**
```rust
Document {
canvas_size: (u32, u32),
layers: Vec<Layer>,
undo_stack: Vec<Box<dyn Action>>,
redo_stack: Vec<Box<dyn Action>>,
}
Layer (enum) {
VectorLayer { clips: Vec<VectorClip>, ... },
AudioLayer { clips: Vec<AudioClip>, ... },
VideoLayer { clips: Vec<VideoClip>, ... },
}
ClipInstance {
clip_id: Uuid, // Reference to clip definition
start_time: f64, // Timeline position
duration: f64,
trim_start: f64,
trim_end: f64,
}
```
**Responsibilities:**
- Document structure and state
- Clip and layer management
- Action system (undo/redo)
- Tool definitions
- Animation data and keyframes
### 2. Lightningbeam Editor (`lightningbeam-editor/`)
The editor application implements the UI and user interactions.
**Main Entry Point:** `src/main.rs`
- Initializes eframe application
- Sets up window, GPU context, and audio system
- Runs main event loop
**Panes** (`src/panes/`):
Each pane is a self-contained UI component:
- `stage.rs` (214KB): Main canvas for drawing, transform tools, GPU rendering
- `timeline.rs` (84KB): Multi-track timeline with clip editing
- `asset_library.rs` (70KB): Asset browser with drag-and-drop
- `infopanel.rs` (31KB): Context-sensitive property editor
- `virtual_piano.rs` (31KB): MIDI keyboard input
- `toolbar.rs` (9KB): Tool palette
**Pane System:**
```rust
pub enum PaneInstance {
Stage(Stage),
Timeline(Timeline),
AssetLibrary(AssetLibrary),
// ... other panes
}
impl PaneInstance {
pub fn render(&mut self, ui: &mut Ui, shared_state: &mut SharedPaneState) {
match self {
PaneInstance::Stage(stage) => stage.render(ui, shared_state),
// ... dispatch to specific pane
}
}
}
```
**SharedPaneState:**
Facilitates communication between panes:
```rust
pub struct SharedPaneState {
pub document: Document,
pub selected_tool: Tool,
pub pending_actions: Vec<Box<dyn Action>>,
pub audio_system: AudioSystem,
// ... other shared state
}
```
### 3. DAW Backend (`daw-backend/`)
Standalone audio engine crate with real-time audio processing.
**Architecture:**
```
UI Thread Audio Thread (real-time)
│ │
│ Commands (rtrb queue) │
├──────────────────────────────>│
│ │
│ State Updates │
<──────────────────────────────┤
│ │
┌───────────────┐
│ Audio Engine │
│ process() │
└───────────────┘
┌───────────────┐
│ Track Mix │
└───────────────┘
┌───────────────┐
│ cpal Output │
└───────────────┘
```
**Key Components:**
- **Engine** (`audio/engine.rs`): Main audio callback, runs on real-time thread
- **Project** (`audio/project.rs`): Top-level audio state
- **Track** (`audio/track.rs`): Individual audio tracks with effects chains
- **Effects**: Reverb, delay, EQ, compressor, distortion, etc.
- **Synthesizers**: Oscillator, FM synth, wavetable, sampler
**Lock-Free Design:**
The audio thread never blocks. UI sends commands via lock-free ringbuffers (rtrb), audio thread processes them between buffer callbacks.
## Data Flow
### Document Editing Flow
```
User Input (mouse/keyboard)
egui Event Handlers (in pane.render())
Create Action (implements Action trait)
Add to SharedPaneState.pending_actions
After all panes render: execute actions
Action.apply(&mut document)
Push to undo_stack
UI re-renders with updated document
```
### Audio Playback Flow
```
UI: User clicks Play
Send PlayCommand to audio engine (via rtrb queue)
Audio thread: Receive command
Audio thread: Start playback, increment playhead
Audio callback (every ~5ms): Engine::process()
Mix tracks, apply effects, output samples
Send playhead position back to UI
UI: Update timeline playhead position
```
### GPU Rendering Flow
```
egui layout phase
Stage pane requests wgpu callback
Vello renders vector shapes to GPU texture
Custom wgpu integration composites:
- Vello output (vector graphics)
- Waveform textures (GPU-rendered audio)
- egui UI overlay
Present to screen
```
## Rendering Pipeline
### Stage Rendering
The Stage pane uses a custom wgpu callback to render directly to GPU:
```rust
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
rect,
StageCallback { /* render data */ }
));
```
**Vello Integration:**
1. Create Vello `Scene` from document shapes
2. Render scene to GPU texture using compute shaders
3. Composite with UI elements
**Waveform Rendering:**
- Audio waveforms rendered on GPU using custom WGSL shaders
- Mipmaps generated via compute shader for level-of-detail
- Uniform buffers store view parameters (zoom, offset, tint color)
**WGSL Alignment Requirements:**
WGSL has strict alignment rules. `vec4<f32>` requires 16-byte alignment:
```rust
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct WaveformParams {
view_matrix: [f32; 16], // 64 bytes
viewport_size: [f32; 2], // 8 bytes
zoom: f32, // 4 bytes
_pad1: f32, // 4 bytes padding
tint_color: [f32; 4], // 16 bytes (requires 16-byte alignment)
}
// Total: 96 bytes
```
## Audio Architecture
### Real-Time Constraints
Audio callbacks run on a dedicated real-time thread with strict timing requirements:
- Buffer size: 256 frames default (~5.8ms at 44.1kHz)
- ALSA may provide smaller buffers (64-75 frames, ~1.5ms)
- **No blocking operations allowed**: No locks, no allocations, no syscalls
### Lock-Free Communication
UI and audio thread communicate via lock-free ringbuffers (rtrb):
```rust
// UI Thread
command_sender.push(AudioCommand::Play).ok();
// Audio Thread (in process callback)
while let Ok(command) = command_receiver.pop() {
match command {
AudioCommand::Play => self.playing = true,
// ... handle other commands
}
}
```
### Audio Processing Pipeline
```
Audio Callback Invoked (every ~5ms)
Process queued commands
For each track:
- Read audio samples at playhead position
- Apply effects chain
- Mix to master output
Write samples to output buffer
Return from callback (must complete in <5ms)
```
### Optimized Debug Builds
Audio code is optimized even in debug builds to meet real-time deadlines:
```toml
[profile.dev.package.daw-backend]
opt-level = 2
[profile.dev.package.symphonia]
opt-level = 2
# ... other audio libraries
```
## Key Design Decisions
### Layer & Clip System
**Type-Specific Layers:**
Each layer type supports only its matching clip type:
- `VectorLayer``VectorClip`
- `AudioLayer``AudioClip`
- `VideoLayer``VideoClip`
**Recursive Nesting:**
Vector clips can contain internal layers of any type, enabling complex nested compositions.
**Clip vs ClipInstance:**
- **Clip**: Template/definition in asset library (the "master")
- **ClipInstance**: Placed on timeline with instance-specific properties (position, duration, trim points)
- Multiple instances can reference the same clip
- "Make Unique" operation duplicates the underlying clip
### Undo/Redo System
**Action Trait:**
```rust
pub trait Action: Send {
fn apply(&mut self, document: &mut Document);
fn undo(&mut self, document: &mut Document);
fn redo(&mut self, document: &mut Document);
}
```
All operations (drawing, editing, clip manipulation) implement this trait.
**Continuous Operations:**
Dragging sliders or scrubbing creates only one undo action when complete, not one per frame.
### Two-Phase Dispatch Pattern
Panes cannot directly mutate shared state during rendering (borrowing rules). Instead:
1. **Phase 1 (Render)**: Panes register actions
```rust
shared_state.register_action(Box::new(MyAction { ... }));
```
2. **Phase 2 (Execute)**: After all panes rendered, execute actions
```rust
for action in shared_state.pending_actions.drain(..) {
action.apply(&mut document);
undo_stack.push(action);
}
```
### Pane ID Salting
egui uses IDs to track widget state. Multiple instances of the same pane would collide without unique IDs.
**Solution**: Salt all IDs with the pane's node path:
```rust
ui.horizontal(|ui| {
ui.label("My Widget");
}).id.with(&node_path);
```
### Selection & Clipboard
- **Selection scope**: Limited to current clip/layer
- **Type-aware paste**: Content must match target type
- **Clip instance copying**: Creates reference to same underlying clip
- **Make unique**: Duplicates underlying clip for independent editing
## Directory Structure
```
lightningbeam-2/
├── lightningbeam-ui/ # Rust UI workspace
│ ├── Cargo.toml # Workspace manifest
│ ├── lightningbeam-editor/ # Main application crate
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── main.rs # Entry point, event loop
│ │ ├── app.rs # Application state
│ │ ├── panes/
│ │ │ ├── mod.rs # Pane system dispatch
│ │ │ ├── stage.rs # Main canvas
│ │ │ ├── timeline.rs # Timeline editor
│ │ │ ├── asset_library.rs
│ │ │ └── ...
│ │ ├── tools/ # Drawing and editing tools
│ │ ├── rendering/
│ │ │ ├── vello_integration.rs
│ │ │ ├── waveform_gpu.rs
│ │ │ └── shaders/
│ │ │ ├── waveform.wgsl
│ │ │ └── waveform_mipgen.wgsl
│ │ └── export/ # Export functionality
│ └── lightningbeam-core/ # Core data model crate
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ ├── document.rs # Document structure
│ ├── layer.rs # Layer types
│ ├── clip.rs # Clip types and instances
│ ├── shape.rs # Shape definitions
│ ├── action.rs # Action trait and undo/redo
│ ├── animation.rs # Keyframe animation
│ └── tools.rs # Tool definitions
├── daw-backend/ # Audio engine (standalone)
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs # Audio system initialization
│ ├── audio/
│ │ ├── engine.rs # Main audio callback
│ │ ├── track.rs # Track management
│ │ ├── project.rs # Project state
│ │ └── buffer.rs # Audio buffer utilities
│ ├── effects/ # Audio effects
│ │ ├── reverb.rs
│ │ ├── delay.rs
│ │ └── ...
│ ├── synth/ # Synthesizers
│ └── midi/ # MIDI handling
├── src-tauri/ # Legacy Tauri backend
├── src/ # Legacy JavaScript frontend
├── CONTRIBUTING.md # Contributor guide
├── ARCHITECTURE.md # This file
├── README.md # Project overview
└── docs/ # Additional documentation
├── AUDIO_SYSTEM.md
├── UI_SYSTEM.md
└── ...
```
## Performance Considerations
### GPU Rendering
- Vello uses compute shaders for efficient 2D rendering
- Waveforms pre-rendered on GPU with mipmaps for smooth zooming
- Custom wgpu integration minimizes CPU↔GPU data transfer
### Audio Processing
- Lock-free design: No blocking in audio thread
- Optimized even in debug builds (`opt-level = 2`)
- Memory-mapped file I/O for large audio files
- Zero-copy audio buffers where possible
### Memory Management
- Audio buffers pre-allocated, no allocations in audio thread
- Vello manages GPU memory automatically
- Document structure uses `Rc`/`Arc` for shared clip references
## Future Considerations
### Video Integration
Video decoding has been ported from the legacy Tauri backend. Video soundtracks become audio tracks in daw-backend, enabling full effects processing.
### File Format
The .beam file format is not yet finalized. Considerations:
- Single JSON file vs container format (e.g., ZIP)
- Embedded media vs external references
- Forward/backward compatibility strategy
### Node Editor
Primary use: Audio effects chains and modular synthesizers. Future expansion to visual effects and procedural generation is possible.
## Related Documentation
- [CONTRIBUTING.md](CONTRIBUTING.md) - Development setup and workflow
- [docs/AUDIO_SYSTEM.md](docs/AUDIO_SYSTEM.md) - Detailed audio engine documentation
- [docs/UI_SYSTEM.md](docs/UI_SYSTEM.md) - UI pane system details
- [docs/RENDERING.md](docs/RENDERING.md) - GPU rendering pipeline
- [Claude.md](Claude.md) - Comprehensive architectural reference for AI assistants

278
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,278 @@
# Contributing to Lightningbeam
Thank you for your interest in contributing to Lightningbeam! This document provides guidelines and instructions for setting up your development environment and contributing to the project.
## Table of Contents
- [Development Setup](#development-setup)
- [Building the Project](#building-the-project)
- [Project Structure](#project-structure)
- [Making Changes](#making-changes)
- [Code Style](#code-style)
- [Testing](#testing)
- [Submitting Changes](#submitting-changes)
- [Getting Help](#getting-help)
## Development Setup
### Prerequisites
- **Rust**: Install via [rustup](https://rustup.rs/) (stable toolchain)
- **System dependencies** (Linux):
- ALSA development files: `libasound2-dev`
- For Ubuntu/Debian: `sudo apt install libasound2-dev pkg-config`
- For Arch/Manjaro: `sudo pacman -S alsa-lib`
- **FFmpeg**: Required for video encoding/decoding
- Ubuntu/Debian: `sudo apt install ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev pkg-config clang`
- Arch/Manjaro: `sudo pacman -S ffmpeg`
### Clone and Build
```bash
# Clone the repository (GitHub)
git clone https://github.com/skykooler/lightningbeam.git
# Or from Gitea
git clone https://git.skyler.io/skyler/lightningbeam.git
cd lightningbeam
# Build the Rust UI editor (current focus)
cd lightningbeam-ui
cargo build
# Run the editor
cargo run
```
**Note**: The project is hosted on both GitHub and Gitea (git.skyler.io). You can use either for cloning and submitting pull requests.
## Building the Project
### Workspace Structure
The project consists of multiple Rust workspaces:
1. **lightningbeam-ui** (current focus) - Pure Rust UI application
- `lightningbeam-editor/` - Main editor application
- `lightningbeam-core/` - Core data models and business logic
2. **daw-backend** - Audio engine (standalone crate)
3. **Root workspace** (legacy) - Contains Tauri backend and benchmarks
### Build Commands
```bash
# Build the editor (from lightningbeam-ui/)
cargo build
# Build with optimizations (faster runtime)
cargo build --release
# Check just the audio backend
cargo check -p daw-backend
# Build the audio backend separately
cd ../daw-backend
cargo build
```
### Debug Builds and Audio Performance
The audio engine runs on a real-time thread with strict timing constraints (~5.8ms at 44.1kHz). To maintain performance in debug builds, the audio backend is compiled with optimizations even in debug mode:
```toml
# In lightningbeam-ui/Cargo.toml
[profile.dev.package.daw-backend]
opt-level = 2
```
This is already configured—no action needed.
### Debug Flags
Enable audio diagnostics with:
```bash
DAW_AUDIO_DEBUG=1 cargo run
```
This prints timing information, buffer sizes, and overrun warnings to help debug audio issues.
## Project Structure
```
lightningbeam-2/
├── lightningbeam-ui/ # Rust UI workspace (current)
│ ├── lightningbeam-editor/ # Main application
│ │ └── src/
│ │ ├── main.rs # Entry point
│ │ ├── panes/ # UI panes (stage, timeline, etc.)
│ │ └── tools/ # Drawing and editing tools
│ └── lightningbeam-core/ # Core data model
│ └── src/
│ ├── document.rs # Document structure
│ ├── clip.rs # Clips and instances
│ ├── action.rs # Undo/redo system
│ └── tools.rs # Tool system
├── daw-backend/ # Audio engine
│ └── src/
│ ├── lib.rs # Audio system setup
│ ├── audio/
│ │ ├── engine.rs # Audio callback
│ │ ├── track.rs # Track management
│ │ └── project.rs # Project state
│ └── effects/ # Audio effects
├── src-tauri/ # Legacy Tauri backend
└── src/ # Legacy JavaScript frontend
```
## Making Changes
### Branching Strategy
- `main` - Stable branch
- `rust-ui` - Active development branch for Rust UI rewrite
- Feature branches - Create from `rust-ui` for new features
### Before You Start
1. Check existing issues or create a new one to discuss your change
2. Make sure you're on the latest `rust-ui` branch:
```bash
git checkout rust-ui
git pull origin rust-ui
```
3. Create a feature branch:
```bash
git checkout -b feature/your-feature-name
```
## Code Style
### Rust Style
- Follow standard Rust formatting: `cargo fmt`
- Check for common issues: `cargo clippy`
- Use meaningful variable names
- Add comments for non-obvious code
- Keep functions focused and reasonably sized
### Key Patterns
#### Pane ID Salting
When implementing new panes, **always salt egui IDs** with the node path to avoid collisions when users add multiple instances of the same pane:
```rust
ui.horizontal(|ui| {
ui.label("My Widget");
}).id.with(&node_path); // Salt with node path
```
#### Splitting Borrows with `std::mem::take`
When you need to split borrows from a struct, use `std::mem::take`:
```rust
let mut clips = std::mem::take(&mut self.clips);
// Now you can borrow other fields while processing clips
```
#### Two-Phase Dispatch
Panes register handlers during render, execution happens after:
```rust
// During render
shared_state.register_action(Box::new(MyAction { ... }));
// After all panes rendered
for action in shared_state.pending_actions.drain(..) {
action.execute(&mut document);
}
```
## Testing
### Running Tests
```bash
# Run all tests
cargo test
# Test specific package
cargo test -p lightningbeam-core
cargo test -p daw-backend
# Run with output
cargo test -- --nocapture
```
### Audio Testing
Test audio functionality:
```bash
# Run with audio debug output
DAW_AUDIO_DEBUG=1 cargo run
# Check for audio dropouts or timing issues in the console output
```
## Submitting Changes
### Before Submitting
1. **Format your code**: `cargo fmt --all`
2. **Run clippy**: `cargo clippy --all-targets --all-features`
3. **Run tests**: `cargo test --all`
4. **Test manually**: Build and run the application to verify your changes work
5. **Write clear commit messages**: Describe what and why, not just what
### Commit Message Format
```
Short summary (50 chars or less)
More detailed explanation if needed. Wrap at 72 characters.
Explain the problem this commit solves and why you chose
this solution.
- Bullet points are fine
- Use present tense: "Add feature" not "Added feature"
```
### Pull Request Process
1. Push your branch to GitHub or Gitea
2. Open a pull request against `rust-ui` branch
- GitHub: https://github.com/skykooler/lightningbeam
- Gitea: https://git.skyler.io/skyler/lightningbeam
3. Provide a clear description of:
- What problem does this solve?
- How does it work?
- Any testing you've done
- Screenshots/videos if applicable (especially for UI changes)
4. Address review feedback
5. Once approved, a maintainer will merge your PR
### PR Checklist
- [ ] Code follows project style (`cargo fmt`, `cargo clippy`)
- [ ] Tests pass (`cargo test`)
- [ ] New code has appropriate tests (if applicable)
- [ ] Documentation updated (if needed)
- [ ] Commit messages are clear
- [ ] PR description explains the change
## Getting Help
- **Issues**: Check issues on [GitHub](https://github.com/skykooler/lightningbeam/issues) or [Gitea](https://git.skyler.io/skyler/lightningbeam/issues) for existing discussions
- **Documentation**: See `ARCHITECTURE.md` and `docs/` folder for technical details
- **Questions**: Open a discussion or issue with the "question" label on either platform
## Additional Resources
- [ARCHITECTURE.md](ARCHITECTURE.md) - System architecture overview
- [docs/AUDIO_SYSTEM.md](docs/AUDIO_SYSTEM.md) - Audio engine details
- [docs/UI_SYSTEM.md](docs/UI_SYSTEM.md) - UI and pane system
## License
By contributing, you agree that your contributions will be licensed under the same license as the project.

View File

@ -1,3 +1,73 @@
# 1.0.3-alpha:
Changes:
- Add gradient support to vector graphics
- Add "frames" timeline mode
- Reduce CPU usage at idle
- Allow group tracks' audio node graphs to be edited
Bugfixes:
- Support Vello CPU fallback on systems with older GPUs
# 1.0.2-alpha:
Changes:
- All vector shapes on a layer go into a unified shape rather than separate shapes
- Keyboard shortcuts are now user-configurable
- Added webcam support in video editor
- Background can now be transparent
- Video thumbnails are now displayed on the clip
- Virtual keyboard, piano roll and node editor now have a quick switcher
- Add electric guitar preset
- Layers can now be grouped
- Layers can be reordered by dragging
- Added VU meters to audio layers and mix
- Added raster image editing
- Added brush, airbrush, dodge/burn, sponge, pattern stamp, healing brush, clone stamp, blur/sharpen, magic wand and quick select tools
- Added support for MyPaint .myb brushes
- UI now uses CSS styling to support future user styles
- Added image export
Bugfixes:
- Toolbar now only shows tools that can be used on the current layer
- Fix NAM model loading
- Fix menu width and mouse following
- Export dialog now remembers the previous export filename
# 1.0.1-alpha:
Changes:
- Added real-time amp simulation via NAM
- Added beat mode to the timeline
- Changed shape drawing from making separate shapes to making shapes in the layer using a DCEL graph
- Licensed under GPLv3
- Added snapping for vector editing
- Added organ instrument and vibrato node
Bugfixes:
- Fix preset loading not updating node graph editor
- Fix stroke intersections not splitting strokes
- Fix paint bucket fill not attaching to existing strokes
# 1.0.0-alpha:
Changes:
- New native GUI built with egui + wgpu (replaces Tauri/web frontend)
- GPU-accelerated canvas with vello rendering
- MIDI input and node-based audio graph improvements
- Factory instrument presets
- Video import and high performance playback
# 0.8.1-alpha:
Changes:
- Rewrite timeline UI
- Add start screen
- Move audio engine to backend
- Add node editor for audio synthesis
- Add factory presets for instruments
- Add MIDI input support
- Add BPM handling and time signature
- Add metronome
- Add preset layouts for different tasks
- Add video import
- Add animation curves for object properties
# 0.7.14-alpha:
Changes:
- Moving frames can now be undone

674
LICENSE Normal file
View File

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

128
README.md
View File

@ -1,7 +1,127 @@
# Lightningbeam 2
# Lightningbeam
This README needs content. This is Lightningbeam rewritten with Tauri.
A free and open-source 2D multimedia editor combining vector animation, audio production, and video editing in a single application.
To test:
## Screenshots
`pnpm tauri dev`
![Animation View](screenshots/animation.png)
![Music Editing View](screenshots/music.png)
![Video Editing View](screenshots/video.png)
## Features
**Vector Animation**
- GPU-accelerated vector rendering with Vello
- Draw and animate vector shapes with keyframe-based timeline
- Non-destructive editing workflow
- Paint bucket tool for automatic fill detection
**Audio Production**
- Real-time multi-track audio recording and playback
- Node graph-based effects processing
- MIDI sequencing with synthesizers and samplers
- Comprehensive effects library (reverb, delay, EQ, compression, distortion, etc.)
- Custom audio engine with lock-free design for glitch-free playback
**Video Editing**
- Video timeline and editing with FFmpeg-based decoding
- GPU-accelerated waveform rendering with mipmaps
- Audio integration from video soundtracks
## Technical Stack
**Current Implementation (Rust UI)**
- **UI Framework:** egui (immediate-mode GUI)
- **GPU Rendering:** Vello + wgpu (Vulkan/Metal/DirectX 12)
- **Audio Engine:** Custom real-time engine (`daw-backend`)
- cpal for cross-platform audio I/O
- symphonia for audio decoding
- dasp for node graph processing
- **Video:** FFmpeg 8 for encode/decode
- **Platform:** Cross-platform (Linux, macOS, Windows)
**Legacy Implementation (Deprecated)**
- Frontend: Vanilla JavaScript
- Backend: Rust (Tauri framework)
## Project Status
Lightningbeam is under active development on the `rust-ui` branch. The project has been rewritten from a Tauri/JavaScript prototype to a pure Rust application to eliminate IPC bottlenecks and achieve better performance for real-time video and audio processing.
**Current Status:**
- ✅ Core UI panes (Stage, Timeline, Asset Library, Info Panel, Toolbar)
- ✅ Drawing tools (Select, Draw, Rectangle, Ellipse, Paint Bucket, Transform)
- ✅ Undo/redo system
- ✅ GPU-accelerated vector rendering
- ✅ Audio engine with node graph processing
- ✅ GPU waveform rendering with mipmaps
- ✅ Video decoding integration
- 🚧 Export system (in progress)
- 🚧 Node editor UI (planned)
- 🚧 Piano roll editor (planned)
## Getting Started
### Prerequisites
- Rust (stable toolchain via [rustup](https://rustup.rs/))
- System dependencies:
- **Linux:** ALSA development files, FFmpeg 8
- **macOS:** FFmpeg (via Homebrew)
- **Windows:** FFmpeg 8, Visual Studio with C++ tools
See [docs/BUILDING.md](docs/BUILDING.md) for detailed setup instructions.
### Building and Running
```bash
# Clone the repository
git clone https://github.com/skykooler/lightningbeam.git
# Or from Gitea
git clone https://git.skyler.io/skyler/lightningbeam.git
cd lightningbeam/lightningbeam-ui
# Build and run
cargo run
# Or build optimized release version
cargo build --release
```
### Documentation
- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Development setup and contribution guidelines
- **[ARCHITECTURE.md](ARCHITECTURE.md)** - System architecture overview
- **[docs/BUILDING.md](docs/BUILDING.md)** - Detailed build instructions and troubleshooting
- **[docs/AUDIO_SYSTEM.md](docs/AUDIO_SYSTEM.md)** - Audio engine architecture and development
- **[docs/UI_SYSTEM.md](docs/UI_SYSTEM.md)** - UI pane system and tool development
- **[docs/RENDERING.md](docs/RENDERING.md)** - GPU rendering pipeline and shaders
## Project History
Lightningbeam evolved from earlier multimedia editing projects I've worked on since 2010, including the FreeJam DAW. The JavaScript/Tauri prototype began in November 2023, and the Rust UI rewrite started in late 2024 to eliminate performance bottlenecks and provide a more integrated native experience.
## Goals
Create a comprehensive FOSS alternative for 2D-focused multimedia work, integrating animation, audio, and video editing in a unified workflow. Lightningbeam aims to be:
- **Fast:** GPU-accelerated rendering and real-time audio processing
- **Flexible:** Node graph-based audio routing and modular synthesis
- **Integrated:** Seamless workflow across animation, audio, and video
- **Open:** Free and open-source, built on open standards
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## License
[License information to be added]
## Links
- **GitHub:** https://github.com/skykooler/lightningbeam
- **Gitea:** https://git.skyler.io/skyler/lightningbeam

BIN
daw-backend/C2.mp3 Normal file

Binary file not shown.

2484
daw-backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

53
daw-backend/Cargo.toml Normal file
View File

@ -0,0 +1,53 @@
[package]
name = "daw-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
cpal = "0.17"
symphonia = { version = "0.5", features = ["all"] }
rtrb = "0.3"
midly = "0.5"
midir = "0.9"
serde = { version = "1.0", features = ["derive"] }
ratatui = "0.26"
crossterm = "0.27"
rand = "0.8"
base64 = "0.22"
pathdiff = "0.2"
rayon = "1.10"
# Memory-mapped I/O for audio files
memmap2 = "0.9"
# Audio export
hound = "3.5"
ffmpeg-next = "8.0" # For MP3/AAC encoding
# Node-based audio graph dependencies
dasp_graph = "0.11"
dasp_signal = "0.11"
dasp_sample = "0.11"
dasp_interpolate = "0.11"
dasp_envelope = "0.11"
dasp_ring_buffer = "0.11"
dasp_peak = "0.11"
dasp_rms = "0.11"
petgraph = "0.6"
serde_json = "1.0"
zip = "0.6"
# BeamDSP scripting engine
beamdsp = { path = "../lightningbeam-ui/beamdsp" }
# Neural Amp Modeler FFI
nam-ffi = { path = "../nam-ffi" }
[dev-dependencies]
[profile.release]
opt-level = 3
lto = true
[profile.dev]
opt-level = 1 # Faster compile times while still reasonable performance

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,72 @@
use daw_backend::load_midi_file;
fn main() {
let clip = load_midi_file("darude-sandstorm.mid", 0, 44100).unwrap();
println!("Clip duration: {:.2}s", clip.duration);
println!("Total events: {}", clip.events.len());
println!("\nEvent summary:");
let mut note_on_count = 0;
let mut note_off_count = 0;
let mut other_count = 0;
for event in &clip.events {
if event.is_note_on() {
note_on_count += 1;
} else if event.is_note_off() {
note_off_count += 1;
} else {
other_count += 1;
}
}
println!(" Note On events: {}", note_on_count);
println!(" Note Off events: {}", note_off_count);
println!(" Other events: {}", other_count);
// Show events around 28 seconds
println!("\nEvents around 28 seconds (27-29s):");
let sample_rate = 44100.0;
let start_sample = (27.0 * sample_rate) as u64;
let end_sample = (29.0 * sample_rate) as u64;
for (i, event) in clip.events.iter().enumerate() {
if event.timestamp >= start_sample && event.timestamp <= end_sample {
let time_sec = event.timestamp as f64 / sample_rate;
let event_type = if event.is_note_on() {
"NoteOn"
} else if event.is_note_off() {
"NoteOff"
} else {
"Other"
};
println!(" [{:4}] {:.3}s: {} ch={} note={} vel={}",
i, time_sec, event_type, event.channel(), event.data1, event.data2);
}
}
// Check for stuck notes - note ons without corresponding note offs
println!("\nChecking for unmatched notes...");
let mut active_notes = std::collections::HashMap::new();
for (i, event) in clip.events.iter().enumerate() {
if event.is_note_on() {
let key = (event.channel(), event.data1);
active_notes.insert(key, i);
} else if event.is_note_off() {
let key = (event.channel(), event.data1);
active_notes.remove(&key);
}
}
if !active_notes.is_empty() {
println!("Found {} notes that never got note-off events:", active_notes.len());
for ((ch, note), event_idx) in active_notes.iter().take(10) {
let time_sec = clip.events[*event_idx].timestamp as f64 / sample_rate;
println!(" Note {} on channel {} at {:.2}s (event #{})", note, ch, time_sec, event_idx);
}
} else {
println!("All notes have matching note-off events!");
}
}

View File

@ -0,0 +1,74 @@
use daw_backend::load_midi_file;
fn main() {
let clip = load_midi_file("darude-sandstorm.mid", 0, 44100).unwrap();
println!("Clip duration: {:.3}s", clip.duration);
println!("Total events: {}", clip.events.len());
// Show the last 30 events
println!("\nLast 30 events:");
let sample_rate = 44100.0;
let start_idx = clip.events.len().saturating_sub(30);
for (i, event) in clip.events.iter().enumerate().skip(start_idx) {
let time_sec = event.timestamp as f64 / sample_rate;
let event_type = if event.is_note_on() {
"NoteOn "
} else if event.is_note_off() {
"NoteOff"
} else {
"Other "
};
println!(" [{:4}] {:.3}s: {} ch={} note={:3} vel={:3}",
i, time_sec, event_type, event.channel(), event.data1, event.data2);
}
// Find notes that are still active at the end of the clip
println!("\nNotes active at end of clip ({:.3}s):", clip.duration);
let mut active_notes = std::collections::HashMap::new();
for event in &clip.events {
let time_sec = event.timestamp as f64 / sample_rate;
if event.is_note_on() {
let key = (event.channel(), event.data1);
active_notes.insert(key, time_sec);
} else if event.is_note_off() {
let key = (event.channel(), event.data1);
active_notes.remove(&key);
}
}
if !active_notes.is_empty() {
println!("Found {} notes still active after all events:", active_notes.len());
for ((ch, note), start_time) in &active_notes {
println!(" Channel {} Note {} started at {:.3}s (no note-off before clip end)",
ch, note, start_time);
}
} else {
println!("All notes are turned off by the end!");
}
// Check maximum polyphony
println!("\nAnalyzing polyphony...");
let mut max_polyphony = 0;
let mut current_notes = std::collections::HashSet::new();
for event in &clip.events {
if event.is_note_on() {
let key = (event.channel(), event.data1);
current_notes.insert(key);
max_polyphony = max_polyphony.max(current_notes.len());
} else if event.is_note_off() {
let key = (event.channel(), event.data1);
current_notes.remove(&key);
}
}
println!("Maximum simultaneous notes: {}", max_polyphony);
println!("Available synth voices: 16");
if max_polyphony > 16 {
println!("WARNING: Polyphony exceeds available voices! Voice stealing will occur.");
}
}

View File

@ -0,0 +1,272 @@
/// Automation system for parameter modulation over time
use serde::{Deserialize, Serialize};
use crate::time::Beats;
/// Unique identifier for automation lanes
pub type AutomationLaneId = u32;
/// Unique identifier for parameters that can be automated
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ParameterId {
/// Track volume
TrackVolume,
/// Track pan
TrackPan,
/// Effect parameter (effect_index, param_id)
EffectParameter(usize, u32),
/// Metatrack time stretch
TimeStretch,
/// Metatrack offset
TimeOffset,
}
/// Type of interpolation curve between automation points
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum CurveType {
/// Linear interpolation (straight line)
Linear,
/// Exponential curve (smooth acceleration)
Exponential,
/// S-curve (ease in/out)
SCurve,
/// Step (no interpolation, jump to next value)
Step,
}
/// A single automation point
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct AutomationPoint {
pub time: Beats,
pub value: f32,
pub curve: CurveType,
}
impl AutomationPoint {
pub fn new(time: Beats, value: f32, curve: CurveType) -> Self {
Self { time, value, curve }
}
}
/// An automation lane for a specific parameter
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutomationLane {
/// Unique identifier for this lane
pub id: AutomationLaneId,
/// Which parameter this lane controls
pub parameter_id: ParameterId,
/// Sorted list of automation points
points: Vec<AutomationPoint>,
/// Whether this lane is enabled
pub enabled: bool,
}
impl AutomationLane {
/// Create a new automation lane
pub fn new(id: AutomationLaneId, parameter_id: ParameterId) -> Self {
Self {
id,
parameter_id,
points: Vec::new(),
enabled: true,
}
}
/// Add an automation point, maintaining sorted order
pub fn add_point(&mut self, point: AutomationPoint) {
// Find insertion position to maintain sorted order
let pos = self.points.binary_search_by(|p| {
p.time.partial_cmp(&point.time).unwrap_or(std::cmp::Ordering::Equal)
});
match pos {
Ok(idx) => {
// Replace existing point at same time
self.points[idx] = point;
}
Err(idx) => {
// Insert at correct position
self.points.insert(idx, point);
}
}
}
pub fn remove_point_at_time(&mut self, time: Beats, tolerance: Beats) -> bool {
if let Some(idx) = self.points.iter().position(|p| (p.time - time).abs() < tolerance) {
self.points.remove(idx);
true
} else {
false
}
}
/// Remove all points
pub fn clear(&mut self) {
self.points.clear();
}
/// Get all points
pub fn points(&self) -> &[AutomationPoint] {
&self.points
}
pub fn evaluate(&self, time: Beats) -> Option<f32> {
if !self.enabled || self.points.is_empty() {
return None;
}
// Before first point
if time <= self.points[0].time {
return Some(self.points[0].value);
}
// After last point
if time >= self.points[self.points.len() - 1].time {
return Some(self.points[self.points.len() - 1].value);
}
// Find surrounding points
for i in 0..self.points.len() - 1 {
let p1 = &self.points[i];
let p2 = &self.points[i + 1];
if time >= p1.time && time <= p2.time {
return Some(interpolate(p1, p2, time));
}
}
None
}
/// Get number of points
pub fn point_count(&self) -> usize {
self.points.len()
}
}
fn interpolate(p1: &AutomationPoint, p2: &AutomationPoint, time: Beats) -> f32 {
let t = if p2.time == p1.time {
0.0f64
} else {
(time - p1.time) / (p2.time - p1.time)
} as f32;
// Apply curve
let curved_t = match p1.curve {
CurveType::Linear => t,
CurveType::Exponential => {
// Exponential curve: y = x^2
t * t
}
CurveType::SCurve => {
// Smooth S-curve using smoothstep
smoothstep(t)
}
CurveType::Step => {
// Step: hold value until next point
return p1.value;
}
};
// Linear interpolation with curved t
p1.value + (p2.value - p1.value) * curved_t
}
/// Smoothstep function for S-curve interpolation
/// Returns a smooth curve from 0 to 1
#[inline]
fn smoothstep(t: f32) -> f32 {
// Clamp to [0, 1]
let t = t.clamp(0.0, 1.0);
// 3t^2 - 2t^3
t * t * (3.0 - 2.0 * t)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_points_sorted() {
let mut lane = AutomationLane::new(0, ParameterId::TrackVolume);
lane.add_point(AutomationPoint::new(2.0, 0.5, CurveType::Linear));
lane.add_point(AutomationPoint::new(1.0, 0.3, CurveType::Linear));
lane.add_point(AutomationPoint::new(3.0, 0.8, CurveType::Linear));
assert_eq!(lane.points().len(), 3);
assert_eq!(lane.points()[0].time, 1.0);
assert_eq!(lane.points()[1].time, 2.0);
assert_eq!(lane.points()[2].time, 3.0);
}
#[test]
fn test_replace_point_at_same_time() {
let mut lane = AutomationLane::new(0, ParameterId::TrackVolume);
lane.add_point(AutomationPoint::new(1.0, 0.3, CurveType::Linear));
lane.add_point(AutomationPoint::new(1.0, 0.5, CurveType::Linear));
assert_eq!(lane.points().len(), 1);
assert_eq!(lane.points()[0].value, 0.5);
}
#[test]
fn test_linear_interpolation() {
let mut lane = AutomationLane::new(0, ParameterId::TrackVolume);
lane.add_point(AutomationPoint::new(0.0, 0.0, CurveType::Linear));
lane.add_point(AutomationPoint::new(1.0, 1.0, CurveType::Linear));
assert_eq!(lane.evaluate(0.0), Some(0.0));
assert_eq!(lane.evaluate(0.5), Some(0.5));
assert_eq!(lane.evaluate(1.0), Some(1.0));
}
#[test]
fn test_step_interpolation() {
let mut lane = AutomationLane::new(0, ParameterId::TrackVolume);
lane.add_point(AutomationPoint::new(0.0, 0.5, CurveType::Step));
lane.add_point(AutomationPoint::new(1.0, 1.0, CurveType::Step));
assert_eq!(lane.evaluate(0.0), Some(0.5));
assert_eq!(lane.evaluate(0.5), Some(0.5));
assert_eq!(lane.evaluate(0.99), Some(0.5));
assert_eq!(lane.evaluate(1.0), Some(1.0));
}
#[test]
fn test_evaluate_outside_range() {
let mut lane = AutomationLane::new(0, ParameterId::TrackVolume);
lane.add_point(AutomationPoint::new(1.0, 0.5, CurveType::Linear));
lane.add_point(AutomationPoint::new(2.0, 1.0, CurveType::Linear));
// Before first point
assert_eq!(lane.evaluate(0.0), Some(0.5));
// After last point
assert_eq!(lane.evaluate(3.0), Some(1.0));
}
#[test]
fn test_disabled_lane() {
let mut lane = AutomationLane::new(0, ParameterId::TrackVolume);
lane.add_point(AutomationPoint::new(0.0, 0.5, CurveType::Linear));
lane.enabled = false;
assert_eq!(lane.evaluate(0.0), None);
}
#[test]
fn test_remove_point() {
let mut lane = AutomationLane::new(0, ParameterId::TrackVolume);
lane.add_point(AutomationPoint::new(1.0, 0.5, CurveType::Linear));
lane.add_point(AutomationPoint::new(2.0, 0.8, CurveType::Linear));
assert!(lane.remove_point_at_time(1.0, 0.001));
assert_eq!(lane.points().len(), 1);
assert_eq!(lane.points()[0].time, 2.0);
}
}

View File

@ -0,0 +1,310 @@
/// BPM Detection using autocorrelation and onset detection
///
/// This module provides both offline analysis (for audio import)
/// and real-time streaming analysis (for the BPM detector node)
use std::collections::VecDeque;
/// Detects BPM from a complete audio buffer (offline analysis)
pub fn detect_bpm_offline(audio: &[f32], sample_rate: u32) -> Option<f32> {
if audio.is_empty() {
return None;
}
// Convert to mono if needed (already mono in our case)
// Downsample for efficiency (analyze every 4th sample for faster processing)
let downsampled: Vec<f32> = audio.iter().step_by(4).copied().collect();
let effective_sample_rate = sample_rate / 4;
// Detect onsets using energy-based method
let onsets = detect_onsets(&downsampled, effective_sample_rate);
if onsets.len() < 4 {
return None;
}
// Calculate onset strength function for autocorrelation
let onset_envelope = calculate_onset_envelope(&onsets, downsampled.len(), effective_sample_rate);
// Further downsample onset envelope for BPM analysis
// For 60-200 BPM (1-3.33 Hz), we only need ~10 Hz sample rate by Nyquist
// Use 100 Hz for good margin (100 samples per second)
let tempo_sample_rate = 100.0;
let downsample_factor = (effective_sample_rate as f32 / tempo_sample_rate) as usize;
let downsampled_envelope: Vec<f32> = onset_envelope
.iter()
.step_by(downsample_factor.max(1))
.copied()
.collect();
// Use autocorrelation to find the fundamental period
let bpm = detect_bpm_autocorrelation(&downsampled_envelope, tempo_sample_rate as u32);
bpm
}
/// Calculate an onset envelope from detected onsets
fn calculate_onset_envelope(onsets: &[usize], total_length: usize, sample_rate: u32) -> Vec<f32> {
// Create a sparse representation of onsets with exponential decay
let mut envelope = vec![0.0; total_length];
let decay_samples = (sample_rate as f32 * 0.05) as usize; // 50ms decay
for &onset in onsets {
if onset < total_length {
envelope[onset] = 1.0;
// Add exponential decay after onset
for i in 1..decay_samples.min(total_length - onset) {
let decay_value = (-3.0 * i as f32 / decay_samples as f32).exp();
envelope[onset + i] = f32::max(envelope[onset + i], decay_value);
}
}
}
envelope
}
/// Detect BPM using autocorrelation on onset envelope
fn detect_bpm_autocorrelation(onset_envelope: &[f32], sample_rate: u32) -> Option<f32> {
// BPM range: 60-200 BPM
let min_bpm = 60.0;
let max_bpm = 200.0;
let min_lag = (60.0 * sample_rate as f32 / max_bpm) as usize;
let max_lag = (60.0 * sample_rate as f32 / min_bpm) as usize;
if max_lag >= onset_envelope.len() / 2 {
return None;
}
// Calculate autocorrelation for tempo range
let mut best_lag = min_lag;
let mut best_correlation = 0.0;
for lag in min_lag..=max_lag {
let mut correlation = 0.0;
let mut count = 0;
for i in 0..(onset_envelope.len() - lag) {
correlation += onset_envelope[i] * onset_envelope[i + lag];
count += 1;
}
if count > 0 {
correlation /= count as f32;
// Bias toward faster tempos slightly (common in EDM)
let bias = 1.0 + (lag as f32 - min_lag as f32) / (max_lag - min_lag) as f32 * 0.1;
correlation /= bias;
if correlation > best_correlation {
best_correlation = correlation;
best_lag = lag;
}
}
}
// Convert best lag to BPM
let bpm = 60.0 * sample_rate as f32 / best_lag as f32;
// Check for octave errors by testing multiples
// Common ranges: 60-90 (slow), 90-140 (medium), 140-200 (fast)
let half_bpm = bpm / 2.0;
let double_bpm = bpm * 2.0;
let quad_bpm = bpm * 4.0;
// Choose the octave that falls in the most common range (100-180 BPM for EDM/pop)
let final_bpm = if quad_bpm >= 100.0 && quad_bpm <= 200.0 {
// Very slow detection, multiply by 4
quad_bpm
} else if double_bpm >= 100.0 && double_bpm <= 200.0 {
// Slow detection, multiply by 2
double_bpm
} else if bpm >= 100.0 && bpm <= 200.0 {
// Already in good range
bpm
} else if half_bpm >= 100.0 && half_bpm <= 200.0 {
// Too fast detection, divide by 2
half_bpm
} else {
// Outside ideal range, use as-is
bpm
};
// Round to nearest 0.5 BPM for cleaner values
Some((final_bpm * 2.0).round() / 2.0)
}
/// Detect onsets (beat events) in audio using energy-based method
fn detect_onsets(audio: &[f32], sample_rate: u32) -> Vec<usize> {
let mut onsets = Vec::new();
// Window size for energy calculation (~20ms)
let window_size = ((sample_rate as f32 * 0.02) as usize).max(1);
let hop_size = window_size / 2;
if audio.len() < window_size {
return onsets;
}
// Calculate energy for each window
let mut energies = Vec::new();
let mut pos = 0;
while pos + window_size <= audio.len() {
let window = &audio[pos..pos + window_size];
let energy: f32 = window.iter().map(|&s| s * s).sum();
energies.push(energy / window_size as f32); // Normalize
pos += hop_size;
}
if energies.len() < 3 {
return onsets;
}
// Calculate energy differences (onset strength)
let mut onset_strengths = Vec::new();
for i in 1..energies.len() {
let diff = (energies[i] - energies[i - 1]).max(0.0); // Only positive changes
onset_strengths.push(diff);
}
// Find threshold (adaptive)
let mean_strength: f32 = onset_strengths.iter().sum::<f32>() / onset_strengths.len() as f32;
let threshold = mean_strength * 1.5; // 1.5x mean
// Peak picking with minimum distance
let min_distance = sample_rate as usize / 10; // Minimum 100ms between onsets
let mut last_onset = 0;
for (i, &strength) in onset_strengths.iter().enumerate() {
if strength > threshold {
let sample_pos = (i + 1) * hop_size;
// Check if it's a local maximum and far enough from last onset
let is_local_max = (i == 0 || onset_strengths[i - 1] <= strength) &&
(i == onset_strengths.len() - 1 || onset_strengths[i + 1] < strength);
if is_local_max && (onsets.is_empty() || sample_pos - last_onset >= min_distance) {
onsets.push(sample_pos);
last_onset = sample_pos;
}
}
}
onsets
}
/// Real-time BPM detector for streaming audio
pub struct BpmDetectorRealtime {
sample_rate: u32,
// Circular buffer for recent audio (e.g., 10 seconds)
audio_buffer: VecDeque<f32>,
max_buffer_samples: usize,
// Current BPM estimate
current_bpm: f32,
// Update interval (samples)
samples_since_update: usize,
update_interval: usize,
// Smoothing
bpm_history: VecDeque<f32>,
history_size: usize,
}
impl BpmDetectorRealtime {
pub fn new(sample_rate: u32, buffer_duration_seconds: f32) -> Self {
let max_buffer_samples = (sample_rate as f32 * buffer_duration_seconds) as usize;
let update_interval = sample_rate as usize; // Update every 1 second
Self {
sample_rate,
audio_buffer: VecDeque::with_capacity(max_buffer_samples),
max_buffer_samples,
current_bpm: 120.0, // Default BPM
samples_since_update: 0,
update_interval,
bpm_history: VecDeque::with_capacity(8),
history_size: 8,
}
}
/// Process a chunk of audio and return current BPM estimate
pub fn process(&mut self, audio: &[f32]) -> f32 {
// Add samples to buffer
for &sample in audio {
if self.audio_buffer.len() >= self.max_buffer_samples {
self.audio_buffer.pop_front();
}
self.audio_buffer.push_back(sample);
}
self.samples_since_update += audio.len();
// Periodically re-analyze
if self.samples_since_update >= self.update_interval && self.audio_buffer.len() > self.sample_rate as usize {
self.samples_since_update = 0;
// Convert buffer to slice for analysis
let buffer_vec: Vec<f32> = self.audio_buffer.iter().copied().collect();
if let Some(detected_bpm) = detect_bpm_offline(&buffer_vec, self.sample_rate) {
// Add to history for smoothing
if self.bpm_history.len() >= self.history_size {
self.bpm_history.pop_front();
}
self.bpm_history.push_back(detected_bpm);
// Use median of recent detections for stability
let mut sorted_history: Vec<f32> = self.bpm_history.iter().copied().collect();
sorted_history.sort_by(|a, b| a.partial_cmp(b).unwrap());
self.current_bpm = sorted_history[sorted_history.len() / 2];
}
}
self.current_bpm
}
pub fn get_bpm(&self) -> f32 {
self.current_bpm
}
pub fn reset(&mut self) {
self.audio_buffer.clear();
self.bpm_history.clear();
self.samples_since_update = 0;
self.current_bpm = 120.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_120_bpm_detection() {
let sample_rate = 48000;
let bpm = 120.0;
let beat_interval = 60.0 / bpm;
let beat_samples = (sample_rate as f32 * beat_interval) as usize;
// Generate 8 beats
let mut audio = vec![0.0; beat_samples * 8];
for beat in 0..8 {
let pos = beat * beat_samples;
// Add a sharp transient at each beat
for i in 0..100 {
audio[pos + i] = (1.0 - i as f32 / 100.0) * 0.8;
}
}
let detected = detect_bpm_offline(&audio, sample_rate);
assert!(detected.is_some());
let detected_bpm = detected.unwrap();
// Allow 5% tolerance
assert!((detected_bpm - bpm).abs() / bpm < 0.05,
"Expected ~{} BPM, got {}", bpm, detected_bpm);
}
}

View File

@ -0,0 +1,149 @@
use std::sync::atomic::{AtomicUsize, Ordering};
/// Pool of reusable audio buffers for recursive group rendering
///
/// This pool allows groups to acquire temporary buffers for submixing
/// child tracks without allocating memory in the audio thread.
pub struct BufferPool {
buffers: Vec<Vec<f32>>,
available: Vec<usize>,
buffer_size: usize,
/// Tracks the number of times a buffer had to be allocated (not reused)
/// This should be zero during steady-state playback
total_allocations: AtomicUsize,
/// Peak number of buffers simultaneously in use
peak_usage: AtomicUsize,
}
impl BufferPool {
/// Create a new buffer pool
///
/// # Arguments
/// * `initial_capacity` - Number of buffers to pre-allocate
/// * `buffer_size` - Size of each buffer in samples
pub fn new(initial_capacity: usize, buffer_size: usize) -> Self {
let mut buffers = Vec::with_capacity(initial_capacity);
let mut available = Vec::with_capacity(initial_capacity);
// Pre-allocate buffers
for i in 0..initial_capacity {
buffers.push(vec![0.0; buffer_size]);
available.push(i);
}
Self {
buffers,
available,
buffer_size,
total_allocations: AtomicUsize::new(0),
peak_usage: AtomicUsize::new(0),
}
}
/// Acquire a buffer from the pool
///
/// Returns a zeroed buffer ready for use. If no buffers are available,
/// allocates a new one (though this should be avoided in the audio thread).
pub fn acquire(&mut self) -> Vec<f32> {
// Track peak usage
let current_in_use = self.buffers.len() - self.available.len();
let peak = self.peak_usage.load(Ordering::Relaxed);
if current_in_use > peak {
self.peak_usage.store(current_in_use, Ordering::Relaxed);
}
if let Some(idx) = self.available.pop() {
// Reuse an existing buffer
let mut buf = std::mem::take(&mut self.buffers[idx]);
buf.fill(0.0);
buf
} else {
// No buffers available, allocate a new one
// This should be rare if the pool is sized correctly
self.total_allocations.fetch_add(1, Ordering::Relaxed);
vec![0.0; self.buffer_size]
}
}
/// Release a buffer back to the pool
///
/// # Arguments
/// * `buffer` - The buffer to return to the pool
pub fn release(&mut self, buffer: Vec<f32>) {
// Only add to pool if it's the correct size
if buffer.len() == self.buffer_size {
let idx = self.buffers.len();
self.buffers.push(buffer);
self.available.push(idx);
}
// Otherwise, drop the buffer (wrong size, shouldn't happen normally)
}
/// Get the configured buffer size
pub fn buffer_size(&self) -> usize {
self.buffer_size
}
/// Get the number of available buffers
pub fn available_count(&self) -> usize {
self.available.len()
}
/// Get the total number of buffers in the pool
pub fn total_count(&self) -> usize {
self.buffers.len()
}
/// Get the total number of allocations that occurred (excluding pre-allocated buffers)
///
/// This should be zero during steady-state playback. If non-zero, the pool
/// should be resized to avoid allocations in the audio thread.
pub fn allocation_count(&self) -> usize {
self.total_allocations.load(Ordering::Relaxed)
}
/// Get the peak number of buffers simultaneously in use
///
/// Use this to determine the optimal initial_capacity for your workload.
pub fn peak_usage(&self) -> usize {
self.peak_usage.load(Ordering::Relaxed)
}
/// Reset allocation statistics
///
/// Useful for benchmarking steady-state performance after warmup.
pub fn reset_stats(&mut self) {
self.total_allocations.store(0, Ordering::Relaxed);
self.peak_usage.store(0, Ordering::Relaxed);
}
/// Get comprehensive pool statistics
pub fn stats(&self) -> BufferPoolStats {
BufferPoolStats {
total_buffers: self.total_count(),
available_buffers: self.available_count(),
in_use_buffers: self.total_count() - self.available_count(),
peak_usage: self.peak_usage(),
total_allocations: self.allocation_count(),
buffer_size: self.buffer_size,
}
}
}
/// Statistics about buffer pool usage
#[derive(Debug, Clone, Copy)]
pub struct BufferPoolStats {
pub total_buffers: usize,
pub available_buffers: usize,
pub in_use_buffers: usize,
pub peak_usage: usize,
pub total_allocations: usize,
pub buffer_size: usize,
}
impl Default for BufferPool {
fn default() -> Self {
// Default: 8 buffers of 4096 samples (enough for 85ms at 48kHz stereo)
Self::new(8, 4096)
}
}

View File

@ -0,0 +1,114 @@
use std::sync::Arc;
use serde::{Serialize, Deserialize};
use crate::time::{Beats, Seconds};
use crate::tempo_map::TempoMap;
/// Audio clip instance ID type
pub type AudioClipInstanceId = u32;
/// Type alias for backwards compatibility
pub type ClipId = AudioClipInstanceId;
/// Audio clip instance that references content in the AudioClipPool
///
/// ## Timing Model
/// - `internal_start` / `internal_end`: Region of the source audio to play (seconds — audio file seek positions)
/// - `external_start` / `external_duration`: Where the clip appears on the timeline (**beats**)
///
/// ## Looping
/// If `external_duration_secs(bpm)` > `internal_end - internal_start`, the clip loops.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioClipInstance {
pub id: AudioClipInstanceId,
pub audio_pool_index: usize,
/// Start position within the audio content
pub internal_start: Seconds,
/// End position within the audio content
pub internal_end: Seconds,
/// Start position on the timeline
pub external_start: Beats,
/// Duration on the timeline
pub external_duration: Beats,
/// Clip-level gain
pub gain: f32,
/// Per-instance read-ahead buffer for compressed audio streaming.
#[serde(skip)]
pub read_ahead: Option<Arc<super::disk_reader::ReadAheadBuffer>>,
}
/// Type alias for backwards compatibility
pub type Clip = AudioClipInstance;
impl AudioClipInstance {
pub fn new(
id: AudioClipInstanceId,
audio_pool_index: usize,
internal_start: Seconds,
internal_end: Seconds,
external_start: Beats,
external_duration: Beats,
) -> Self {
Self {
id,
audio_pool_index,
internal_start,
internal_end,
external_start,
external_duration,
gain: 1.0,
read_ahead: None,
}
}
pub fn external_end(&self) -> Beats {
self.external_start + self.external_duration
}
pub fn external_start_secs(&self, tempo_map: &TempoMap) -> Seconds {
tempo_map.beats_to_seconds(self.external_start)
}
pub fn external_end_secs(&self, tempo_map: &TempoMap) -> Seconds {
tempo_map.beats_to_seconds(self.external_end())
}
pub fn external_duration_secs(&self, tempo_map: &TempoMap) -> Seconds {
tempo_map.beats_to_seconds(self.external_end()) - tempo_map.beats_to_seconds(self.external_start)
}
pub fn is_active_at(&self, time: Seconds, tempo_map: &TempoMap) -> bool {
time >= self.external_start_secs(tempo_map) && time < self.external_end_secs(tempo_map)
}
pub fn internal_duration(&self) -> Seconds {
self.internal_end - self.internal_start
}
pub fn is_looping(&self, tempo_map: &TempoMap) -> bool {
self.external_duration_secs(tempo_map) > self.internal_duration()
}
/// Get the audio content position for a given timeline position. Handles looping.
pub fn get_content_position(&self, timeline_pos: Seconds, tempo_map: &TempoMap) -> Option<Seconds> {
let start_secs = self.external_start_secs(tempo_map);
let end_secs = self.external_end_secs(tempo_map);
if timeline_pos < start_secs || timeline_pos >= end_secs {
return None;
}
let relative_pos = timeline_pos - start_secs;
let internal_duration = self.internal_duration();
if internal_duration.0 <= 0.0 {
return None;
}
let content_offset = relative_pos % internal_duration;
Some(self.internal_start + content_offset)
}
pub fn set_gain(&mut self, gain: f32) {
self.gain = gain.max(0.0);
}
}

View File

@ -0,0 +1,651 @@
//! Disk reader for streaming audio playback.
//!
//! Provides lock-free read-ahead buffers for audio files that cannot be kept
//! fully decoded in memory. A background thread fills these buffers ahead of
//! the playhead so the audio callback never blocks on I/O or decoding.
//!
//! **InMemory** files bypass the disk reader entirely — their data is already
//! available as `&[f32]`. **Mapped** files (mmap'd WAV/AIFF) also bypass the
//! disk reader for now (OS page cache handles paging). **Compressed** files
//! (MP3, FLAC, OGG, etc.) use a `CompressedReader` that stream-decodes on
//! demand via Symphonia into a `ReadAheadBuffer`.
use std::cell::UnsafeCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
/// Read-ahead distance in seconds.
const PREFETCH_SECONDS: f64 = 2.0;
/// How often the disk reader thread wakes up to check for work (ms).
const POLL_INTERVAL_MS: u64 = 5;
// ---------------------------------------------------------------------------
// ReadAheadBuffer
// ---------------------------------------------------------------------------
/// Lock-free read-ahead buffer shared between the disk reader (writer) and the
/// audio callback (reader).
///
/// # Thread safety
///
/// This is a **single-producer single-consumer** (SPSC) structure:
/// - **Producer** (disk reader thread): calls `write_samples()` and
/// `advance_start()` to fill and reclaim buffer space.
/// - **Consumer** (audio callback): calls `read_sample()` and `has_range()`
/// to access decoded audio.
///
/// The producer only writes to indices **beyond** `valid_frames`, while the
/// consumer only reads indices **within** `[start_frame, start_frame +
/// valid_frames)`. Because the two threads always operate on disjoint regions,
/// the sample data itself requires no locking. Atomics with Acquire/Release
/// ordering on `start_frame` and `valid_frames` provide the happens-before
/// relationship that guarantees the consumer sees completed writes.
///
/// The `UnsafeCell` wrapping the buffer data allows the producer to mutate it
/// through a shared `&self` reference. This is sound because only one thread
/// (the producer) ever writes, and it writes to a region that the consumer
/// cannot yet see (gated by the `valid_frames` atomic).
pub struct ReadAheadBuffer {
/// Interleaved f32 samples stored as a circular buffer.
/// Wrapped in `UnsafeCell` to allow the producer to write through `&self`.
buffer: UnsafeCell<Box<[f32]>>,
/// The absolute frame number of the oldest valid frame in the ring.
start_frame: AtomicU64,
/// Number of valid frames starting from `start_frame`.
valid_frames: AtomicU64,
/// Total capacity in frames.
capacity_frames: usize,
/// Number of audio channels.
channels: u32,
/// Source file sample rate.
sample_rate: u32,
/// Last file-local frame requested by the audio callback.
/// Written by the consumer (render_from_file), read by the disk reader.
/// The disk reader uses this instead of the global playhead to know
/// where in the file to buffer around.
target_frame: AtomicU64,
/// When true, `render_from_file` will block-wait for frames instead of
/// returning silence on buffer miss. Used during offline export.
export_mode: AtomicBool,
}
// SAFETY: See the doc comment on ReadAheadBuffer for the full safety argument.
// In short: SPSC access pattern with atomic coordination means no data races.
// The circular design means advance_start never moves data — it only bumps
// the start pointer, so the consumer never sees partially-shifted memory.
unsafe impl Send for ReadAheadBuffer {}
unsafe impl Sync for ReadAheadBuffer {}
impl std::fmt::Debug for ReadAheadBuffer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ReadAheadBuffer")
.field("capacity_frames", &self.capacity_frames)
.field("channels", &self.channels)
.field("sample_rate", &self.sample_rate)
.field("start_frame", &self.start_frame.load(Ordering::Relaxed))
.field("valid_frames", &self.valid_frames.load(Ordering::Relaxed))
.finish()
}
}
impl ReadAheadBuffer {
/// Create a new read-ahead buffer with the given capacity (in seconds).
pub fn new(capacity_seconds: f64, sample_rate: u32, channels: u32) -> Self {
let capacity_frames = (capacity_seconds * sample_rate as f64) as usize;
let buffer_len = capacity_frames * channels as usize;
Self {
buffer: UnsafeCell::new(vec![0.0f32; buffer_len].into_boxed_slice()),
start_frame: AtomicU64::new(0),
valid_frames: AtomicU64::new(0),
capacity_frames,
channels,
sample_rate,
target_frame: AtomicU64::new(0),
export_mode: AtomicBool::new(false),
}
}
/// Map an absolute frame number to a ring-buffer sample index.
#[inline(always)]
fn ring_index(&self, frame: u64, channel: usize) -> usize {
let ring_frame = (frame as usize) % self.capacity_frames;
ring_frame * self.channels as usize + channel
}
/// Snapshot the current valid range. Call once per audio callback, then
/// pass the returned `(start, end)` to `read_sample` for consistent reads.
#[inline]
pub fn snapshot(&self) -> (u64, u64) {
let start = self.start_frame.load(Ordering::Acquire);
let valid = self.valid_frames.load(Ordering::Acquire);
(start, start + valid)
}
/// Read a single interleaved sample using a pre-loaded range snapshot.
/// Returns `0.0` if the frame is outside `[snap_start, snap_end)`.
/// Called from the **audio callback** (consumer).
#[inline]
pub fn read_sample(&self, frame: u64, channel: usize, snap_start: u64, snap_end: u64) -> f32 {
if frame < snap_start || frame >= snap_end {
return 0.0;
}
let idx = self.ring_index(frame, channel);
// SAFETY: We only read indices that the producer has already written
// and published via valid_frames. The circular layout means
// advance_start never moves data, so no torn reads are possible.
let buffer = unsafe { &*self.buffer.get() };
buffer[idx]
}
/// Check whether a contiguous range of frames is fully available.
#[inline]
pub fn has_range(&self, start: u64, count: u64) -> bool {
let buf_start = self.start_frame.load(Ordering::Acquire);
let valid = self.valid_frames.load(Ordering::Acquire);
start >= buf_start && start + count <= buf_start + valid
}
/// Current start frame of the buffer.
#[inline]
pub fn start_frame(&self) -> u64 {
self.start_frame.load(Ordering::Acquire)
}
/// Number of valid frames currently in the buffer.
#[inline]
pub fn valid_frames_count(&self) -> u64 {
self.valid_frames.load(Ordering::Acquire)
}
/// Update the target frame — the file-local frame the audio callback
/// is currently reading from. Called by `render_from_file` (consumer).
/// Each clip instance has its own buffer, so a plain store is sufficient.
#[inline]
pub fn set_target_frame(&self, frame: u64) {
self.target_frame.store(frame, Ordering::Relaxed);
}
/// Reset the target frame to MAX before a new render cycle.
/// If no clip calls `set_target_frame` this cycle, `has_active_target()`
/// returns false, telling the disk reader to skip this buffer.
#[inline]
pub fn reset_target_frame(&self) {
self.target_frame.store(u64::MAX, Ordering::Relaxed);
}
/// Force-set the target frame to an exact value.
/// Used by the disk reader's seek command where we need an absolute position.
#[inline]
pub fn force_target_frame(&self, frame: u64) {
self.target_frame.store(frame, Ordering::Relaxed);
}
/// Get the target frame set by the audio callback.
/// Called by the disk reader thread (producer).
#[inline]
pub fn target_frame(&self) -> u64 {
self.target_frame.load(Ordering::Relaxed)
}
/// Check if any clip set a target this cycle (vs still at reset value).
#[inline]
pub fn has_active_target(&self) -> bool {
self.target_frame.load(Ordering::Relaxed) != u64::MAX
}
/// Enable or disable export (blocking) mode. When enabled,
/// `render_from_file` will spin-wait for frames instead of returning
/// silence on buffer miss.
pub fn set_export_mode(&self, export: bool) {
self.export_mode.store(export, Ordering::Release);
}
/// Check if export (blocking) mode is active.
pub fn is_export_mode(&self) -> bool {
self.export_mode.load(Ordering::Acquire)
}
/// Reset the buffer to start at `new_start` with zero valid frames.
/// Called by the **disk reader thread** (producer) after a seek.
pub fn reset(&self, new_start: u64) {
self.valid_frames.store(0, Ordering::Release);
self.start_frame.store(new_start, Ordering::Release);
}
/// Write interleaved samples into the buffer, extending the valid range.
/// Called by the **disk reader thread** (producer only).
/// Returns the number of frames actually written (may be less than `frames`
/// if the buffer is full).
///
/// # Safety
/// Must only be called from the single producer thread.
pub fn write_samples(&self, samples: &[f32], frames: usize) -> usize {
let valid = self.valid_frames.load(Ordering::Acquire) as usize;
let remaining_capacity = self.capacity_frames - valid;
let write_frames = frames.min(remaining_capacity);
if write_frames == 0 {
return 0;
}
let ch = self.channels as usize;
let start = self.start_frame.load(Ordering::Acquire);
let write_start_frame = start as usize + valid;
// SAFETY: We only write to ring positions beyond the current valid
// range, which the consumer cannot access. Only one producer calls this.
let buffer = unsafe { &mut *self.buffer.get() };
// Write with wrap-around: the ring position may cross the buffer end.
let ring_start = (write_start_frame % self.capacity_frames) * ch;
let total_samples = write_frames * ch;
let buffer_sample_len = self.capacity_frames * ch;
let first_chunk = total_samples.min(buffer_sample_len - ring_start);
buffer[ring_start..ring_start + first_chunk]
.copy_from_slice(&samples[..first_chunk]);
if first_chunk < total_samples {
// Wrap around to the beginning of the buffer.
let second_chunk = total_samples - first_chunk;
buffer[..second_chunk]
.copy_from_slice(&samples[first_chunk..first_chunk + second_chunk]);
}
// Make the new samples visible to the consumer.
self.valid_frames
.store((valid + write_frames) as u64, Ordering::Release);
write_frames
}
/// Advance the buffer start, discarding frames behind the playhead.
/// Called by the **disk reader thread** (producer only) to reclaim space.
///
/// Because this is a circular buffer, advancing the start only updates
/// atomic counters — no data is moved, so the consumer never sees
/// partially-shifted memory.
pub fn advance_start(&self, new_start: u64) {
let old_start = self.start_frame.load(Ordering::Acquire);
if new_start <= old_start {
return;
}
let advance_frames = (new_start - old_start) as usize;
let valid = self.valid_frames.load(Ordering::Acquire) as usize;
if advance_frames >= valid {
// All data is stale — just reset.
self.valid_frames.store(0, Ordering::Release);
self.start_frame.store(new_start, Ordering::Release);
return;
}
let new_valid = valid - advance_frames;
// Store valid_frames first (shrinking the visible range), then
// advance start_frame. The consumer always sees a consistent
// sub-range of valid data.
self.valid_frames
.store(new_valid as u64, Ordering::Release);
self.start_frame.store(new_start, Ordering::Release);
}
}
// ---------------------------------------------------------------------------
// CompressedReader
// ---------------------------------------------------------------------------
/// Wraps a Symphonia decoder for streaming a single compressed audio file.
struct CompressedReader {
format_reader: Box<dyn symphonia::core::formats::FormatReader>,
decoder: Box<dyn symphonia::core::codecs::Decoder>,
track_id: u32,
/// Current decoder position in frames.
current_frame: u64,
sample_rate: u32,
channels: u32,
#[allow(dead_code)]
total_frames: u64,
/// Temporary decode buffer.
sample_buf: Option<SampleBuffer<f32>>,
}
impl CompressedReader {
/// Open a compressed audio file and prepare for streaming decode.
fn open(path: &Path) -> Result<Self, String> {
let file =
std::fs::File::open(path).map_err(|e| format!("Failed to open file: {}", e))?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.map_err(|e| format!("Failed to probe file: {}", e))?;
let format_reader = probed.format;
let track = format_reader
.tracks()
.iter()
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
.ok_or_else(|| "No audio tracks found".to_string())?;
let track_id = track.id;
let codec_params = &track.codec_params;
let sample_rate = codec_params.sample_rate.unwrap_or(44100);
let channels = codec_params
.channels
.map(|c| c.count())
.unwrap_or(2) as u32;
let total_frames = codec_params.n_frames.unwrap_or(0);
let decoder = symphonia::default::get_codecs()
.make(codec_params, &DecoderOptions::default())
.map_err(|e| format!("Failed to create decoder: {}", e))?;
Ok(Self {
format_reader,
decoder,
track_id,
current_frame: 0,
sample_rate,
channels,
total_frames,
sample_buf: None,
})
}
/// Seek to a specific frame. Returns the actual frame reached (may differ
/// for compressed formats that can only seek to keyframes).
fn seek(&mut self, target_frame: u64) -> Result<u64, String> {
let seek_to = SeekTo::TimeStamp {
ts: target_frame,
track_id: self.track_id,
};
let seeked = self
.format_reader
.seek(SeekMode::Coarse, seek_to)
.map_err(|e| format!("Seek failed: {}", e))?;
let actual_frame = seeked.actual_ts;
self.current_frame = actual_frame;
// Reset the decoder after seeking.
self.decoder.reset();
Ok(actual_frame)
}
/// Decode the next chunk of audio into `out`. Returns the number of frames
/// decoded. Returns `Ok(0)` at end-of-file.
fn decode_next(&mut self, out: &mut Vec<f32>) -> Result<usize, String> {
out.clear();
loop {
let packet = match self.format_reader.next_packet() {
Ok(p) => p,
Err(symphonia::core::errors::Error::IoError(ref e))
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
{
return Ok(0); // EOF
}
Err(e) => return Err(format!("Read packet error: {}", e)),
};
if packet.track_id() != self.track_id {
continue;
}
match self.decoder.decode(&packet) {
Ok(decoded) => {
if self.sample_buf.is_none() {
let spec = *decoded.spec();
let duration = decoded.capacity() as u64;
self.sample_buf = Some(SampleBuffer::new(duration, spec));
}
if let Some(ref mut buf) = self.sample_buf {
buf.copy_interleaved_ref(decoded);
let samples = buf.samples();
out.extend_from_slice(samples);
let frames = samples.len() / self.channels as usize;
self.current_frame += frames as u64;
return Ok(frames);
}
return Ok(0);
}
Err(symphonia::core::errors::Error::DecodeError(_)) => {
continue; // Skip corrupt packets.
}
Err(e) => return Err(format!("Decode error: {}", e)),
}
}
}
}
// ---------------------------------------------------------------------------
// DiskReaderCommand
// ---------------------------------------------------------------------------
/// Commands sent from the engine to the disk reader thread.
pub enum DiskReaderCommand {
/// Start streaming a compressed file for a clip instance.
ActivateFile {
reader_id: u64,
path: PathBuf,
buffer: Arc<ReadAheadBuffer>,
},
/// Stop streaming for a clip instance.
DeactivateFile { reader_id: u64 },
/// The playhead has jumped — refill buffers from the new position.
Seek { frame: u64 },
/// Shut down the disk reader thread.
Shutdown,
}
// ---------------------------------------------------------------------------
// DiskReader
// ---------------------------------------------------------------------------
/// Manages background read-ahead for compressed audio files.
///
/// The engine creates a `DiskReader` at startup. When a compressed file is
/// imported, it sends an `ActivateFile` command. The disk reader opens a
/// Symphonia decoder and starts filling the file's `ReadAheadBuffer` ahead
/// of the shared playhead.
pub struct DiskReader {
/// Channel to send commands to the background thread.
command_tx: rtrb::Producer<DiskReaderCommand>,
/// Shared playhead position (frames). The engine updates this atomically.
#[allow(dead_code)]
playhead_frame: Arc<AtomicU64>,
/// Whether the reader thread is running.
running: Arc<AtomicBool>,
/// Background thread handle.
thread_handle: Option<std::thread::JoinHandle<()>>,
}
impl DiskReader {
/// Create a new disk reader with a background thread.
pub fn new(playhead_frame: Arc<AtomicU64>, _sample_rate: u32) -> Self {
let (command_tx, command_rx) = rtrb::RingBuffer::new(64);
let running = Arc::new(AtomicBool::new(true));
let thread_running = running.clone();
let thread_handle = std::thread::Builder::new()
.name("disk-reader".into())
.spawn(move || {
Self::reader_thread(command_rx, thread_running);
})
.expect("Failed to spawn disk reader thread");
Self {
command_tx,
playhead_frame,
running,
thread_handle: Some(thread_handle),
}
}
/// Send a command to the disk reader thread.
pub fn send(&mut self, cmd: DiskReaderCommand) {
let _ = self.command_tx.push(cmd);
}
/// Create a `ReadAheadBuffer` for a compressed file.
pub fn create_buffer(sample_rate: u32, channels: u32) -> Arc<ReadAheadBuffer> {
Arc::new(ReadAheadBuffer::new(
PREFETCH_SECONDS + 1.0, // extra headroom
sample_rate,
channels,
))
}
/// The disk reader background thread.
fn reader_thread(
mut command_rx: rtrb::Consumer<DiskReaderCommand>,
running: Arc<AtomicBool>,
) {
let mut active_files: HashMap<u64, (CompressedReader, Arc<ReadAheadBuffer>)> =
HashMap::new();
let mut decode_buf = Vec::with_capacity(8192);
while running.load(Ordering::Relaxed) {
// Process commands.
while let Ok(cmd) = command_rx.pop() {
match cmd {
DiskReaderCommand::ActivateFile {
reader_id,
path,
buffer,
} => match CompressedReader::open(&path) {
Ok(reader) => {
eprintln!("[DiskReader] Activated reader={}, ch={}, sr={}, path={:?}",
reader_id, reader.channels, reader.sample_rate, path);
active_files.insert(reader_id, (reader, buffer));
}
Err(e) => {
eprintln!(
"[DiskReader] Failed to open compressed file {:?}: {}",
path, e
);
}
},
DiskReaderCommand::DeactivateFile { reader_id } => {
active_files.remove(&reader_id);
}
DiskReaderCommand::Seek { frame } => {
for (_, (reader, buffer)) in active_files.iter_mut() {
buffer.force_target_frame(frame);
buffer.reset(frame);
if let Err(e) = reader.seek(frame) {
eprintln!("[DiskReader] Seek error: {}", e);
}
}
}
DiskReaderCommand::Shutdown => {
return;
}
}
}
// Fill each active reader's buffer ahead of its target frame.
// Each clip instance has its own buffer and target_frame, set by
// render_from_file during the audio callback.
for (_reader_id, (reader, buffer)) in active_files.iter_mut() {
// Skip files where no clip is currently playing
if !buffer.has_active_target() {
continue;
}
let target = buffer.target_frame();
let buf_start = buffer.start_frame();
let buf_valid = buffer.valid_frames_count();
let buf_end = buf_start + buf_valid;
// If the target has jumped behind or far ahead of the buffer,
// seek the decoder and reset.
if target < buf_start || target > buf_end + reader.sample_rate as u64 {
buffer.reset(target);
let _ = reader.seek(target);
continue;
}
// Advance the buffer start to reclaim space behind the target.
// Keep a small lookback for sinc interpolation (~32 frames).
let lookback = 64u64;
let advance_to = target.saturating_sub(lookback);
if advance_to > buf_start {
buffer.advance_start(advance_to);
}
// Calculate how far ahead we need to fill.
let buf_start = buffer.start_frame();
let buf_valid = buffer.valid_frames_count();
let buf_end = buf_start + buf_valid;
let prefetch_target =
target + (PREFETCH_SECONDS * reader.sample_rate as f64) as u64;
if buf_end >= prefetch_target {
continue; // Already filled far enough ahead.
}
// Decode more data into the buffer.
match reader.decode_next(&mut decode_buf) {
Ok(0) => {} // EOF
Ok(frames) => {
let was_empty = buffer.valid_frames_count() == 0;
buffer.write_samples(&decode_buf, frames);
if was_empty {
eprintln!("[DiskReader] reader={}: first fill, {} frames, buf_start={}, valid={}",
_reader_id, frames, buffer.start_frame(), buffer.valid_frames_count());
}
}
Err(e) => {
eprintln!("[DiskReader] Decode error: {}", e);
}
}
}
// In export mode, skip the sleep so decoding runs at full speed.
// Otherwise sleep briefly to avoid busy-spinning.
let any_exporting = active_files.values().any(|(_, buf)| buf.is_export_mode());
if !any_exporting {
std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
}
}
}
}
impl Drop for DiskReader {
fn drop(&mut self) {
self.running.store(false, Ordering::Release);
let _ = self.command_tx.push(DiskReaderCommand::Shutdown);
if let Some(handle) = self.thread_handle.take() {
let _ = handle.join();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,816 @@
use super::buffer_pool::BufferPool;
use super::pool::AudioPool;
use super::project::Project;
use crate::command::AudioEvent;
use crate::tempo_map::TempoMap;
use crate::time::Seconds;
use std::path::Path;
/// Render chunk size for offline export. Matches the real-time playback buffer size
/// so that MIDI events are processed at the same granularity, avoiding timing jitter.
const EXPORT_CHUNK_FRAMES: usize = 256;
/// Supported export formats
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
Wav,
Flac,
Mp3,
Aac,
}
impl ExportFormat {
/// Get the file extension for this format
pub fn extension(&self) -> &'static str {
match self {
ExportFormat::Wav => "wav",
ExportFormat::Flac => "flac",
ExportFormat::Mp3 => "mp3",
ExportFormat::Aac => "m4a",
}
}
}
/// Export settings for rendering audio
#[derive(Debug, Clone)]
pub struct ExportSettings {
/// Output format
pub format: ExportFormat,
/// Sample rate for export
pub sample_rate: u32,
/// Number of channels (1 = mono, 2 = stereo)
pub channels: u32,
/// Bit depth (16 or 24) - only for WAV/FLAC
pub bit_depth: u16,
/// MP3 bitrate in kbps (128, 192, 256, 320)
pub mp3_bitrate: u32,
/// Start time
pub start_time: Seconds,
/// End time
pub end_time: Seconds,
/// Tempo map for beat-position scheduling
pub tempo_map: TempoMap,
}
impl Default for ExportSettings {
fn default() -> Self {
Self {
format: ExportFormat::Wav,
sample_rate: 44100,
channels: 2,
bit_depth: 16,
mp3_bitrate: 320,
start_time: Seconds::ZERO,
end_time: Seconds(60.0),
tempo_map: TempoMap::constant(120.0),
}
}
}
/// Export the project to an audio file
///
/// This performs offline rendering, processing the entire timeline
/// in chunks to generate the final audio file.
///
/// If an event producer is provided, progress events will be sent
/// after each chunk with (frames_rendered, total_frames).
pub fn export_audio<P: AsRef<Path>>(
project: &mut Project,
pool: &AudioPool,
settings: &ExportSettings,
output_path: P,
mut event_tx: Option<&mut rtrb::Producer<AudioEvent>>,
) -> Result<(), String>
{
// Validate duration
let duration = settings.end_time - settings.start_time;
if duration <= Seconds::ZERO {
return Err(format!(
"Export duration is zero or negative (start={:.3}s, end={:.3}s). \
Check that the timeline has content.",
settings.start_time.seconds_to_f64(), settings.end_time.seconds_to_f64()
));
}
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
if total_frames == 0 {
return Err("Export would produce zero audio frames".to_string());
}
// Reset all node graphs to clear stale effect buffers (echo, reverb, etc.)
project.reset_all_graphs();
// Enable blocking mode on all read-ahead buffers so compressed audio
// streams block until decoded frames are available (instead of returning
// silence when the disk reader hasn't caught up with offline rendering).
project.set_export_mode(true);
// Route to appropriate export implementation based on format.
// Ensure export mode is disabled even if an error occurs.
let result = match settings.format {
ExportFormat::Wav | ExportFormat::Flac => {
let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?;
// Signal that rendering is done and we're now writing the file
if let Some(ref mut tx) = event_tx {
let _ = tx.push(AudioEvent::ExportFinalizing);
}
match settings.format {
ExportFormat::Wav => write_wav(&samples, settings, &output_path),
ExportFormat::Flac => write_flac(&samples, settings, &output_path),
_ => unreachable!(),
}
}
ExportFormat::Mp3 => {
export_mp3(project, pool, settings, output_path, event_tx)
}
ExportFormat::Aac => {
export_aac(project, pool, settings, output_path, event_tx)
}
};
// Always disable export mode, even on error
project.set_export_mode(false);
result
}
/// Render the project to memory
///
/// This function renders the project's audio to an in-memory buffer
/// of interleaved f32 samples. This is useful for custom export formats
/// or for passing audio to external encoders (e.g., FFmpeg for MP3/AAC).
///
/// The returned samples are interleaved (L,R,L,R,... for stereo).
///
/// If an event producer is provided, progress events will be sent
/// after each chunk with (frames_rendered, total_frames).
pub fn render_to_memory(
project: &mut Project,
pool: &AudioPool,
settings: &ExportSettings,
mut event_tx: Option<&mut rtrb::Producer<AudioEvent>>,
) -> Result<Vec<f32>, String>
{
// Calculate total number of frames
let duration = settings.end_time - settings.start_time;
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
let total_samples = total_frames * settings.channels as usize;
println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}",
duration.seconds_to_f64(), total_frames, total_samples, settings.channels);
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
// Create buffer for rendering
let mut render_buffer = vec![0.0f32; chunk_samples];
let mut buffer_pool = BufferPool::new(16, chunk_samples);
// Collect all rendered samples
let mut all_samples = Vec::with_capacity(total_samples);
let mut playhead = settings.start_time;
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
let mut frames_rendered = 0;
// Render the entire timeline in chunks
while playhead < settings.end_time {
// Clear the render buffer
render_buffer.fill(0.0);
// Render this chunk
project.render(
&mut render_buffer,
pool,
&mut buffer_pool,
playhead,
&settings.tempo_map,
settings.sample_rate,
settings.channels,
false,
);
// Calculate how many samples we actually need from this chunk
let remaining_time = settings.end_time - playhead;
let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration {
// Calculate frames needed and ensure it's a whole number
let frames_needed = (remaining_time.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
let samples = frames_needed * settings.channels as usize;
// Ensure we don't exceed chunk size
samples.min(chunk_samples)
} else {
chunk_samples
};
// Append to output
all_samples.extend_from_slice(&render_buffer[..samples_needed]);
// Update progress
frames_rendered += samples_needed / settings.channels as usize;
if let Some(event_tx) = event_tx.as_mut() {
let _ = event_tx.push(AudioEvent::ExportProgress {
frames_rendered,
total_frames,
});
}
playhead = playhead + Seconds(chunk_duration);
}
println!("Export: rendered {} samples total", all_samples.len());
// Verify the sample count is a multiple of channels
if all_samples.len() % settings.channels as usize != 0 {
return Err(format!(
"Sample count {} is not a multiple of channel count {}",
all_samples.len(),
settings.channels
));
}
Ok(all_samples)
}
/// Write WAV file using hound
fn write_wav<P: AsRef<Path>>(
samples: &[f32],
settings: &ExportSettings,
output_path: P,
) -> Result<(), String> {
let spec = hound::WavSpec {
channels: settings.channels as u16,
sample_rate: settings.sample_rate,
bits_per_sample: settings.bit_depth,
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::create(output_path, spec)
.map_err(|e| format!("Failed to create WAV file: {}", e))?;
// Write samples
match settings.bit_depth {
16 => {
for &sample in samples {
let clamped = sample.max(-1.0).min(1.0);
let pcm_value = (clamped * 32767.0) as i16;
writer.write_sample(pcm_value)
.map_err(|e| format!("Failed to write sample: {}", e))?;
}
}
24 => {
for &sample in samples {
let clamped = sample.max(-1.0).min(1.0);
let pcm_value = (clamped * 8388607.0) as i32;
writer.write_sample(pcm_value)
.map_err(|e| format!("Failed to write sample: {}", e))?;
}
}
_ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)),
}
writer.finalize()
.map_err(|e| format!("Failed to finalize WAV file: {}", e))?;
Ok(())
}
/// Write FLAC file using hound (FLAC is essentially lossless WAV)
fn write_flac<P: AsRef<Path>>(
samples: &[f32],
settings: &ExportSettings,
output_path: P,
) -> Result<(), String> {
// For now, we'll use hound to write a WAV-like FLAC file
// In the future, we could use a dedicated FLAC encoder
let spec = hound::WavSpec {
channels: settings.channels as u16,
sample_rate: settings.sample_rate,
bits_per_sample: settings.bit_depth,
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::create(output_path, spec)
.map_err(|e| format!("Failed to create FLAC file: {}", e))?;
// Write samples (same as WAV for now)
match settings.bit_depth {
16 => {
for &sample in samples {
let clamped = sample.max(-1.0).min(1.0);
let pcm_value = (clamped * 32767.0) as i16;
writer.write_sample(pcm_value)
.map_err(|e| format!("Failed to write sample: {}", e))?;
}
}
24 => {
for &sample in samples {
let clamped = sample.max(-1.0).min(1.0);
let pcm_value = (clamped * 8388607.0) as i32;
writer.write_sample(pcm_value)
.map_err(|e| format!("Failed to write sample: {}", e))?;
}
}
_ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)),
}
writer.finalize()
.map_err(|e| format!("Failed to finalize FLAC file: {}", e))?;
Ok(())
}
/// Export audio as MP3 using FFmpeg (streaming - render and encode simultaneously)
fn export_mp3<P: AsRef<Path>>(
project: &mut Project,
pool: &AudioPool,
settings: &ExportSettings,
output_path: P,
mut event_tx: Option<&mut rtrb::Producer<AudioEvent>>,
) -> Result<(), String> {
// Initialize FFmpeg
ffmpeg_next::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?;
// Set up FFmpeg encoder
let encoder_codec = ffmpeg_next::encoder::find(ffmpeg_next::codec::Id::MP3)
.ok_or("MP3 encoder (libmp3lame) not found")?;
let mut output = ffmpeg_next::format::output(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
let mut encoder = ffmpeg_next::codec::Context::new_with_codec(encoder_codec)
.encoder()
.audio()
.map_err(|e| format!("Failed to create encoder: {}", e))?;
let channel_layout = match settings.channels {
1 => ffmpeg_next::channel_layout::ChannelLayout::MONO,
2 => ffmpeg_next::channel_layout::ChannelLayout::STEREO,
_ => return Err(format!("Unsupported channel count: {}", settings.channels)),
};
encoder.set_rate(settings.sample_rate as i32);
encoder.set_channel_layout(channel_layout);
encoder.set_format(ffmpeg_next::format::Sample::I16(ffmpeg_next::format::sample::Type::Planar));
encoder.set_bit_rate((settings.mp3_bitrate * 1000) as usize);
encoder.set_time_base(ffmpeg_next::Rational(1, settings.sample_rate as i32));
let mut encoder = encoder.open_as(encoder_codec)
.map_err(|e| format!("Failed to open MP3 encoder: {}", e))?;
{
let mut stream = output.add_stream(encoder_codec)
.map_err(|e| format!("Failed to add stream: {}", e))?;
stream.set_parameters(&encoder);
}
output.write_header()
.map_err(|e| format!("Failed to write header: {}", e))?;
// Calculate rendering parameters
let duration = settings.end_time - settings.start_time;
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
// Create buffers for rendering
let mut render_buffer = vec![0.0f32; chunk_samples];
let mut buffer_pool = BufferPool::new(16, chunk_samples);
// Get encoder frame size for proper buffering
let encoder_frame_size = encoder.frame_size() as usize;
let encoder_frame_size = if encoder_frame_size > 0 {
encoder_frame_size
} else {
1152 // Default MP3 frame size
};
// Sample buffer to accumulate samples until we have complete frames
let mut sample_buffer: Vec<f32> = Vec::new();
// PTS (presentation timestamp) tracking for proper timing
let mut pts: i64 = 0;
// Streaming render and encode loop
let mut playhead = settings.start_time;
let mut frames_rendered = 0;
while playhead < settings.end_time {
// Render this chunk
render_buffer.fill(0.0);
project.render(
&mut render_buffer,
pool,
&mut buffer_pool,
playhead,
&settings.tempo_map,
settings.sample_rate,
settings.channels,
false,
);
// Calculate how many samples we need from this chunk
let remaining_time = settings.end_time - playhead;
let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration {
((remaining_time.seconds_to_f64() * settings.sample_rate as f64) as usize * settings.channels as usize)
.min(chunk_samples)
} else {
chunk_samples
};
// Add to sample buffer
sample_buffer.extend_from_slice(&render_buffer[..samples_needed]);
// Encode complete frames from buffer
let encoder_frame_samples = encoder_frame_size * settings.channels as usize;
while sample_buffer.len() >= encoder_frame_samples {
// Extract one complete frame
let frame_samples: Vec<f32> = sample_buffer.drain(..encoder_frame_samples).collect();
// Convert to planar i16
let planar_i16 = convert_chunk_to_planar_i16(&frame_samples, settings.channels);
// Encode this frame
encode_complete_frame_mp3(
&mut encoder,
&mut output,
&planar_i16,
encoder_frame_size,
settings.sample_rate,
channel_layout,
pts,
)?;
frames_rendered += encoder_frame_size;
pts += encoder_frame_size as i64;
// Report progress
if let Some(ref mut tx) = event_tx {
let _ = tx.push(AudioEvent::ExportProgress {
frames_rendered,
total_frames,
});
}
}
playhead = playhead + Seconds(chunk_duration);
}
// Encode any remaining samples as the final frame
if !sample_buffer.is_empty() {
let planar_i16 = convert_chunk_to_planar_i16(&sample_buffer, settings.channels);
let final_frame_size = sample_buffer.len() / settings.channels as usize;
encode_complete_frame_mp3(
&mut encoder,
&mut output,
&planar_i16,
final_frame_size,
settings.sample_rate,
channel_layout,
pts,
)?;
}
// Signal that rendering is done and we're now flushing/finalizing
if let Some(ref mut tx) = event_tx {
let _ = tx.push(AudioEvent::ExportFinalizing);
}
// Flush encoder
encoder.send_eof()
.map_err(|e| format!("Failed to send EOF: {}", e))?;
receive_and_write_packets(&mut encoder, &mut output)?;
output.write_trailer()
.map_err(|e| format!("Failed to write trailer: {}", e))?;
Ok(())
}
/// Export audio as AAC using FFmpeg (streaming - render and encode simultaneously)
fn export_aac<P: AsRef<Path>>(
project: &mut Project,
pool: &AudioPool,
settings: &ExportSettings,
output_path: P,
mut event_tx: Option<&mut rtrb::Producer<AudioEvent>>,
) -> Result<(), String> {
// Initialize FFmpeg
ffmpeg_next::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?;
// Set up FFmpeg encoder
let encoder_codec = ffmpeg_next::encoder::find(ffmpeg_next::codec::Id::AAC)
.ok_or("AAC encoder not found")?;
let mut output = ffmpeg_next::format::output(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
let mut encoder = ffmpeg_next::codec::Context::new_with_codec(encoder_codec)
.encoder()
.audio()
.map_err(|e| format!("Failed to create encoder: {}", e))?;
let channel_layout = match settings.channels {
1 => ffmpeg_next::channel_layout::ChannelLayout::MONO,
2 => ffmpeg_next::channel_layout::ChannelLayout::STEREO,
_ => return Err(format!("Unsupported channel count: {}", settings.channels)),
};
encoder.set_rate(settings.sample_rate as i32);
encoder.set_channel_layout(channel_layout);
encoder.set_format(ffmpeg_next::format::Sample::F32(ffmpeg_next::format::sample::Type::Planar));
encoder.set_bit_rate((settings.mp3_bitrate * 1000) as usize);
encoder.set_time_base(ffmpeg_next::Rational(1, settings.sample_rate as i32));
let mut encoder = encoder.open_as(encoder_codec)
.map_err(|e| format!("Failed to open AAC encoder: {}", e))?;
{
let mut stream = output.add_stream(encoder_codec)
.map_err(|e| format!("Failed to add stream: {}", e))?;
stream.set_parameters(&encoder);
}
output.write_header()
.map_err(|e| format!("Failed to write header: {}", e))?;
// Calculate rendering parameters
let duration = settings.end_time - settings.start_time;
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
// Create buffers for rendering
let mut render_buffer = vec![0.0f32; chunk_samples];
let mut buffer_pool = BufferPool::new(16, chunk_samples);
// Get encoder frame size for proper buffering
let encoder_frame_size = encoder.frame_size() as usize;
let encoder_frame_size = if encoder_frame_size > 0 {
encoder_frame_size
} else {
1024 // Default AAC frame size
};
// Sample buffer to accumulate samples until we have complete frames
let mut sample_buffer: Vec<f32> = Vec::new();
// PTS (presentation timestamp) tracking for proper timing
let mut pts: i64 = 0;
// Streaming render and encode loop
let mut playhead = settings.start_time;
let mut frames_rendered = 0;
while playhead < settings.end_time {
// Render this chunk
render_buffer.fill(0.0);
project.render(
&mut render_buffer,
pool,
&mut buffer_pool,
playhead,
&settings.tempo_map,
settings.sample_rate,
settings.channels,
false,
);
// Calculate how many samples we need from this chunk
let remaining_time = settings.end_time - playhead;
let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration {
((remaining_time.seconds_to_f64() * settings.sample_rate as f64) as usize * settings.channels as usize)
.min(chunk_samples)
} else {
chunk_samples
};
// Add to sample buffer
sample_buffer.extend_from_slice(&render_buffer[..samples_needed]);
// Encode complete frames from buffer
let encoder_frame_samples = encoder_frame_size * settings.channels as usize;
while sample_buffer.len() >= encoder_frame_samples {
// Extract one complete frame
let frame_samples: Vec<f32> = sample_buffer.drain(..encoder_frame_samples).collect();
// Convert to planar f32
let planar_f32 = convert_chunk_to_planar_f32(&frame_samples, settings.channels);
// Encode this frame
encode_complete_frame_aac(
&mut encoder,
&mut output,
&planar_f32,
encoder_frame_size,
settings.sample_rate,
channel_layout,
pts,
)?;
frames_rendered += encoder_frame_size;
pts += encoder_frame_size as i64;
// Report progress
if let Some(ref mut tx) = event_tx {
let _ = tx.push(AudioEvent::ExportProgress {
frames_rendered,
total_frames,
});
}
}
playhead = playhead + Seconds(chunk_duration);
}
// Encode any remaining samples as the final frame
if !sample_buffer.is_empty() {
let planar_f32 = convert_chunk_to_planar_f32(&sample_buffer, settings.channels);
let final_frame_size = sample_buffer.len() / settings.channels as usize;
encode_complete_frame_aac(
&mut encoder,
&mut output,
&planar_f32,
final_frame_size,
settings.sample_rate,
channel_layout,
pts,
)?;
}
// Signal that rendering is done and we're now flushing/finalizing
if let Some(ref mut tx) = event_tx {
let _ = tx.push(AudioEvent::ExportFinalizing);
}
// Flush encoder
encoder.send_eof()
.map_err(|e| format!("Failed to send EOF: {}", e))?;
receive_and_write_packets(&mut encoder, &mut output)?;
output.write_trailer()
.map_err(|e| format!("Failed to write trailer: {}", e))?;
Ok(())
}
/// Convert a chunk of interleaved f32 samples to planar i16 format
fn convert_chunk_to_planar_i16(interleaved: &[f32], channels: u32) -> Vec<Vec<i16>> {
let num_frames = interleaved.len() / channels as usize;
let mut planar = vec![vec![0i16; num_frames]; channels as usize];
for (i, chunk) in interleaved.chunks(channels as usize).enumerate() {
for (ch, &sample) in chunk.iter().enumerate() {
let clamped = sample.max(-1.0).min(1.0);
planar[ch][i] = (clamped * 32767.0) as i16;
}
}
planar
}
/// Convert a chunk of interleaved f32 samples to planar f32 format
fn convert_chunk_to_planar_f32(interleaved: &[f32], channels: u32) -> Vec<Vec<f32>> {
let num_frames = interleaved.len() / channels as usize;
let mut planar = vec![vec![0.0f32; num_frames]; channels as usize];
for (i, chunk) in interleaved.chunks(channels as usize).enumerate() {
for (ch, &sample) in chunk.iter().enumerate() {
planar[ch][i] = sample;
}
}
planar
}
/// Encode a single complete frame of planar i16 samples to MP3
fn encode_complete_frame_mp3(
encoder: &mut ffmpeg_next::encoder::Audio,
output: &mut ffmpeg_next::format::context::Output,
planar_samples: &[Vec<i16>],
num_frames: usize,
sample_rate: u32,
channel_layout: ffmpeg_next::channel_layout::ChannelLayout,
pts: i64,
) -> Result<(), String> {
if num_frames == 0 {
return Ok(());
}
let channels = planar_samples.len();
// Create audio frame
let mut frame = ffmpeg_next::frame::Audio::new(
ffmpeg_next::format::Sample::I16(ffmpeg_next::format::sample::Type::Planar),
num_frames,
channel_layout,
);
frame.set_rate(sample_rate);
frame.set_pts(Some(pts));
// Verify frame was allocated (check linesize[0] via planes())
if frame.planes() == 0 {
return Err("FFmpeg failed to allocate audio frame. Try exporting as WAV instead.".to_string());
}
// Copy all planar samples to frame
// Use plane_mut::<i16> instead of data_mut — data_mut(ch) is buggy for planar audio:
// FFmpeg only sets linesize[0], so data_mut returns 0-length slices for ch > 0.
// plane_mut uses self.samples() for the length, which is correct for all planes.
for ch in 0..channels {
let plane = frame.plane_mut::<i16>(ch);
plane.copy_from_slice(&planar_samples[ch]);
}
encoder.send_frame(&frame)
.map_err(|e| format!("Failed to send frame: {}", e))?;
receive_and_write_packets(encoder, output)?;
Ok(())
}
/// Encode a single complete frame of planar f32 samples to AAC
fn encode_complete_frame_aac(
encoder: &mut ffmpeg_next::encoder::Audio,
output: &mut ffmpeg_next::format::context::Output,
planar_samples: &[Vec<f32>],
num_frames: usize,
sample_rate: u32,
channel_layout: ffmpeg_next::channel_layout::ChannelLayout,
pts: i64,
) -> Result<(), String> {
if num_frames == 0 {
return Ok(());
}
let channels = planar_samples.len();
// Create audio frame
let mut frame = ffmpeg_next::frame::Audio::new(
ffmpeg_next::format::Sample::F32(ffmpeg_next::format::sample::Type::Planar),
num_frames,
channel_layout,
);
frame.set_rate(sample_rate);
frame.set_pts(Some(pts));
// Verify frame was allocated
if frame.planes() == 0 {
return Err("FFmpeg failed to allocate audio frame. Try exporting as WAV instead.".to_string());
}
// Copy all planar samples to frame
// Use plane_mut::<f32> instead of data_mut — data_mut(ch) is buggy for planar audio:
// FFmpeg only sets linesize[0], so data_mut returns 0-length slices for ch > 0.
// plane_mut uses self.samples() for the length, which is correct for all planes.
for ch in 0..channels {
let plane = frame.plane_mut::<f32>(ch);
plane.copy_from_slice(&planar_samples[ch]);
}
encoder.send_frame(&frame)
.map_err(|e| format!("Failed to send frame: {}", e))?;
receive_and_write_packets(encoder, output)?;
Ok(())
}
/// Receive encoded packets and write to output
fn receive_and_write_packets(
encoder: &mut ffmpeg_next::encoder::Audio,
output: &mut ffmpeg_next::format::context::Output,
) -> Result<(), String> {
let mut encoded = ffmpeg_next::Packet::empty();
while encoder.receive_packet(&mut encoded).is_ok() {
encoded.set_stream(0);
encoded.write_interleaved(output)
.map_err(|e| format!("Failed to write packet: {}", e))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_export_settings_default() {
let settings = ExportSettings::default();
assert_eq!(settings.format, ExportFormat::Wav);
assert_eq!(settings.sample_rate, 44100);
assert_eq!(settings.channels, 2);
assert_eq!(settings.bit_depth, 16);
}
#[test]
fn test_format_extension() {
assert_eq!(ExportFormat::Wav.extension(), "wav");
assert_eq!(ExportFormat::Flac.extension(), "flac");
}
}

View File

@ -0,0 +1,162 @@
/// Metronome for providing click track during playback
pub struct Metronome {
enabled: bool,
bpm: f32,
time_signature_numerator: u32,
time_signature_denominator: u32,
last_beat: i64, // Last beat number that was played (-1 = none)
// Pre-generated click samples (mono)
high_click: Vec<f32>, // Accent click for first beat
low_click: Vec<f32>, // Normal click for other beats
// Click playback state
click_position: usize, // Current position in the click sample (0 = not playing)
playing_high_click: bool, // Which click we're currently playing
#[allow(dead_code)]
sample_rate: u32,
}
impl Metronome {
/// Create a new metronome with pre-generated click sounds
pub fn new(sample_rate: u32) -> Self {
let (high_click, low_click) = Self::generate_clicks(sample_rate);
Self {
enabled: false,
bpm: 120.0,
time_signature_numerator: 4,
time_signature_denominator: 4,
last_beat: -1,
high_click,
low_click,
click_position: 0,
playing_high_click: false,
sample_rate,
}
}
/// Generate woodblock-style click samples
fn generate_clicks(sample_rate: u32) -> (Vec<f32>, Vec<f32>) {
let click_duration_ms = 10.0; // 10ms click
let click_samples = ((sample_rate as f32 * click_duration_ms) / 1000.0) as usize;
// High click (accent): 1200 Hz + 2400 Hz (higher pitched woodblock)
let high_freq1 = 1200.0;
let high_freq2 = 2400.0;
let mut high_click = Vec::with_capacity(click_samples);
for i in 0..click_samples {
let t = i as f32 / sample_rate as f32;
let envelope = 1.0 - (i as f32 / click_samples as f32); // Linear decay
let envelope = envelope * envelope; // Square for faster decay
// Mix two sine waves for woodblock character
let sample = 0.3 * (2.0 * std::f32::consts::PI * high_freq1 * t).sin()
+ 0.2 * (2.0 * std::f32::consts::PI * high_freq2 * t).sin();
// Add a bit of noise for attack transient
let noise = (i as f32 * 0.1).sin() * 0.1;
high_click.push((sample + noise) * envelope * 0.5); // Scale down to avoid clipping
}
// Low click: 800 Hz + 1600 Hz (lower pitched woodblock)
let low_freq1 = 800.0;
let low_freq2 = 1600.0;
let mut low_click = Vec::with_capacity(click_samples);
for i in 0..click_samples {
let t = i as f32 / sample_rate as f32;
let envelope = 1.0 - (i as f32 / click_samples as f32);
let envelope = envelope * envelope;
let sample = 0.3 * (2.0 * std::f32::consts::PI * low_freq1 * t).sin()
+ 0.2 * (2.0 * std::f32::consts::PI * low_freq2 * t).sin();
let noise = (i as f32 * 0.1).sin() * 0.1;
low_click.push((sample + noise) * envelope * 0.4); // Slightly quieter than high click
}
(high_click, low_click)
}
/// Enable or disable the metronome
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
if !enabled {
self.last_beat = -1; // Reset beat tracking when disabled
self.click_position = 0; // Stop any playing click
} else {
// Reset beat tracking so the next beat boundary (including beat 0) fires a click
self.last_beat = -1;
self.click_position = self.high_click.len(); // Idle (past end, nothing playing)
}
}
/// Update BPM and time signature
pub fn update_timing(&mut self, bpm: f32, time_signature: (u32, u32)) {
self.bpm = bpm;
self.time_signature_numerator = time_signature.0;
self.time_signature_denominator = time_signature.1;
}
/// Process audio and mix in metronome clicks
pub fn process(
&mut self,
output: &mut [f32],
playhead_samples: i64,
playing: bool,
sample_rate: u32,
channels: u32,
) {
if !self.enabled || !playing {
self.click_position = 0; // Reset if not playing
return;
}
let frames = output.len() / channels as usize;
for frame in 0..frames {
let current_sample = playhead_samples + frame as i64;
// Calculate current beat number
let current_time_seconds = current_sample as f64 / sample_rate as f64;
let beats_per_second = self.bpm as f64 / 60.0;
let current_beat = (current_time_seconds * beats_per_second).floor() as i64;
// Check if we crossed a beat boundary (including negative beats during count-in pre-roll)
if current_beat != self.last_beat {
self.last_beat = current_beat;
// Determine which click to play.
// Beat 0 of each measure gets the accent (high click).
// Use rem_euclid so negative beat numbers map correctly (e.g. -4 % 4 = 0).
let beat_in_measure = current_beat.rem_euclid(self.time_signature_numerator as i64) as usize;
self.playing_high_click = beat_in_measure == 0;
self.click_position = 0; // Start from beginning of click
}
// Continue playing click sample if we're currently in one
let click = if self.playing_high_click {
&self.high_click
} else {
&self.low_click
};
if self.click_position < click.len() {
let click_sample = click[self.click_position];
// Mix into all channels
for ch in 0..channels as usize {
let output_idx = frame * channels as usize + ch;
output[output_idx] += click_sample;
}
self.click_position += 1;
}
}
}
}

View File

@ -0,0 +1,191 @@
use crate::time::Beats;
/// MIDI event representing a single MIDI message
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct MidiEvent {
/// Time position in beats (quarter-note beats)
pub timestamp: Beats,
/// MIDI status byte (includes channel)
pub status: u8,
/// First data byte (note number, CC number, etc.)
pub data1: u8,
/// Second data byte (velocity, CC value, etc.)
pub data2: u8,
}
impl MidiEvent {
pub fn new(timestamp: Beats, status: u8, data1: u8, data2: u8) -> Self {
Self { timestamp, status, data1, data2 }
}
pub fn note_on(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self {
Self { timestamp, status: 0x90 | (channel & 0x0F), data1: note, data2: velocity }
}
pub fn note_off(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self {
Self { timestamp, status: 0x80 | (channel & 0x0F), data1: note, data2: velocity }
}
pub fn is_note_on(&self) -> bool {
(self.status & 0xF0) == 0x90 && self.data2 > 0
}
pub fn is_note_off(&self) -> bool {
(self.status & 0xF0) == 0x80 || ((self.status & 0xF0) == 0x90 && self.data2 == 0)
}
pub fn channel(&self) -> u8 { self.status & 0x0F }
pub fn message_type(&self) -> u8 { self.status & 0xF0 }
}
/// MIDI clip ID type (for clips stored in the pool)
pub type MidiClipId = u32;
/// MIDI clip instance ID type (for instances placed on tracks)
pub type MidiClipInstanceId = u32;
/// MIDI clip content — stores the actual MIDI events.
/// `duration` is in beats.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MidiClip {
pub id: MidiClipId,
pub events: Vec<MidiEvent>,
/// Total content duration in beats
pub duration: Beats,
pub name: String,
}
impl MidiClip {
pub fn new(id: MidiClipId, events: Vec<MidiEvent>, duration: Beats, name: String) -> Self {
let mut clip = Self { id, events, duration, name };
clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
clip
}
pub fn empty(id: MidiClipId, duration: Beats, name: String) -> Self {
Self { id, events: Vec::new(), duration, name }
}
pub fn add_event(&mut self, event: MidiEvent) {
self.events.push(event);
self.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
}
/// Get events within a beat range (relative to clip start)
pub fn get_events_in_range(&self, start: Beats, end: Beats) -> Vec<MidiEvent> {
self.events.iter()
.filter(|e| e.timestamp >= start && e.timestamp < end)
.copied()
.collect()
}
}
/// MIDI clip instance — a reference to MidiClip content with timeline positioning.
///
/// All timing fields are in beats.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MidiClipInstance {
pub id: MidiClipInstanceId,
pub clip_id: MidiClipId,
/// Start of the trimmed region within the clip content (beats)
pub internal_start: Beats,
/// End of the trimmed region within the clip content (beats)
pub internal_end: Beats,
/// Start position on the timeline (beats)
pub external_start: Beats,
/// Duration on the timeline (beats); > internal duration = looping
pub external_duration: Beats,
}
impl MidiClipInstance {
pub fn new(
id: MidiClipInstanceId,
clip_id: MidiClipId,
internal_start: Beats,
internal_end: Beats,
external_start: Beats,
external_duration: Beats,
) -> Self {
Self { id, clip_id, internal_start, internal_end, external_start, external_duration }
}
/// Create an instance covering the full clip with no trim
pub fn from_full_clip(
id: MidiClipInstanceId,
clip_id: MidiClipId,
clip_duration: Beats,
external_start: Beats,
) -> Self {
Self {
id,
clip_id,
internal_start: Beats::ZERO,
internal_end: clip_duration,
external_start,
external_duration: clip_duration,
}
}
pub fn internal_duration(&self) -> Beats { self.internal_end - self.internal_start }
pub fn external_end(&self) -> Beats { self.external_start + self.external_duration }
pub fn is_looping(&self) -> bool { self.external_duration > self.internal_duration() }
/// Check if this instance overlaps with a beat range
pub fn overlaps_range(&self, range_start: Beats, range_end: Beats) -> bool {
self.external_start < range_end && self.external_end() > range_start
}
/// Get events that should fire in a given beat range on the timeline.
/// Returns events with `timestamp` set to their global timeline beat position.
pub fn get_events_in_range(
&self,
clip: &MidiClip,
range_start: Beats,
range_end: Beats,
) -> Vec<MidiEvent> {
let mut result = Vec::new();
if !self.overlaps_range(range_start, range_end) {
return result;
}
let internal_duration = self.internal_duration();
if internal_duration <= Beats::ZERO {
return result;
}
let num_loops = if self.external_duration > internal_duration {
(self.external_duration / internal_duration).ceil() as usize
} else {
1
};
let external_end = self.external_end();
for loop_idx in 0..num_loops {
let loop_offset = internal_duration * loop_idx as f64;
for event in &clip.events {
if event.timestamp < self.internal_start || event.timestamp > self.internal_end {
continue;
}
let relative_content_time = event.timestamp - self.internal_start;
let timeline_time = self.external_start + loop_offset + relative_content_time;
if timeline_time >= range_start
&& timeline_time < range_end
&& timeline_time <= external_end
{
let mut adjusted_event = *event;
adjusted_event.timestamp = timeline_time;
result.push(adjusted_event);
}
}
}
result
}
}

View File

@ -0,0 +1,104 @@
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
use super::midi::{MidiClip, MidiClipId, MidiEvent};
use crate::time::Beats;
/// Pool for storing MIDI clip content
/// Similar to AudioClipPool but for MIDI data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MidiClipPool {
clips: HashMap<MidiClipId, MidiClip>,
next_id: MidiClipId,
}
impl MidiClipPool {
/// Create a new empty MIDI clip pool
pub fn new() -> Self {
Self {
clips: HashMap::new(),
next_id: 1, // Start at 1 so 0 can indicate "no clip"
}
}
/// Add a new clip to the pool with the given events and duration
/// Returns the ID of the newly created clip
pub fn add_clip(&mut self, events: Vec<MidiEvent>, duration: Beats, name: String) -> MidiClipId {
let id = self.next_id;
self.next_id += 1;
let clip = MidiClip::new(id, events, duration, name);
self.clips.insert(id, clip);
id
}
/// Add an existing clip to the pool (used when loading projects)
/// The clip's ID is preserved
pub fn add_existing_clip(&mut self, clip: MidiClip) {
// Update next_id to avoid collisions
if clip.id >= self.next_id {
self.next_id = clip.id + 1;
}
self.clips.insert(clip.id, clip);
}
/// Get a clip by ID
pub fn get_clip(&self, id: MidiClipId) -> Option<&MidiClip> {
self.clips.get(&id)
}
/// Get a mutable clip by ID
pub fn get_clip_mut(&mut self, id: MidiClipId) -> Option<&mut MidiClip> {
self.clips.get_mut(&id)
}
/// Remove a clip from the pool
pub fn remove_clip(&mut self, id: MidiClipId) -> Option<MidiClip> {
self.clips.remove(&id)
}
/// Duplicate a clip, returning the new clip's ID
pub fn duplicate_clip(&mut self, id: MidiClipId) -> Option<MidiClipId> {
let clip = self.clips.get(&id)?;
let new_id = self.next_id;
self.next_id += 1;
let mut new_clip = clip.clone();
new_clip.id = new_id;
new_clip.name = format!("{} (copy)", clip.name);
self.clips.insert(new_id, new_clip);
Some(new_id)
}
/// Get all clip IDs in the pool
pub fn clip_ids(&self) -> Vec<MidiClipId> {
self.clips.keys().copied().collect()
}
/// Get the number of clips in the pool
pub fn len(&self) -> usize {
self.clips.len()
}
/// Check if the pool is empty
pub fn is_empty(&self) -> bool {
self.clips.is_empty()
}
/// Clear all clips from the pool
pub fn clear(&mut self) {
self.clips.clear();
self.next_id = 1;
}
/// Get an iterator over all clips
pub fn iter(&self) -> impl Iterator<Item = (&MidiClipId, &MidiClip)> {
self.clips.iter()
}
}
impl Default for MidiClipPool {
fn default() -> Self {
Self::new()
}
}

View File

@ -0,0 +1,32 @@
pub mod automation;
pub mod bpm_detector;
pub mod buffer_pool;
pub mod clip;
pub mod disk_reader;
pub mod engine;
pub mod export;
pub mod metronome;
pub mod midi;
pub mod midi_pool;
pub mod node_graph;
pub mod pool;
pub mod project;
pub mod recording;
pub mod sample_loader;
pub mod track;
pub mod waveform_cache;
pub use automation::{AutomationLane, AutomationLaneId, AutomationPoint, CurveType, ParameterId};
pub use buffer_pool::BufferPool;
pub use clip::{AudioClipInstance, AudioClipInstanceId, Clip, ClipId};
pub use engine::{AudioClipSnapshot, Engine, EngineController};
pub use export::{export_audio, ExportFormat, ExportSettings};
pub use metronome::Metronome;
pub use midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent};
pub use midi_pool::MidiClipPool;
pub use pool::{AudioClipPool, AudioFile as PoolAudioFile, AudioPool, AudioStorage, PcmSampleFormat};
pub use project::Project;
pub use recording::RecordingState;
pub use sample_loader::{load_audio_file, SampleData};
pub use track::{AudioTrack, Metatrack, MidiTrack, RenderContext, Track, TrackId, TrackNode};
pub use waveform_cache::{ChunkPriority, DetailLevel, WaveformCache};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,192 @@
/// Load and save `.lbins` instrument bundle files.
///
/// A `.lbins` file is a ZIP archive with the following layout:
///
/// ```
/// instrument.lbins (ZIP)
/// ├── instrument.json ← GraphPreset JSON (existing schema)
/// ├── samples/
/// │ ├── kick.wav
/// │ └── snare.flac
/// └── models/
/// └── amp.nam
/// ```
///
/// All asset paths in `instrument.json` are ZIP-relative
/// (e.g. `"samples/kick.wav"`, `"models/amp.nam"`).
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::Path;
use crate::audio::node_graph::preset::{GraphPreset, SampleData};
/// Load a `.lbins` file.
///
/// Returns the deserialized `GraphPreset` together with a map of all
/// non-JSON entries keyed by their ZIP-relative path (e.g. `"samples/kick.wav"`).
pub fn load_lbins(path: &Path) -> Result<(GraphPreset, HashMap<String, Vec<u8>>), String> {
let file = std::fs::File::open(path)
.map_err(|e| format!("Failed to open .lbins file: {}", e))?;
let mut archive = zip::ZipArchive::new(file)
.map_err(|e| format!("Failed to read ZIP archive: {}", e))?;
// Read instrument.json first
let preset_json = {
let mut entry = archive
.by_name("instrument.json")
.map_err(|_| "Missing instrument.json in .lbins archive".to_string())?;
let mut buf = String::new();
entry
.read_to_string(&mut buf)
.map_err(|e| format!("Failed to read instrument.json: {}", e))?;
buf
};
let preset = GraphPreset::from_json(&preset_json)
.map_err(|e| format!("Failed to parse instrument.json: {}", e))?;
// Read all other entries into memory
let mut assets: HashMap<String, Vec<u8>> = HashMap::new();
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| format!("Failed to read ZIP entry {}: {}", i, e))?;
let entry_name = entry.name().to_string();
if entry_name == "instrument.json" || entry.is_dir() {
continue;
}
let mut bytes = Vec::new();
entry
.read_to_end(&mut bytes)
.map_err(|e| format!("Failed to read {}: {}", entry_name, e))?;
assets.insert(entry_name, bytes);
}
Ok((preset, assets))
}
/// Save a preset to a `.lbins` file.
///
/// Asset paths in `preset` are rewritten to ZIP-relative form
/// (`samples/<basename>` or `models/<basename>`).
/// If the path is already ZIP-relative (starts with `samples/` or `models/`)
/// it is used as-is. Absolute / relative filesystem paths are resolved
/// relative to `asset_base` (typically the directory that contained the
/// original `.json` preset) and then read from disk.
pub fn save_lbins(path: &Path, preset: &GraphPreset, asset_base: Option<&Path>) -> Result<(), String> {
let file = std::fs::File::create(path)
.map_err(|e| format!("Failed to create .lbins file: {}", e))?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::FileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
// We'll build a rewritten copy of the preset while collecting assets
let mut rewritten = preset.clone();
// Map: original path → (zip_path, file_bytes)
let mut asset_map: HashMap<String, (String, Vec<u8>)> = HashMap::new();
// Helper: given an original asset path string and a subdirectory ("samples" or "models"),
// resolve the bytes and return the canonical ZIP-relative path.
let mut resolve_asset = |orig_path: &str, subdir: &str| -> Result<String, String> {
// Already a ZIP-relative path — no re-reading needed, caller stored bytes already
// or the asset will be provided by a prior pass. Just normalise the subdirectory.
if orig_path.starts_with(&format!("{}/", subdir)) {
return Ok(orig_path.to_string());
}
let basename = Path::new(orig_path)
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| format!("Cannot determine filename for asset: {}", orig_path))?;
let zip_path = format!("{}/{}", subdir, basename);
if !asset_map.contains_key(orig_path) {
// Resolve to an absolute filesystem path
let fs_path = if Path::new(orig_path).is_absolute() {
std::path::PathBuf::from(orig_path)
} else if let Some(base) = asset_base {
base.join(orig_path)
} else {
std::path::PathBuf::from(orig_path)
};
let bytes = std::fs::read(&fs_path)
.map_err(|e| format!("Failed to read asset {}: {}", fs_path.display(), e))?;
asset_map.insert(orig_path.to_string(), (zip_path.clone(), bytes));
}
Ok(zip_path)
};
// Rewrite paths in all nodes
for node in &mut rewritten.nodes {
// Sample data paths
if let Some(ref mut sample_data) = node.sample_data {
match sample_data {
SampleData::SimpleSampler { ref mut file_path, .. } => {
if let Some(ref orig) = file_path.clone() {
if !orig.is_empty() {
match resolve_asset(orig, "samples") {
Ok(zip_path) => *file_path = Some(zip_path),
Err(e) => eprintln!("Warning: {}", e),
}
}
}
}
SampleData::MultiSampler { ref mut layers } => {
for layer in layers.iter_mut() {
if let Some(ref orig) = layer.file_path.clone() {
if !orig.is_empty() {
match resolve_asset(orig, "samples") {
Ok(zip_path) => layer.file_path = Some(zip_path),
Err(e) => eprintln!("Warning: {}", e),
}
}
}
}
}
}
}
// NAM model path
if let Some(ref orig) = node.nam_model_path.clone() {
if !orig.starts_with("bundled:") && !orig.is_empty() {
match resolve_asset(orig, "models") {
Ok(zip_path) => node.nam_model_path = Some(zip_path),
Err(e) => eprintln!("Warning: {}", e),
}
}
}
}
// Write all collected assets to the ZIP
for (_, (zip_path, bytes)) in &asset_map {
zip.start_file(zip_path, options)
.map_err(|e| format!("Failed to start ZIP entry {}: {}", zip_path, e))?;
zip.write_all(bytes)
.map_err(|e| format!("Failed to write {}: {}", zip_path, e))?;
}
// Write instrument.json last (after assets so paths are already rewritten)
let json = rewritten
.to_json()
.map_err(|e| format!("Failed to serialize preset: {}", e))?;
zip.start_file("instrument.json", options)
.map_err(|e| format!("Failed to start instrument.json entry: {}", e))?;
zip.write_all(json.as_bytes())
.map_err(|e| format!("Failed to write instrument.json: {}", e))?;
zip.finish()
.map_err(|e| format!("Failed to finalize ZIP: {}", e))?;
Ok(())
}

View File

@ -0,0 +1,11 @@
mod graph;
mod node_trait;
mod types;
pub mod lbins;
pub mod nodes;
pub mod preset;
pub use graph::{Connection, GraphNode, AudioGraph};
pub use node_trait::{AudioNode, cv_input_or_default};
pub use preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode, SerializedGroup, SerializedBoundaryConnection};
pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};

View File

@ -0,0 +1,110 @@
use super::types::{NodeCategory, NodePort, Parameter};
use crate::audio::midi::MidiEvent;
/// Custom node trait for audio processing nodes
///
/// All nodes must be Send to be usable in the audio thread.
/// Nodes should be real-time safe: no allocations, no blocking operations.
pub trait AudioNode: Send {
/// Node category for UI organization
fn category(&self) -> NodeCategory;
/// Input port definitions
fn inputs(&self) -> &[NodePort];
/// Output port definitions
fn outputs(&self) -> &[NodePort];
/// User-facing parameters
fn parameters(&self) -> &[Parameter];
/// Set parameter by ID
fn set_parameter(&mut self, id: u32, value: f32);
/// Get parameter by ID
fn get_parameter(&self, id: u32) -> f32;
/// Process audio buffers
///
/// # Arguments
/// * `inputs` - Audio/CV input buffers for each input port
/// * `outputs` - Audio/CV output buffers for each output port
/// * `midi_inputs` - MIDI event buffers for each MIDI input port
/// * `midi_outputs` - MIDI event buffers for each MIDI output port
/// * `sample_rate` - Current sample rate in Hz
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
midi_inputs: &[&[MidiEvent]],
midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
);
/// Handle MIDI events (for nodes with MIDI inputs)
fn handle_midi(&mut self, _event: &MidiEvent) {
// Default: do nothing
}
/// Reset internal state (clear delays, resonances, etc.)
fn reset(&mut self);
/// Get the node type name (for serialization)
fn node_type(&self) -> &str;
/// Get a unique identifier for this node instance
fn name(&self) -> &str;
/// Clone this node into a new boxed instance
/// Required for VoiceAllocator to create multiple instances
fn clone_node(&self) -> Box<dyn AudioNode>;
/// Get oscilloscope data if this is an oscilloscope node
/// Returns None for non-oscilloscope nodes
fn get_oscilloscope_data(&self, _sample_count: usize) -> Option<Vec<f32>> {
None
}
/// Get oscilloscope CV data if this is an oscilloscope node
/// Returns None for non-oscilloscope nodes
fn get_oscilloscope_cv_data(&self, _sample_count: usize) -> Option<Vec<f32>> {
None
}
/// Downcast to `&mut dyn Any` for type-specific operations
fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
/// Downcast to `&dyn Any` for type-specific read-only operations
fn as_any(&self) -> &dyn std::any::Any;
}
/// Helper function for CV inputs with optional connections
///
/// Returns the input value if connected (not NaN), otherwise returns the default value.
/// This implements "Blender-style" input behavior where parameters are replaced by
/// connected inputs.
///
/// # Arguments
/// * `inputs` - Input buffer array from process()
/// * `port` - Input port index
/// * `frame` - Current frame index
/// * `default` - Default value to use when input is unconnected
///
/// # Returns
/// The input value if connected, otherwise the default value
#[inline]
pub fn cv_input_or_default(inputs: &[&[f32]], port: usize, frame: usize, default: f32) -> f32 {
if port < inputs.len() && frame < inputs[port].len() {
let value = inputs[port][frame];
if value.is_nan() {
// Unconnected: use default parameter value
default
} else {
// Connected: use input signal
value
}
} else {
// No input buffer: use default
default
}
}

View File

@ -0,0 +1,46 @@
#!/bin/bash
for file in *.rs; do
if [ "$file" = "mod.rs" ]; then
continue
fi
echo "Processing $file"
# Create a backup
cp "$file" "$file.bak"
# Add as_any() method right after as_any_mut()
awk '
{
lines[NR] = $0
if (/fn as_any_mut\(&mut self\)/) {
# Found as_any_mut, look for its closing brace
found_method = NR
}
if (found_method > 0 && /^ }$/ && !inserted) {
closing_brace = NR
inserted = 1
}
}
END {
for (i = 1; i <= NR; i++) {
print lines[i]
if (i == closing_brace) {
print ""
print " fn as_any(&self) -> &dyn std::any::Any {"
print " self"
print " }"
}
}
}
' "$file.bak" > "$file"
# Verify the change was made
if grep -q "fn as_any(&self)" "$file"; then
echo " ✓ Successfully added as_any() to $file"
rm "$file.bak"
else
echo " ✗ Failed to add as_any() to $file - restoring backup"
mv "$file.bak" "$file"
fi
done

View File

@ -0,0 +1,309 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
const PARAM_ATTACK: u32 = 0;
const PARAM_DECAY: u32 = 1;
const PARAM_SUSTAIN: u32 = 2;
const PARAM_RELEASE: u32 = 3;
const PARAM_CURVE: u32 = 4;
#[derive(Debug, Clone, Copy, PartialEq)]
enum EnvelopeStage {
Idle,
Attack,
Decay,
Sustain,
Release,
}
/// Curve shape for envelope segments
#[derive(Debug, Clone, Copy, PartialEq)]
enum CurveType {
Linear,
Exponential,
}
impl CurveType {
fn from_f32(v: f32) -> Self {
if v >= 0.5 { CurveType::Exponential } else { CurveType::Linear }
}
}
/// ADSR Envelope Generator
/// Outputs a CV signal (0.0-1.0) based on gate input and ADSR parameters
pub struct ADSRNode {
name: String,
attack: f32, // seconds
decay: f32, // seconds
sustain: f32, // level (0.0-1.0)
release: f32, // seconds
curve: CurveType,
stage: EnvelopeStage,
level: f32, // current envelope level
/// For exponential curves: the coefficient per sample (computed on stage entry)
exp_coeff: f32,
/// For exponential curves: the base level when the stage started
exp_base: f32,
/// For exponential curves: the target level
exp_target: f32,
gate_was_high: bool,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl ADSRNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Gate", SignalType::CV, 0),
];
let outputs = vec![
NodePort::new("Envelope Out", SignalType::CV, 0),
];
let parameters = vec![
Parameter::new(PARAM_ATTACK, "Attack", 0.001, 5.0, 0.01, ParameterUnit::Time),
Parameter::new(PARAM_DECAY, "Decay", 0.001, 5.0, 0.1, ParameterUnit::Time),
Parameter::new(PARAM_SUSTAIN, "Sustain", 0.0, 1.0, 0.7, ParameterUnit::Generic),
Parameter::new(PARAM_RELEASE, "Release", 0.001, 5.0, 0.2, ParameterUnit::Time),
Parameter::new(PARAM_CURVE, "Curve", 0.0, 1.0, 0.0, ParameterUnit::Generic),
];
Self {
name,
attack: 0.01,
decay: 0.1,
sustain: 0.7,
release: 0.2,
curve: CurveType::Linear,
stage: EnvelopeStage::Idle,
level: 0.0,
exp_coeff: 0.0,
exp_base: 0.0,
exp_target: 0.0,
gate_was_high: false,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for ADSRNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_ATTACK => self.attack = value.clamp(0.001, 5.0),
PARAM_DECAY => self.decay = value.clamp(0.001, 5.0),
PARAM_SUSTAIN => self.sustain = value.clamp(0.0, 1.0),
PARAM_RELEASE => self.release = value.clamp(0.001, 5.0),
PARAM_CURVE => self.curve = CurveType::from_f32(value),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_ATTACK => self.attack,
PARAM_DECAY => self.decay,
PARAM_SUSTAIN => self.sustain,
PARAM_RELEASE => self.release,
PARAM_CURVE => match self.curve { CurveType::Linear => 0.0, CurveType::Exponential => 1.0 },
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let sample_rate_f32 = sample_rate as f32;
// CV signals are mono
let frames = output.len();
for frame in 0..frames {
// Gate input: when unconnected, defaults to 0.0 (off)
let gate_cv = cv_input_or_default(inputs, 0, frame, 0.0);
let gate_high = gate_cv > 0.5;
// Detect gate transitions
if gate_high && !self.gate_was_high {
// Note on: Start attack
self.stage = EnvelopeStage::Attack;
if self.curve == CurveType::Exponential {
// For exponential attack, compute coefficient for ~5 time constants
// We overshoot the target slightly so the curve reaches 1.0 naturally
let samples = self.attack * sample_rate_f32;
self.exp_coeff = (-5.0 / samples).exp();
self.exp_base = self.level;
self.exp_target = 1.0;
}
} else if !gate_high && self.gate_was_high {
// Note off: Start release
self.stage = EnvelopeStage::Release;
if self.curve == CurveType::Exponential {
let samples = self.release * sample_rate_f32;
self.exp_coeff = (-5.0 / samples).exp();
self.exp_base = self.level;
self.exp_target = 0.0;
}
}
self.gate_was_high = gate_high;
// Process envelope stage
match self.stage {
EnvelopeStage::Idle => {
self.level = 0.0;
}
EnvelopeStage::Attack => {
match self.curve {
CurveType::Linear => {
let increment = 1.0 / (self.attack * sample_rate_f32);
self.level += increment;
if self.level >= 1.0 {
self.level = 1.0;
self.stage = EnvelopeStage::Decay;
}
}
CurveType::Exponential => {
// Asymptotic approach: level moves toward overshoot target
// Using target of 1.0 + small overshoot so we actually reach 1.0
let overshoot_target = 1.0 + (1.0 - self.exp_base) * 0.01;
self.level = overshoot_target - (overshoot_target - self.level) * self.exp_coeff;
if self.level >= 1.0 {
self.level = 1.0;
self.stage = EnvelopeStage::Decay;
// Set up decay exponential
let samples = self.decay * sample_rate_f32;
self.exp_coeff = (-5.0 / samples).exp();
self.exp_base = 1.0;
self.exp_target = self.sustain;
}
}
}
}
EnvelopeStage::Decay => {
let target = self.sustain;
match self.curve {
CurveType::Linear => {
let decrement = (1.0 - target) / (self.decay * sample_rate_f32);
self.level -= decrement;
if self.level <= target {
self.level = target;
self.stage = EnvelopeStage::Sustain;
}
}
CurveType::Exponential => {
// Exponential decay toward sustain level
self.level = target + (self.level - target) * self.exp_coeff;
if (self.level - target).abs() < 0.001 {
self.level = target;
self.stage = EnvelopeStage::Sustain;
}
}
}
}
EnvelopeStage::Sustain => {
// Hold at sustain level
self.level = self.sustain;
}
EnvelopeStage::Release => {
match self.curve {
CurveType::Linear => {
let decrement = self.level / (self.release * sample_rate_f32);
self.level -= decrement;
if self.level <= 0.001 {
self.level = 0.0;
self.stage = EnvelopeStage::Idle;
}
}
CurveType::Exponential => {
// Exponential decay toward 0
self.level *= self.exp_coeff;
if self.level <= 0.001 {
self.level = 0.0;
self.stage = EnvelopeStage::Idle;
}
}
}
}
}
// Write envelope value (CV is mono)
output[frame] = self.level;
}
}
fn reset(&mut self) {
self.stage = EnvelopeStage::Idle;
self.level = 0.0;
self.exp_coeff = 0.0;
self.exp_base = 0.0;
self.exp_target = 0.0;
self.gate_was_high = false;
}
fn node_type(&self) -> &str {
"ADSR"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
attack: self.attack,
decay: self.decay,
sustain: self.sustain,
release: self.release,
curve: self.curve,
stage: EnvelopeStage::Idle,
level: 0.0,
exp_coeff: 0.0,
exp_base: 0.0,
exp_target: 0.0,
gate_was_high: false,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,225 @@
use crate::audio::midi::MidiEvent;
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use nam_ffi::NamModel;
use std::path::Path;
const PARAM_INPUT_GAIN: u32 = 0;
const PARAM_OUTPUT_GAIN: u32 = 1;
const PARAM_MIX: u32 = 2;
/// Guitar amp simulator node using Neural Amp Modeler (.nam) models.
pub struct AmpSimNode {
name: String,
input_gain: f32,
output_gain: f32,
mix: f32,
model: Option<NamModel>,
model_path: Option<String>,
// Mono scratch buffers for NAM processing (NAM is mono-only)
mono_in: Vec<f32>,
mono_out: Vec<f32>,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl AmpSimNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![NodePort::new("Audio In", SignalType::Audio, 0)];
let outputs = vec![NodePort::new("Audio Out", SignalType::Audio, 0)];
let parameters = vec![
Parameter::new(PARAM_INPUT_GAIN, "Input Gain", 0.0, 4.0, 1.0, ParameterUnit::Generic),
Parameter::new(PARAM_OUTPUT_GAIN, "Output Gain", 0.0, 4.0, 1.0, ParameterUnit::Generic),
Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic),
];
Self {
name,
input_gain: 1.0,
output_gain: 1.0,
mix: 1.0,
model: None,
model_path: None,
mono_in: Vec::new(),
mono_out: Vec::new(),
inputs,
outputs,
parameters,
}
}
/// Load a .nam model file. Call from the audio thread via command dispatch.
pub fn load_model(&mut self, path: &str) -> Result<(), String> {
let model_path = Path::new(path);
let mut model =
NamModel::from_file(model_path).map_err(|e| format!("{}", e))?;
model.set_max_buffer_size(1024);
self.model = Some(model);
self.model_path = Some(path.to_string());
Ok(())
}
/// Load a bundled NAM model by name (e.g. "BossSD1").
pub fn load_bundled_model(&mut self, name: &str) -> Result<(), String> {
let mut model = super::bundled_models::load_bundled_model(name)
.ok_or_else(|| format!("Unknown bundled model: {}", name))??;
model.set_max_buffer_size(1024);
self.model = Some(model);
self.model_path = Some(format!("bundled:{}", name));
Ok(())
}
/// Load a .nam model from in-memory bytes (used when loading from a .lbins bundle).
/// `zip_path` is the ZIP-relative path stored back in `model_path` for serialization.
pub fn load_model_from_bytes(&mut self, zip_path: &str, bytes: &[u8]) -> Result<(), String> {
let basename = std::path::Path::new(zip_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(zip_path);
let mut model = nam_ffi::NamModel::from_bytes(basename, bytes)
.map_err(|e| format!("{}", e))?;
model.set_max_buffer_size(1024);
self.model = Some(model);
self.model_path = Some(zip_path.to_string());
Ok(())
}
/// Get the loaded model path (for preset serialization).
pub fn model_path(&self) -> Option<&str> {
self.model_path.as_deref()
}
}
impl AudioNode for AmpSimNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_INPUT_GAIN => self.input_gain = value.clamp(0.0, 4.0),
PARAM_OUTPUT_GAIN => self.output_gain = value.clamp(0.0, 4.0),
PARAM_MIX => self.mix = value.clamp(0.0, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_INPUT_GAIN => self.input_gain,
PARAM_OUTPUT_GAIN => self.output_gain,
PARAM_MIX => self.mix,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
let input = inputs[0];
let output = &mut outputs[0];
let frames = input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
if let Some(ref mut model) = self.model {
// Ensure scratch buffers are large enough
if self.mono_in.len() < frames_to_process {
self.mono_in.resize(frames_to_process, 0.0);
self.mono_out.resize(frames_to_process, 0.0);
}
// Deinterleave stereo to mono (average L+R) and apply input gain
for frame in 0..frames_to_process {
let left = input[frame * 2];
let right = input[frame * 2 + 1];
self.mono_in[frame] = (left + right) * 0.5 * self.input_gain;
}
// Process through NAM model
model.process(
&self.mono_in[..frames_to_process],
&mut self.mono_out[..frames_to_process],
);
// Apply output gain, mix wet/dry, copy mono back to stereo
for frame in 0..frames_to_process {
let dry = (input[frame * 2] + input[frame * 2 + 1]) * 0.5;
let wet = self.mono_out[frame] * self.output_gain;
let mixed = dry * (1.0 - self.mix) + wet * self.mix;
output[frame * 2] = mixed;
output[frame * 2 + 1] = mixed;
}
} else {
// No model loaded — pass through unchanged
let samples = frames_to_process * 2;
output[..samples].copy_from_slice(&input[..samples]);
}
}
fn reset(&mut self) {
// No persistent filter state to reset
}
fn node_type(&self) -> &str {
"AmpSim"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
// Cannot clone the NAM model (C++ pointer), so clone without model.
// The model will need to be reloaded via command if needed.
Box::new(Self {
name: self.name.clone(),
input_gain: self.input_gain,
output_gain: self.output_gain,
mix: self.mix,
model: None,
model_path: self.model_path.clone(),
mono_in: Vec::new(),
mono_out: Vec::new(),
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,412 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
const PARAM_MODE: u32 = 0;
const PARAM_DIRECTION: u32 = 1;
const PARAM_OCTAVES: u32 = 2;
const PARAM_RETRIGGER: u32 = 3;
/// ~1ms gate-off for re-triggering at 48kHz
const RETRIGGER_SAMPLES: u32 = 48;
#[derive(Debug, Clone, Copy, PartialEq)]
enum ArpMode {
OnePerCycle = 0,
AllPerCycle = 1,
}
impl ArpMode {
fn from_f32(v: f32) -> Self {
if v.round() as i32 >= 1 { ArpMode::AllPerCycle } else { ArpMode::OnePerCycle }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ArpDirection {
Up = 0,
Down = 1,
UpDown = 2,
Random = 3,
}
impl ArpDirection {
fn from_f32(v: f32) -> Self {
match v.round() as i32 {
1 => ArpDirection::Down,
2 => ArpDirection::UpDown,
3 => ArpDirection::Random,
_ => ArpDirection::Up,
}
}
}
/// Arpeggiator node — takes MIDI input (held chord) and a CV phase input,
/// outputs CV V/Oct + Gate stepping through the held notes.
pub struct ArpeggiatorNode {
name: String,
/// Currently held notes: (note, velocity), kept sorted by pitch
held_notes: Vec<(u8, u8)>,
/// Expanded sequence after applying direction + octaves
sequence: Vec<(u8, u8)>,
/// Current position in the sequence (for OnePerCycle mode)
current_step: usize,
/// Previous phase value for wraparound detection
prev_phase: f32,
/// Countdown for gate re-trigger gap
retrigger_countdown: u32,
/// Current output values
current_voct: f32,
current_gate: f32,
/// Parameters
mode: ArpMode,
direction: ArpDirection,
octaves: u32,
retrigger: bool,
/// For Up/Down direction tracking
going_up: bool,
/// Track whether sequence needs rebuilding
sequence_dirty: bool,
/// Stateful PRNG for random direction
rng_state: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl ArpeggiatorNode {
pub fn new(name: impl Into<String>) -> Self {
let inputs = vec![
NodePort::new("MIDI In", SignalType::Midi, 0),
NodePort::new("Phase", SignalType::CV, 0),
];
let outputs = vec![
NodePort::new("V/Oct", SignalType::CV, 0),
NodePort::new("Gate", SignalType::CV, 1),
];
let parameters = vec![
Parameter::new(PARAM_MODE, "Mode", 0.0, 1.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_DIRECTION, "Direction", 0.0, 3.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_OCTAVES, "Octaves", 1.0, 4.0, 1.0, ParameterUnit::Generic),
Parameter::new(PARAM_RETRIGGER, "Retrigger", 0.0, 1.0, 1.0, ParameterUnit::Generic),
];
Self {
name: name.into(),
held_notes: Vec::new(),
sequence: Vec::new(),
current_step: 0,
prev_phase: 0.0,
retrigger_countdown: 0,
current_voct: 0.0,
current_gate: 0.0,
mode: ArpMode::OnePerCycle,
direction: ArpDirection::Up,
octaves: 1,
retrigger: true,
going_up: true,
sequence_dirty: false,
rng_state: 12345,
inputs,
outputs,
parameters,
}
}
fn midi_note_to_voct(note: u8) -> f32 {
(note as f32 - 69.0) / 12.0
}
fn rebuild_sequence(&mut self) {
self.sequence.clear();
if self.held_notes.is_empty() {
return;
}
// Build base sequence sorted by pitch (held_notes is already sorted)
let base: Vec<(u8, u8)> = self.held_notes.clone();
// Expand across octaves
let mut expanded = Vec::new();
for oct in 0..self.octaves {
for &(note, vel) in &base {
let transposed = note.saturating_add((oct * 12) as u8);
if transposed <= 127 {
expanded.push((transposed, vel));
}
}
}
// Apply direction
match self.direction {
ArpDirection::Up => {
self.sequence = expanded;
}
ArpDirection::Down => {
expanded.reverse();
self.sequence = expanded;
}
ArpDirection::UpDown => {
if expanded.len() > 1 {
let mut up_down = expanded.clone();
// Go back down, skipping the top and bottom notes to avoid doubles
for i in (1..expanded.len() - 1).rev() {
up_down.push(expanded[i]);
}
self.sequence = up_down;
} else {
self.sequence = expanded;
}
}
ArpDirection::Random => {
// For random, keep the expanded list; we'll pick randomly in process()
self.sequence = expanded;
}
}
// Clamp current_step to valid range and update V/Oct immediately
if !self.sequence.is_empty() {
self.current_step = self.current_step % self.sequence.len();
let (note, _vel) = self.sequence[self.current_step];
self.current_voct = Self::midi_note_to_voct(note);
} else {
self.current_step = 0;
}
self.sequence_dirty = false;
}
fn advance_step(&mut self) {
if self.sequence.is_empty() {
return;
}
if self.direction == ArpDirection::Random {
// Stateful xorshift32 PRNG — evolves independently of current_step
let mut x = self.rng_state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.rng_state = x;
// Use upper bits (better distribution) and exclude current note
if self.sequence.len() > 1 {
let pick = ((x >> 16) as usize) % (self.sequence.len() - 1);
self.current_step = if pick >= self.current_step { pick + 1 } else { pick };
}
} else {
self.current_step = (self.current_step + 1) % self.sequence.len();
}
}
fn step_changed(&mut self, new_step: usize) {
let old_step = self.current_step;
self.current_step = new_step;
if !self.sequence.is_empty() {
let (note, _vel) = self.sequence[self.current_step];
self.current_voct = Self::midi_note_to_voct(note);
}
// Start retrigger gap if enabled and the step actually changed
if self.retrigger && old_step != new_step {
self.retrigger_countdown = RETRIGGER_SAMPLES;
}
}
}
impl AudioNode for ArpeggiatorNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_MODE => self.mode = ArpMode::from_f32(value),
PARAM_DIRECTION => {
let new_dir = ArpDirection::from_f32(value);
if new_dir != self.direction {
self.direction = new_dir;
self.going_up = true;
self.sequence_dirty = true;
}
}
PARAM_OCTAVES => {
// UI sends 0-3 (combo box index), map to 1-4 octaves
let new_oct = (value.round() as u32 + 1).clamp(1, 4);
if new_oct != self.octaves {
self.octaves = new_oct;
self.sequence_dirty = true;
}
}
PARAM_RETRIGGER => self.retrigger = value.round() as i32 >= 1,
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_MODE => self.mode as i32 as f32,
PARAM_DIRECTION => self.direction as i32 as f32,
PARAM_OCTAVES => (self.octaves - 1) as f32,
PARAM_RETRIGGER => if self.retrigger { 1.0 } else { 0.0 },
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
// Process incoming MIDI to build held_notes
if !midi_inputs.is_empty() {
for event in midi_inputs[0] {
let status = event.status & 0xF0;
match status {
0x90 if event.data2 > 0 => {
// Note on — add to held notes (sorted by pitch)
let note = event.data1;
let vel = event.data2;
// Remove if already held (avoid duplicates)
self.held_notes.retain(|&(n, _)| n != note);
// Insert sorted by pitch
let pos = self.held_notes.partition_point(|&(n, _)| n < note);
self.held_notes.insert(pos, (note, vel));
self.sequence_dirty = true;
}
0x80 | 0x90 => {
// Note off
let note = event.data1;
self.held_notes.retain(|&(n, _)| n != note);
self.sequence_dirty = true;
}
_ => {}
}
}
}
// Rebuild sequence if needed
if self.sequence_dirty {
self.rebuild_sequence();
}
if outputs.len() < 2 {
return;
}
let len = outputs[0].len();
// If no notes held, output silence
if self.sequence.is_empty() {
for i in 0..len {
outputs[0][i] = self.current_voct;
outputs[1][i] = 0.0;
}
self.current_gate = 0.0;
return;
}
for i in 0..len {
let phase = cv_input_or_default(inputs, 0, i, 0.0).clamp(0.0, 1.0);
match self.mode {
ArpMode::OnePerCycle => {
// Detect phase wraparound (high → low = new cycle)
if self.prev_phase > 0.7 && phase < 0.3 {
self.advance_step();
let step = self.current_step;
self.step_changed(step);
}
}
ArpMode::AllPerCycle => {
// Phase 0→1 maps across all sequence notes
let new_step = ((phase * self.sequence.len() as f32).floor() as usize)
.min(self.sequence.len() - 1);
if new_step != self.current_step {
self.step_changed(new_step);
}
}
}
self.prev_phase = phase;
// Gate: off if retriggering, on otherwise
if self.retrigger_countdown > 0 {
self.retrigger_countdown -= 1;
self.current_gate = 0.0;
} else {
self.current_gate = 1.0;
}
outputs[0][i] = self.current_voct;
outputs[1][i] = self.current_gate;
}
}
fn reset(&mut self) {
self.held_notes.clear();
self.sequence.clear();
self.current_step = 0;
self.prev_phase = 0.0;
self.retrigger_countdown = 0;
self.current_voct = 0.0;
self.current_gate = 0.0;
self.going_up = true;
}
fn node_type(&self) -> &str {
"Arpeggiator"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
held_notes: Vec::new(),
sequence: Vec::new(),
current_step: 0,
prev_phase: 0.0,
retrigger_countdown: 0,
current_voct: 0.0,
current_gate: 0.0,
mode: self.mode,
direction: self.direction,
octaves: self.octaves,
retrigger: self.retrigger,
going_up: true,
sequence_dirty: false,
rng_state: 12345,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,127 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType};
use crate::audio::midi::MidiEvent;
/// Audio input node - receives audio from audio track clip playback
/// This node acts as the entry point for audio tracks, injecting clip audio into the effects graph
pub struct AudioInputNode {
name: String,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
/// Internal buffer to hold injected audio from clips
/// This is filled externally by AudioTrack::render() before graph processing
audio_buffer: Vec<f32>,
}
impl AudioInputNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
// Audio input node has no inputs - audio is injected externally
let inputs = vec![];
// Outputs stereo audio
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
Self {
name,
inputs,
outputs,
audio_buffer: Vec::new(),
}
}
/// Inject audio from clip playback into this node
/// Should be called by AudioTrack::render() before processing the graph
pub fn inject_audio(&mut self, audio: &[f32]) {
self.audio_buffer.clear();
self.audio_buffer.extend_from_slice(audio);
}
/// Clear the internal audio buffer
pub fn clear_buffer(&mut self) {
self.audio_buffer.clear();
}
}
impl AudioNode for AudioInputNode {
fn category(&self) -> NodeCategory {
NodeCategory::Input
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&[] // No parameters
}
fn set_parameter(&mut self, _id: u32, _value: f32) {
// No parameters
}
fn get_parameter(&self, _id: u32) -> f32 {
0.0
}
fn process(
&mut self,
_inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let len = output.len().min(self.audio_buffer.len());
// Copy audio from internal buffer to output
if len > 0 {
output[..len].copy_from_slice(&self.audio_buffer[..len]);
}
// Clear any remaining samples in output
if output.len() > len {
output[len..].fill(0.0);
}
}
fn reset(&mut self) {
self.audio_buffer.clear();
}
fn node_type(&self) -> &str {
"AudioInput"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
audio_buffer: Vec::new(), // Don't clone the buffer, start fresh
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,109 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType};
use crate::audio::midi::MidiEvent;
/// Audio to CV converter
/// Directly converts a stereo audio signal to mono CV (averages L+R channels)
pub struct AudioToCVNode {
name: String,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl AudioToCVNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("CV Out", SignalType::CV, 0),
];
Self {
name,
inputs,
outputs,
parameters: Vec::new(),
}
}
}
impl AudioNode for AudioToCVNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, _id: u32, _value: f32) {}
fn get_parameter(&self, _id: u32) -> f32 {
0.0
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
let input = inputs[0];
let output = &mut outputs[0];
// Audio input is stereo (interleaved L/R), CV output is mono
let audio_frames = input.len() / 2;
let frames = audio_frames.min(output.len());
for frame in 0..frames {
let left = input[frame * 2];
let right = input[frame * 2 + 1];
output[frame] = (left + right) * 0.5;
}
}
fn reset(&mut self) {}
fn node_type(&self) -> &str {
"AudioToCV"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,305 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use crate::time::Beats;
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
/// Interpolation type for automation curves
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum InterpolationType {
Linear,
Bezier,
Step,
Hold,
}
/// A single keyframe in an automation curve
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutomationKeyframe {
pub time: Beats,
/// CV output value
pub value: f32,
/// Interpolation type to next keyframe
pub interpolation: InterpolationType,
/// Bezier ease-out control point (for bezier interpolation)
pub ease_out: (f32, f32),
/// Bezier ease-in control point (for bezier interpolation)
pub ease_in: (f32, f32),
}
impl AutomationKeyframe {
pub fn new(time: Beats, value: f32) -> Self {
Self {
time,
value,
interpolation: InterpolationType::Linear,
ease_out: (0.58, 1.0),
ease_in: (0.42, 0.0),
}
}
}
/// Automation Input Node - outputs CV signal controlled by timeline curves
pub struct AutomationInputNode {
name: String,
display_name: String, // User-editable name shown in UI
keyframes: Vec<AutomationKeyframe>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
/// Shared playback time in beats (set by the graph before processing)
playback_time: Arc<RwLock<Beats>>,
/// Current BPM (set by the graph before processing, used for per-sample beat advancement)
bpm: f64,
/// Minimum output value (for UI display range)
pub value_min: f32,
/// Maximum output value (for UI display range)
pub value_max: f32,
}
impl AutomationInputNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let outputs = vec![
NodePort::new("CV Out", SignalType::CV, 0),
];
let parameters = vec![
Parameter::new(0, "Min", f32::NEG_INFINITY, f32::INFINITY, -1.0, ParameterUnit::Generic),
Parameter::new(1, "Max", f32::NEG_INFINITY, f32::INFINITY, 1.0, ParameterUnit::Generic),
];
Self {
name: name.clone(),
display_name: "Automation".to_string(),
keyframes: vec![AutomationKeyframe::new(Beats::ZERO, 0.0)],
outputs,
parameters,
playback_time: Arc::new(RwLock::new(Beats::ZERO)),
bpm: 120.0,
value_min: -1.0,
value_max: 1.0,
}
}
pub fn set_playback_time(&mut self, time: Beats) {
if let Ok(mut playback) = self.playback_time.write() {
*playback = time;
}
}
pub fn set_bpm(&mut self, bpm: f64) {
self.bpm = bpm;
}
/// Get the display name (shown in UI)
pub fn display_name(&self) -> &str {
&self.display_name
}
/// Set the display name
pub fn set_display_name(&mut self, name: String) {
self.display_name = name;
}
/// Add a keyframe to the curve (maintains sorted order by time)
pub fn add_keyframe(&mut self, keyframe: AutomationKeyframe) {
// Find insertion position to maintain sorted order
let pos = self.keyframes.binary_search_by(|kf| {
kf.time.partial_cmp(&keyframe.time).unwrap_or(std::cmp::Ordering::Equal)
});
match pos {
Ok(idx) => {
// Replace existing keyframe at same time
self.keyframes[idx] = keyframe;
}
Err(idx) => {
// Insert at correct position
self.keyframes.insert(idx, keyframe);
}
}
}
pub fn remove_keyframe_at_time(&mut self, time: Beats, tolerance: Beats) -> bool {
if let Some(idx) = self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) {
self.keyframes.remove(idx);
true
} else {
false
}
}
pub fn update_keyframe(&mut self, keyframe: AutomationKeyframe) {
self.remove_keyframe_at_time(keyframe.time, Beats(0.001));
self.add_keyframe(keyframe);
}
/// Get all keyframes
pub fn keyframes(&self) -> &[AutomationKeyframe] {
&self.keyframes
}
/// Clear all keyframes
pub fn clear_keyframes(&mut self) {
self.keyframes.clear();
}
fn evaluate_at_time(&self, time: Beats) -> f32 {
if self.keyframes.is_empty() {
return 0.0;
}
if time <= self.keyframes[0].time {
return self.keyframes[0].value;
}
let last_idx = self.keyframes.len() - 1;
if time >= self.keyframes[last_idx].time {
return self.keyframes[last_idx].value;
}
for i in 0..self.keyframes.len() - 1 {
let kf1 = &self.keyframes[i];
let kf2 = &self.keyframes[i + 1];
if time >= kf1.time && time <= kf2.time {
return self.interpolate(kf1, kf2, time);
}
}
0.0
}
fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: Beats) -> f32 {
let t = if kf2.time == kf1.time {
0.0f64
} else {
(time - kf1.time) / (kf2.time - kf1.time)
} as f32;
match kf1.interpolation {
InterpolationType::Linear => {
// Simple linear interpolation
kf1.value + (kf2.value - kf1.value) * t
}
InterpolationType::Bezier => {
// Cubic bezier interpolation using control points
let eased_t = self.cubic_bezier_ease(t, kf1.ease_out, kf2.ease_in);
kf1.value + (kf2.value - kf1.value) * eased_t
}
InterpolationType::Step | InterpolationType::Hold => {
// Hold value until next keyframe
kf1.value
}
}
}
/// Cubic bezier easing function
fn cubic_bezier_ease(&self, t: f32, ease_out: (f32, f32), ease_in: (f32, f32)) -> f32 {
// Simplified cubic bezier for 0,0 -> easeOut -> easeIn -> 1,1
let u = 1.0 - t;
3.0 * u * u * t * ease_out.1 +
3.0 * u * t * t * ease_in.1 +
t * t * t
}
}
impl AudioNode for AutomationInputNode {
fn category(&self) -> NodeCategory {
NodeCategory::Input
}
fn inputs(&self) -> &[NodePort] {
&[] // No inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
0 => self.value_min = value,
1 => self.value_max = value,
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
0 => self.value_min,
1 => self.value_max,
_ => 0.0,
}
}
fn process(
&mut self,
_inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let length = output.len();
let playhead = if let Ok(playback) = self.playback_time.read() {
*playback
} else {
Beats::ZERO
};
// Advance per sample in beats: beats_per_sample = bpm / 60 / sample_rate
let beats_per_sample = self.bpm / 60.0 / sample_rate as f64;
for i in 0..length {
let time = playhead + Beats(i as f64 * beats_per_sample);
output[i] = self.evaluate_at_time(time);
}
}
fn reset(&mut self) {
// No state to reset
}
fn node_type(&self) -> &str {
"AutomationInput"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
display_name: self.display_name.clone(),
keyframes: self.keyframes.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
playback_time: Arc::new(RwLock::new(Beats::ZERO)),
bpm: self.bpm,
value_min: self.value_min,
value_max: self.value_max,
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,231 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use crate::time::Beats;
const PARAM_RESOLUTION: u32 = 0;
const DEFAULT_BPM: f32 = 120.0;
const DEFAULT_BEATS_PER_BAR: u32 = 4;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BeatResolution {
Whole = 0, // 1/1
Half = 1, // 1/2
Quarter = 2, // 1/4
Eighth = 3, // 1/8
Sixteenth = 4, // 1/16
QuarterT = 5, // 1/4 triplet
EighthT = 6, // 1/8 triplet
}
impl BeatResolution {
fn from_f32(value: f32) -> Self {
match value.round() as i32 {
0 => BeatResolution::Whole,
1 => BeatResolution::Half,
2 => BeatResolution::Quarter,
3 => BeatResolution::Eighth,
4 => BeatResolution::Sixteenth,
5 => BeatResolution::QuarterT,
6 => BeatResolution::EighthT,
_ => BeatResolution::Quarter,
}
}
/// How many subdivisions per quarter note beat
fn subdivisions_per_beat(&self) -> f64 {
match self {
BeatResolution::Whole => 0.25, // 1 per 4 beats
BeatResolution::Half => 0.5, // 1 per 2 beats
BeatResolution::Quarter => 1.0, // 1 per beat
BeatResolution::Eighth => 2.0, // 2 per beat
BeatResolution::Sixteenth => 4.0, // 4 per beat
BeatResolution::QuarterT => 1.5, // 3 per 2 beats (triplet)
BeatResolution::EighthT => 3.0, // 3 per beat (triplet)
}
}
}
/// Beat clock node — generates tempo-synced CV signals.
///
/// BPM and time signature are synced from the project document via SetTempo.
/// When playing: synced to timeline position.
/// When stopped: free-runs continuously at the project BPM.
///
/// Outputs:
/// - BPM: constant CV proportional to tempo (bpm / 240)
/// - Beat Phase: sawtooth 0→1 per beat subdivision
/// - Bar Phase: sawtooth 0→1 per bar (uses project time signature)
/// - Gate: 1.0 for first half of each subdivision, 0.0 otherwise
pub struct BeatNode {
name: String,
bpm: f32,
beats_per_bar: u32,
resolution: BeatResolution,
/// Playback time in beats, set by the graph before process()
playback_time: Beats,
/// Previous playback_time to detect paused state
prev_playback_time: Beats,
/// Free-running beat accumulator for when playback is stopped
free_run_time: Beats,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl BeatNode {
pub fn new(name: impl Into<String>) -> Self {
let inputs = vec![];
let outputs = vec![
NodePort::new("BPM", SignalType::CV, 0),
NodePort::new("Beat Phase", SignalType::CV, 1),
NodePort::new("Bar Phase", SignalType::CV, 2),
NodePort::new("Gate", SignalType::CV, 3),
];
let parameters = vec![
Parameter::new(PARAM_RESOLUTION, "Resolution", 0.0, 6.0, 2.0, ParameterUnit::Generic),
];
Self {
name: name.into(),
bpm: DEFAULT_BPM,
beats_per_bar: DEFAULT_BEATS_PER_BAR,
resolution: BeatResolution::Quarter,
playback_time: Beats::ZERO,
prev_playback_time: Beats(-1.0),
free_run_time: Beats::ZERO,
inputs,
outputs,
parameters,
}
}
pub fn set_playback_time(&mut self, time: Beats) {
self.playback_time = time;
}
pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) {
self.bpm = bpm;
self.beats_per_bar = beats_per_bar;
}
}
impl AudioNode for BeatNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_RESOLUTION => self.resolution = BeatResolution::from_f32(value),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_RESOLUTION => self.resolution as i32 as f32,
_ => 0.0,
}
}
fn process(
&mut self,
_inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if outputs.len() < 4 {
return;
}
let bpm_cv = (self.bpm / 240.0).clamp(0.0, 1.0);
let len = outputs[0].len();
let sample_period = 1.0 / sample_rate as f64;
// Detect paused: playback_time hasn't changed since last process()
let paused = self.playback_time == self.prev_playback_time;
self.prev_playback_time = self.playback_time;
let beats_per_second = self.bpm as f64 / 60.0;
let subs_per_beat = self.resolution.subdivisions_per_beat();
// Choose time source: timeline when playing, free-running when stopped
let base_time = if paused { self.free_run_time } else { self.playback_time };
for i in 0..len {
// base_time is already in beats; advance by beats_per_second per second
let beat_pos = base_time.0 + i as f64 * beats_per_second * sample_period;
// Beat subdivision phase: 0→1 sawtooth
let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32;
// Bar phase: 0→1 over one bar (beats_per_bar beats)
let bar_phase = ((beat_pos / self.beats_per_bar as f64) % 1.0) as f32;
// Gate: high for first half of each subdivision
let gate = if sub_phase < 0.5 { 1.0f32 } else { 0.0 };
outputs[0][i] = bpm_cv;
outputs[1][i] = sub_phase;
outputs[2][i] = bar_phase;
outputs[3][i] = gate;
}
// Advance free-run time (always ticks, so it's ready when playback stops)
self.free_run_time += Beats(len as f64 * beats_per_second * sample_period);
}
fn reset(&mut self) {
self.playback_time = Beats::ZERO;
self.prev_playback_time = Beats(-1.0);
self.free_run_time = Beats::ZERO;
}
fn node_type(&self) -> &str {
"Beat"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
bpm: self.bpm,
beats_per_bar: self.beats_per_bar,
resolution: self.resolution,
playback_time: Beats::ZERO,
prev_playback_time: Beats(-1.0),
free_run_time: Beats::ZERO,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,195 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_BIT_DEPTH: u32 = 0;
const PARAM_SAMPLE_RATE_REDUCTION: u32 = 1;
const PARAM_MIX: u32 = 2;
/// Bit Crusher effect - reduces bit depth and sample rate for lo-fi sound
pub struct BitCrusherNode {
name: String,
bit_depth: f32, // 1 to 16 bits
sample_rate_reduction: f32, // 1 to 48000 Hz (crushed sample rate)
mix: f32, // 0.0 to 1.0 (dry/wet)
// State for sample rate reduction
hold_left: f32,
hold_right: f32,
hold_counter: f32,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl BitCrusherNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_BIT_DEPTH, "Bit Depth", 1.0, 16.0, 8.0, ParameterUnit::Generic),
Parameter::new(PARAM_SAMPLE_RATE_REDUCTION, "Sample Rate", 100.0, 48000.0, 8000.0, ParameterUnit::Frequency),
Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic),
];
Self {
name,
bit_depth: 8.0,
sample_rate_reduction: 8000.0,
mix: 1.0,
hold_left: 0.0,
hold_right: 0.0,
hold_counter: 0.0,
sample_rate: 48000,
inputs,
outputs,
parameters,
}
}
/// Quantize sample to specified bit depth
fn quantize(&self, sample: f32) -> f32 {
// Calculate number of quantization levels
let levels = 2.0_f32.powf(self.bit_depth);
// Quantize: scale up, round, scale back down
let scaled = sample * levels / 2.0;
let quantized = scaled.round();
quantized * 2.0 / levels
}
}
impl AudioNode for BitCrusherNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_BIT_DEPTH => self.bit_depth = value.clamp(1.0, 16.0),
PARAM_SAMPLE_RATE_REDUCTION => self.sample_rate_reduction = value.clamp(100.0, 48000.0),
PARAM_MIX => self.mix = value.clamp(0.0, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_BIT_DEPTH => self.bit_depth,
PARAM_SAMPLE_RATE_REDUCTION => self.sample_rate_reduction,
PARAM_MIX => self.mix,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
}
let input = inputs[0];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
let frames = input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
// Calculate sample hold period
let hold_period = self.sample_rate as f32 / self.sample_rate_reduction;
for frame in 0..frames_to_process {
let left_in = input[frame * 2];
let right_in = input[frame * 2 + 1];
// Sample rate reduction: hold samples
if self.hold_counter <= 0.0 {
// Time to sample a new value
self.hold_left = self.quantize(left_in);
self.hold_right = self.quantize(right_in);
self.hold_counter = hold_period;
}
self.hold_counter -= 1.0;
// Mix dry and wet
let wet_left = self.hold_left;
let wet_right = self.hold_right;
output[frame * 2] = left_in * (1.0 - self.mix) + wet_left * self.mix;
output[frame * 2 + 1] = right_in * (1.0 - self.mix) + wet_right * self.mix;
}
}
fn reset(&mut self) {
self.hold_left = 0.0;
self.hold_right = 0.0;
self.hold_counter = 0.0;
}
fn node_type(&self) -> &str {
"BitCrusher"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
bit_depth: self.bit_depth,
sample_rate_reduction: self.sample_rate_reduction,
mix: self.mix,
hold_left: 0.0,
hold_right: 0.0,
hold_counter: 0.0,
sample_rate: self.sample_rate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,165 @@
use crate::audio::bpm_detector::BpmDetectorRealtime;
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_SMOOTHING: u32 = 0;
/// BPM Detector Node - analyzes audio input and outputs tempo as CV
/// CV output represents BPM (e.g., 0.12 = 120 BPM when scaled appropriately)
pub struct BpmDetectorNode {
name: String,
detector: BpmDetectorRealtime,
smoothing: f32, // Smoothing factor for output (0-1)
last_output: f32, // For smooth transitions
sample_rate: u32, // Current sample rate
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl BpmDetectorNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("BPM CV", SignalType::CV, 0),
];
let parameters = vec![
Parameter::new(PARAM_SMOOTHING, "Smoothing", 0.0, 1.0, 0.9, ParameterUnit::Percent),
];
// Use 10 second buffer for analysis
let detector = BpmDetectorRealtime::new(48000, 10.0);
Self {
name,
detector,
smoothing: 0.9,
last_output: 120.0,
sample_rate: 48000,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for BpmDetectorNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_SMOOTHING => self.smoothing = value.clamp(0.0, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_SMOOTHING => self.smoothing,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
// Recreate detector if sample rate changed
if sample_rate != self.sample_rate {
self.sample_rate = sample_rate;
self.detector = BpmDetectorRealtime::new(sample_rate, 10.0);
}
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let length = output.len();
let input = if !inputs.is_empty() && !inputs[0].is_empty() {
inputs[0]
} else {
// Fill output with last known BPM
for i in 0..length {
output[i] = self.last_output / 1000.0; // Scale BPM for CV (e.g., 120 BPM -> 0.12)
}
return;
};
// Process audio through detector
let detected_bpm = self.detector.process(input);
// Apply smoothing
let target_bpm = detected_bpm;
let smoothed_bpm = self.last_output * self.smoothing + target_bpm * (1.0 - self.smoothing);
self.last_output = smoothed_bpm;
// Output BPM as CV (scaled down for typical CV range)
// BPM / 1000 gives us reasonable CV values (60-180 BPM -> 0.06-0.18)
let cv_value = smoothed_bpm / 1000.0;
// Fill entire output buffer with current BPM value
for i in 0..length {
output[i] = cv_value;
}
}
fn reset(&mut self) {
self.detector.reset();
self.last_output = 120.0;
}
fn node_type(&self) -> &str {
"BpmDetector"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
detector: BpmDetectorRealtime::new(self.sample_rate, 10.0),
smoothing: self.smoothing,
last_output: self.last_output,
sample_rate: self.sample_rate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,50 @@
use nam_ffi::NamModel;
struct BundledModel {
name: &'static str,
filename: &'static str,
data: &'static [u8],
}
const BUNDLED_MODELS: &[BundledModel] = &[
BundledModel {
name: "BossSD1",
filename: "BossSD1-WaveNet.nam",
data: include_bytes!("../../../../../src/assets/nam_models/BossSD1-WaveNet.nam"),
},
BundledModel {
name: "DeluxeReverb",
filename: "DeluxeReverb.nam",
data: include_bytes!("../../../../../src/assets/nam_models/DeluxeReverb.nam"),
},
BundledModel {
name: "DingwallBass",
filename: "DingwallBass.nam",
data: include_bytes!("../../../../../src/assets/nam_models/DingwallBass.nam"),
},
BundledModel {
name: "Rhythm",
filename: "Rhythm.nam",
data: include_bytes!("../../../../../src/assets/nam_models/Rhythm.nam"),
},
];
/// Return display names of all bundled NAM models.
pub fn bundled_model_names() -> Vec<&'static str> {
BUNDLED_MODELS.iter().map(|m| m.name).collect()
}
/// Load a bundled NAM model by display name.
/// Returns `None` if the name isn't found, `Some(Err(...))` on load failure.
pub fn load_bundled_model(name: &str) -> Option<Result<NamModel, String>> {
eprintln!("[NAM] load_bundled_model: looking up {:?}", name);
let model = BUNDLED_MODELS.iter().find(|m| m.name == name)?;
eprintln!("[NAM] Found bundled model: name={}, filename={}, data_len={}", model.name, model.filename, model.data.len());
Some(
NamModel::from_bytes(model.filename, model.data)
.map_err(|e| {
eprintln!("[NAM] from_bytes failed for {}: {}", model.filename, e);
e.to_string()
}),
)
}

View File

@ -0,0 +1,242 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use std::f32::consts::PI;
const PARAM_RATE: u32 = 0;
const PARAM_DEPTH: u32 = 1;
const PARAM_WET_DRY: u32 = 2;
const MAX_DELAY_MS: f32 = 50.0;
const BASE_DELAY_MS: f32 = 15.0;
/// Chorus effect using modulated delay lines
pub struct ChorusNode {
name: String,
rate: f32, // LFO rate in Hz (0.1 to 5 Hz)
depth: f32, // Modulation depth 0.0 to 1.0
wet_dry: f32, // 0.0 = dry only, 1.0 = wet only
// Delay buffers for left and right channels
delay_buffer_left: Vec<f32>,
delay_buffer_right: Vec<f32>,
write_position: usize,
max_delay_samples: usize,
sample_rate: u32,
// LFO state
lfo_phase: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl ChorusNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_RATE, "Rate", 0.1, 5.0, 1.0, ParameterUnit::Frequency),
Parameter::new(PARAM_DEPTH, "Depth", 0.0, 1.0, 0.5, ParameterUnit::Generic),
Parameter::new(PARAM_WET_DRY, "Wet/Dry", 0.0, 1.0, 0.5, ParameterUnit::Generic),
];
// Allocate max delay buffer size
let max_delay_samples = ((MAX_DELAY_MS / 1000.0) * 48000.0) as usize;
Self {
name,
rate: 1.0,
depth: 0.5,
wet_dry: 0.5,
delay_buffer_left: vec![0.0; max_delay_samples],
delay_buffer_right: vec![0.0; max_delay_samples],
write_position: 0,
max_delay_samples,
sample_rate: 48000,
lfo_phase: 0.0,
inputs,
outputs,
parameters,
}
}
fn read_interpolated_sample(&self, buffer: &[f32], delay_samples: f32) -> f32 {
// Linear interpolation for smooth delay modulation
let delay_samples = delay_samples.clamp(0.0, (self.max_delay_samples - 1) as f32);
let read_pos_float = self.write_position as f32 - delay_samples;
let read_pos_float = if read_pos_float < 0.0 {
read_pos_float + self.max_delay_samples as f32
} else {
read_pos_float
};
let read_pos_int = read_pos_float.floor() as usize;
let frac = read_pos_float - read_pos_int as f32;
let sample1 = buffer[read_pos_int % self.max_delay_samples];
let sample2 = buffer[(read_pos_int + 1) % self.max_delay_samples];
sample1 * (1.0 - frac) + sample2 * frac
}
}
impl AudioNode for ChorusNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_RATE => {
self.rate = value.clamp(0.1, 5.0);
}
PARAM_DEPTH => {
self.depth = value.clamp(0.0, 1.0);
}
PARAM_WET_DRY => {
self.wet_dry = value.clamp(0.0, 1.0);
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_RATE => self.rate,
PARAM_DEPTH => self.depth,
PARAM_WET_DRY => self.wet_dry,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
self.max_delay_samples = ((MAX_DELAY_MS / 1000.0) * sample_rate as f32) as usize;
self.delay_buffer_left.resize(self.max_delay_samples, 0.0);
self.delay_buffer_right.resize(self.max_delay_samples, 0.0);
self.write_position = 0;
}
let input = inputs[0];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
let frames = input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
let dry_gain = 1.0 - self.wet_dry;
let wet_gain = self.wet_dry;
let base_delay_samples = (BASE_DELAY_MS / 1000.0) * self.sample_rate as f32;
let max_modulation_samples = (MAX_DELAY_MS - BASE_DELAY_MS) / 1000.0 * self.sample_rate as f32;
for frame in 0..frames_to_process {
let left_in = input[frame * 2];
let right_in = input[frame * 2 + 1];
// Generate LFO value (sine wave, 0 to 1)
let lfo_value = ((self.lfo_phase * 2.0 * PI).sin() * 0.5 + 0.5) * self.depth;
// Calculate modulated delay time
let delay_samples = base_delay_samples + lfo_value * max_modulation_samples;
// Read delayed samples with interpolation
let left_delayed = self.read_interpolated_sample(&self.delay_buffer_left, delay_samples);
let right_delayed = self.read_interpolated_sample(&self.delay_buffer_right, delay_samples);
// Mix dry and wet signals
output[frame * 2] = left_in * dry_gain + left_delayed * wet_gain;
output[frame * 2 + 1] = right_in * dry_gain + right_delayed * wet_gain;
// Write to delay buffer
self.delay_buffer_left[self.write_position] = left_in;
self.delay_buffer_right[self.write_position] = right_in;
// Advance write position
self.write_position = (self.write_position + 1) % self.max_delay_samples;
// Advance LFO phase
self.lfo_phase += self.rate / self.sample_rate as f32;
if self.lfo_phase >= 1.0 {
self.lfo_phase -= 1.0;
}
}
}
fn reset(&mut self) {
self.delay_buffer_left.fill(0.0);
self.delay_buffer_right.fill(0.0);
self.write_position = 0;
self.lfo_phase = 0.0;
}
fn node_type(&self) -> &str {
"Chorus"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
rate: self.rate,
depth: self.depth,
wet_dry: self.wet_dry,
delay_buffer_left: vec![0.0; self.max_delay_samples],
delay_buffer_right: vec![0.0; self.max_delay_samples],
write_position: 0,
max_delay_samples: self.max_delay_samples,
sample_rate: self.sample_rate,
lfo_phase: 0.0,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,261 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_THRESHOLD: u32 = 0;
const PARAM_RATIO: u32 = 1;
const PARAM_ATTACK: u32 = 2;
const PARAM_RELEASE: u32 = 3;
const PARAM_MAKEUP_GAIN: u32 = 4;
const PARAM_KNEE: u32 = 5;
/// Compressor node for dynamic range compression
pub struct CompressorNode {
name: String,
threshold_db: f32,
ratio: f32,
attack_ms: f32,
release_ms: f32,
makeup_gain_db: f32,
knee_db: f32,
// State
envelope: f32,
attack_coeff: f32,
release_coeff: f32,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl CompressorNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_THRESHOLD, "Threshold", -60.0, 0.0, -20.0, ParameterUnit::Decibels),
Parameter::new(PARAM_RATIO, "Ratio", 1.0, 20.0, 4.0, ParameterUnit::Generic),
Parameter::new(PARAM_ATTACK, "Attack", 0.1, 100.0, 5.0, ParameterUnit::Time),
Parameter::new(PARAM_RELEASE, "Release", 10.0, 1000.0, 50.0, ParameterUnit::Time),
Parameter::new(PARAM_MAKEUP_GAIN, "Makeup", 0.0, 24.0, 0.0, ParameterUnit::Decibels),
Parameter::new(PARAM_KNEE, "Knee", 0.0, 12.0, 3.0, ParameterUnit::Decibels),
];
let sample_rate = 44100;
let attack_coeff = Self::ms_to_coeff(5.0, sample_rate);
let release_coeff = Self::ms_to_coeff(50.0, sample_rate);
Self {
name,
threshold_db: -20.0,
ratio: 4.0,
attack_ms: 5.0,
release_ms: 50.0,
makeup_gain_db: 0.0,
knee_db: 3.0,
envelope: 0.0,
attack_coeff,
release_coeff,
sample_rate,
inputs,
outputs,
parameters,
}
}
/// Convert milliseconds to exponential smoothing coefficient
fn ms_to_coeff(time_ms: f32, sample_rate: u32) -> f32 {
let time_seconds = time_ms / 1000.0;
let samples = time_seconds * sample_rate as f32;
(-1.0 / samples).exp()
}
fn update_coefficients(&mut self) {
self.attack_coeff = Self::ms_to_coeff(self.attack_ms, self.sample_rate);
self.release_coeff = Self::ms_to_coeff(self.release_ms, self.sample_rate);
}
/// Convert linear amplitude to dB
fn linear_to_db(linear: f32) -> f32 {
if linear > 0.0 {
20.0 * linear.log10()
} else {
-160.0
}
}
/// Convert dB to linear gain
fn db_to_linear(db: f32) -> f32 {
10.0_f32.powf(db / 20.0)
}
/// Calculate gain reduction for a given input level
fn calculate_gain_reduction(&self, input_db: f32) -> f32 {
let threshold = self.threshold_db;
let knee = self.knee_db;
let ratio = self.ratio;
// Soft knee implementation
if input_db < threshold - knee / 2.0 {
// Below threshold - no compression
0.0
} else if input_db > threshold + knee / 2.0 {
// Above threshold - full compression
let overshoot = input_db - threshold;
overshoot * (1.0 - 1.0 / ratio)
} else {
// In knee region - gradual compression
let overshoot = input_db - threshold + knee / 2.0;
let knee_factor = overshoot / knee;
overshoot * knee_factor * (1.0 - 1.0 / ratio) / 2.0
}
}
fn process_sample(&mut self, input: f32) -> f32 {
// Detect input level (using absolute value as simple peak detector)
let input_level = input.abs();
// Convert to dB
let input_db = Self::linear_to_db(input_level);
// Calculate target gain reduction
let target_gr_db = self.calculate_gain_reduction(input_db);
let target_gr_linear = Self::db_to_linear(-target_gr_db);
// Smooth envelope with attack/release
let coeff = if target_gr_linear < self.envelope {
self.attack_coeff // Attack (faster response to louder signal)
} else {
self.release_coeff // Release (slower response when signal gets quieter)
};
self.envelope = target_gr_linear + coeff * (self.envelope - target_gr_linear);
// Apply compression and makeup gain
let makeup_linear = Self::db_to_linear(self.makeup_gain_db);
input * self.envelope * makeup_linear
}
}
impl AudioNode for CompressorNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_THRESHOLD => self.threshold_db = value,
PARAM_RATIO => self.ratio = value,
PARAM_ATTACK => {
self.attack_ms = value;
self.update_coefficients();
}
PARAM_RELEASE => {
self.release_ms = value;
self.update_coefficients();
}
PARAM_MAKEUP_GAIN => self.makeup_gain_db = value,
PARAM_KNEE => self.knee_db = value,
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_THRESHOLD => self.threshold_db,
PARAM_RATIO => self.ratio,
PARAM_ATTACK => self.attack_ms,
PARAM_RELEASE => self.release_ms,
PARAM_MAKEUP_GAIN => self.makeup_gain_db,
PARAM_KNEE => self.knee_db,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
self.update_coefficients();
}
let input = inputs[0];
let output = &mut outputs[0];
let len = input.len().min(output.len());
for i in 0..len {
output[i] = self.process_sample(input[i]);
}
}
fn reset(&mut self) {
self.envelope = 0.0;
}
fn node_type(&self) -> &str {
"Compressor"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
threshold_db: self.threshold_db,
ratio: self.ratio,
attack_ms: self.attack_ms,
release_ms: self.release_ms,
makeup_gain_db: self.makeup_gain_db,
knee_db: self.knee_db,
envelope: 0.0, // Reset state for clone
attack_coeff: self.attack_coeff,
release_coeff: self.release_coeff,
sample_rate: self.sample_rate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,121 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_VALUE: u32 = 0;
/// Constant CV source - outputs a constant voltage
/// Useful for providing fixed values to CV inputs, offsets, etc.
pub struct ConstantNode {
name: String,
value: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl ConstantNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![];
let outputs = vec![
NodePort::new("CV Out", SignalType::CV, 0),
];
let parameters = vec![
Parameter::new(PARAM_VALUE, "Value", -10.0, 10.0, 0.0, ParameterUnit::Generic),
];
Self {
name,
value: 0.0,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for ConstantNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_VALUE => self.value = value.clamp(-10.0, 10.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_VALUE => self.value,
_ => 0.0,
}
}
fn process(
&mut self,
_inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let length = output.len();
// Fill output with constant value
for i in 0..length {
output[i] = self.value;
}
}
fn reset(&mut self) {
// No state to reset
}
fn node_type(&self) -> &str {
"Constant"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
value: self.value,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,265 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_DRIVE: u32 = 0;
const PARAM_TYPE: u32 = 1;
const PARAM_TONE: u32 = 2;
const PARAM_MIX: u32 = 3;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DistortionType {
SoftClip = 0,
HardClip = 1,
Tanh = 2,
Asymmetric = 3,
}
impl DistortionType {
fn from_f32(value: f32) -> Self {
match value.round() as i32 {
1 => DistortionType::HardClip,
2 => DistortionType::Tanh,
3 => DistortionType::Asymmetric,
_ => DistortionType::SoftClip,
}
}
}
/// Distortion node with multiple waveshaping algorithms
pub struct DistortionNode {
name: String,
drive: f32, // 0.01 to 20.0 (linear gain)
distortion_type: DistortionType,
tone: f32, // 0.0 to 1.0 (low-pass filter cutoff)
mix: f32, // 0.0 to 1.0 (dry/wet)
// Tone filter state (simple one-pole low-pass)
filter_state_left: f32,
filter_state_right: f32,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl DistortionNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_DRIVE, "Drive", 0.01, 20.0, 1.0, ParameterUnit::Generic),
Parameter::new(PARAM_TYPE, "Type", 0.0, 3.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_TONE, "Tone", 0.0, 1.0, 0.7, ParameterUnit::Generic),
Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic),
];
Self {
name,
drive: 1.0,
distortion_type: DistortionType::SoftClip,
tone: 0.7,
mix: 1.0,
filter_state_left: 0.0,
filter_state_right: 0.0,
sample_rate: 44100,
inputs,
outputs,
parameters,
}
}
/// Soft clipping using cubic waveshaping
fn soft_clip(&self, x: f32) -> f32 {
let x = x.clamp(-2.0, 2.0);
if x.abs() <= 1.0 {
x
} else {
let sign = x.signum();
sign * (2.0 - (2.0 - x.abs()).powi(2)) / 2.0
}
}
/// Hard clipping
fn hard_clip(&self, x: f32) -> f32 {
x.clamp(-1.0, 1.0)
}
/// Hyperbolic tangent waveshaping
fn tanh_distortion(&self, x: f32) -> f32 {
x.tanh()
}
/// Asymmetric waveshaping (different curves for positive/negative)
fn asymmetric(&self, x: f32) -> f32 {
if x >= 0.0 {
// Positive: soft clip
self.soft_clip(x)
} else {
// Negative: harder clip
self.hard_clip(x * 1.5) / 1.5
}
}
/// Apply waveshaping based on type
fn apply_waveshaping(&self, x: f32) -> f32 {
match self.distortion_type {
DistortionType::SoftClip => self.soft_clip(x),
DistortionType::HardClip => self.hard_clip(x),
DistortionType::Tanh => self.tanh_distortion(x),
DistortionType::Asymmetric => self.asymmetric(x),
}
}
/// Simple one-pole low-pass filter for tone control
fn apply_tone_filter(&mut self, input: f32, is_left: bool) -> f32 {
// Tone parameter controls cutoff frequency (0 = dark, 1 = bright)
// Map tone to filter coefficient (0.1 to 0.99)
let coeff = 0.1 + self.tone * 0.89;
let state = if is_left {
&mut self.filter_state_left
} else {
&mut self.filter_state_right
};
*state = *state * coeff + input * (1.0 - coeff);
*state
}
fn process_sample(&mut self, input: f32, is_left: bool) -> f32 {
// Apply drive (input gain)
let driven = input * self.drive;
// Apply waveshaping
let distorted = self.apply_waveshaping(driven);
// Apply tone control (low-pass filter to tame harshness)
let filtered = self.apply_tone_filter(distorted, is_left);
// Apply output gain compensation and mix
let output_gain = 1.0 / (1.0 + self.drive * 0.2); // Compensate for loudness increase
let wet = filtered * output_gain;
let dry = input;
// Mix dry and wet
dry * (1.0 - self.mix) + wet * self.mix
}
}
impl AudioNode for DistortionNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_DRIVE => self.drive = value.clamp(0.01, 20.0),
PARAM_TYPE => self.distortion_type = DistortionType::from_f32(value),
PARAM_TONE => self.tone = value.clamp(0.0, 1.0),
PARAM_MIX => self.mix = value.clamp(0.0, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_DRIVE => self.drive,
PARAM_TYPE => self.distortion_type as i32 as f32,
PARAM_TONE => self.tone,
PARAM_MIX => self.mix,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
}
let input = inputs[0];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
let frames = input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
for frame in 0..frames_to_process {
let left_in = input[frame * 2];
let right_in = input[frame * 2 + 1];
output[frame * 2] = self.process_sample(left_in, true);
output[frame * 2 + 1] = self.process_sample(right_in, false);
}
}
fn reset(&mut self) {
self.filter_state_left = 0.0;
self.filter_state_right = 0.0;
}
fn node_type(&self) -> &str {
"Distortion"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
drive: self.drive,
distortion_type: self.distortion_type,
tone: self.tone,
mix: self.mix,
filter_state_left: 0.0, // Reset state for clone
filter_state_right: 0.0,
sample_rate: self.sample_rate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,219 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_DELAY_TIME: u32 = 0;
const PARAM_FEEDBACK: u32 = 1;
const PARAM_WET_DRY: u32 = 2;
const MAX_DELAY_SECONDS: f32 = 2.0;
/// Stereo echo node with feedback
pub struct EchoNode {
name: String,
delay_time: f32, // seconds
feedback: f32, // 0.0 to 0.95
wet_dry: f32, // 0.0 = dry only, 1.0 = wet only
// Delay buffers for left and right channels
delay_buffer_left: Vec<f32>,
delay_buffer_right: Vec<f32>,
write_position: usize,
max_delay_samples: usize,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl EchoNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_DELAY_TIME, "Delay Time", 0.001, MAX_DELAY_SECONDS, 0.5, ParameterUnit::Time),
Parameter::new(PARAM_FEEDBACK, "Feedback", 0.0, 0.95, 0.5, ParameterUnit::Generic),
Parameter::new(PARAM_WET_DRY, "Wet/Dry", 0.0, 1.0, 0.5, ParameterUnit::Generic),
];
// Allocate max delay buffer size (will be initialized properly when we get sample rate)
let max_delay_samples = (MAX_DELAY_SECONDS * 48000.0) as usize; // Assume max 48kHz
Self {
name,
delay_time: 0.5,
feedback: 0.5,
wet_dry: 0.5,
delay_buffer_left: vec![0.0; max_delay_samples],
delay_buffer_right: vec![0.0; max_delay_samples],
write_position: 0,
max_delay_samples,
sample_rate: 48000,
inputs,
outputs,
parameters,
}
}
fn get_delay_samples(&self) -> usize {
(self.delay_time * self.sample_rate as f32) as usize
}
fn read_delayed_sample(&self, buffer: &[f32], delay_samples: usize) -> f32 {
// Calculate read position (wrap around)
let read_pos = if self.write_position >= delay_samples {
self.write_position - delay_samples
} else {
self.max_delay_samples + self.write_position - delay_samples
};
buffer[read_pos % self.max_delay_samples]
}
}
impl AudioNode for EchoNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_DELAY_TIME => {
self.delay_time = value.clamp(0.001, MAX_DELAY_SECONDS);
}
PARAM_FEEDBACK => {
self.feedback = value.clamp(0.0, 0.95);
}
PARAM_WET_DRY => {
self.wet_dry = value.clamp(0.0, 1.0);
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_DELAY_TIME => self.delay_time,
PARAM_FEEDBACK => self.feedback,
PARAM_WET_DRY => self.wet_dry,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
self.max_delay_samples = (MAX_DELAY_SECONDS * sample_rate as f32) as usize;
self.delay_buffer_left.resize(self.max_delay_samples, 0.0);
self.delay_buffer_right.resize(self.max_delay_samples, 0.0);
self.write_position = 0;
}
let input = inputs[0];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
let frames = input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
let delay_samples = self.get_delay_samples().max(1).min(self.max_delay_samples - 1);
for frame in 0..frames_to_process {
let left_in = input[frame * 2];
let right_in = input[frame * 2 + 1];
// Read delayed samples
let left_delayed = self.read_delayed_sample(&self.delay_buffer_left, delay_samples);
let right_delayed = self.read_delayed_sample(&self.delay_buffer_right, delay_samples);
// Mix dry and wet signals
let dry_gain = 1.0 - self.wet_dry;
let wet_gain = self.wet_dry;
let left_out = left_in * dry_gain + left_delayed * wet_gain;
let right_out = right_in * dry_gain + right_delayed * wet_gain;
output[frame * 2] = left_out;
output[frame * 2 + 1] = right_out;
// Write to delay buffer with feedback
self.delay_buffer_left[self.write_position] = left_in + left_delayed * self.feedback;
self.delay_buffer_right[self.write_position] = right_in + right_delayed * self.feedback;
// Advance write position
self.write_position = (self.write_position + 1) % self.max_delay_samples;
}
}
fn reset(&mut self) {
self.delay_buffer_left.fill(0.0);
self.delay_buffer_right.fill(0.0);
self.write_position = 0;
}
fn node_type(&self) -> &str {
"Echo"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
delay_time: self.delay_time,
feedback: self.feedback,
wet_dry: self.wet_dry,
delay_buffer_left: vec![0.0; self.max_delay_samples],
delay_buffer_right: vec![0.0; self.max_delay_samples],
write_position: 0,
max_delay_samples: self.max_delay_samples,
sample_rate: self.sample_rate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,166 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_ATTACK: u32 = 0;
const PARAM_RELEASE: u32 = 1;
/// Envelope Follower - extracts amplitude envelope from audio signal
/// Outputs a CV signal that follows the loudness of the input
pub struct EnvelopeFollowerNode {
name: String,
attack_time: f32, // seconds
release_time: f32, // seconds
envelope: f32, // current envelope level
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl EnvelopeFollowerNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("CV Out", SignalType::CV, 0),
];
let parameters = vec![
Parameter::new(PARAM_ATTACK, "Attack", 0.001, 1.0, 0.01, ParameterUnit::Time),
Parameter::new(PARAM_RELEASE, "Release", 0.001, 1.0, 0.1, ParameterUnit::Time),
];
Self {
name,
attack_time: 0.01,
release_time: 0.1,
envelope: 0.0,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for EnvelopeFollowerNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_ATTACK => self.attack_time = value.clamp(0.001, 1.0),
PARAM_RELEASE => self.release_time = value.clamp(0.001, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_ATTACK => self.attack_time,
PARAM_RELEASE => self.release_time,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let length = output.len();
let input = if !inputs.is_empty() && !inputs[0].is_empty() {
inputs[0]
} else {
&[]
};
// Calculate filter coefficients
// One-pole filter: y[n] = y[n-1] + coefficient * (x[n] - y[n-1])
let sample_duration = 1.0 / sample_rate as f32;
// Time constant τ = time to reach ~63% of target
// Coefficient = 1 - e^(-1/(τ * sample_rate))
// Simplified approximation: coefficient ≈ sample_duration / time_constant
let attack_coeff = (sample_duration / self.attack_time).min(1.0);
let release_coeff = (sample_duration / self.release_time).min(1.0);
// Process each sample
for i in 0..length {
// Get absolute value of input (rectify)
let input_level = if i < input.len() {
input[i].abs()
} else {
0.0
};
// Apply attack or release
let coeff = if input_level > self.envelope {
attack_coeff // Rising - use attack time
} else {
release_coeff // Falling - use release time
};
// One-pole filter
self.envelope += (input_level - self.envelope) * coeff;
output[i] = self.envelope;
}
}
fn reset(&mut self) {
self.envelope = 0.0;
}
fn node_type(&self) -> &str {
"EnvelopeFollower"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
attack_time: self.attack_time,
release_time: self.release_time,
envelope: self.envelope,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,267 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use crate::dsp::biquad::BiquadFilter;
// Low band (shelving)
const PARAM_LOW_FREQ: u32 = 0;
const PARAM_LOW_GAIN: u32 = 1;
// Mid band (peaking)
const PARAM_MID_FREQ: u32 = 2;
const PARAM_MID_GAIN: u32 = 3;
const PARAM_MID_Q: u32 = 4;
// High band (shelving)
const PARAM_HIGH_FREQ: u32 = 5;
const PARAM_HIGH_GAIN: u32 = 6;
/// 3-Band Parametric EQ Node
/// All three bands use peaking filters at different frequencies
pub struct EQNode {
name: String,
// Parameters
low_freq: f32,
low_gain_db: f32,
low_q: f32,
mid_freq: f32,
mid_gain_db: f32,
mid_q: f32,
high_freq: f32,
high_gain_db: f32,
high_q: f32,
// Filters (stereo)
low_filter_left: BiquadFilter,
low_filter_right: BiquadFilter,
mid_filter_left: BiquadFilter,
mid_filter_right: BiquadFilter,
high_filter_left: BiquadFilter,
high_filter_right: BiquadFilter,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl EQNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_LOW_FREQ, "Low Freq", 20.0, 500.0, 100.0, ParameterUnit::Frequency),
Parameter::new(PARAM_LOW_GAIN, "Low Gain", -24.0, 24.0, 0.0, ParameterUnit::Decibels),
Parameter::new(PARAM_MID_FREQ, "Mid Freq", 200.0, 5000.0, 1000.0, ParameterUnit::Frequency),
Parameter::new(PARAM_MID_GAIN, "Mid Gain", -24.0, 24.0, 0.0, ParameterUnit::Decibels),
Parameter::new(PARAM_MID_Q, "Mid Q", 0.1, 10.0, 0.707, ParameterUnit::Generic),
Parameter::new(PARAM_HIGH_FREQ, "High Freq", 2000.0, 20000.0, 8000.0, ParameterUnit::Frequency),
Parameter::new(PARAM_HIGH_GAIN, "High Gain", -24.0, 24.0, 0.0, ParameterUnit::Decibels),
];
let sample_rate = 44100;
// Initialize filters - all peaking
let low_filter_left = BiquadFilter::peaking(100.0, 1.0, 0.0, sample_rate as f32);
let low_filter_right = BiquadFilter::peaking(100.0, 1.0, 0.0, sample_rate as f32);
let mid_filter_left = BiquadFilter::peaking(1000.0, 0.707, 0.0, sample_rate as f32);
let mid_filter_right = BiquadFilter::peaking(1000.0, 0.707, 0.0, sample_rate as f32);
let high_filter_left = BiquadFilter::peaking(8000.0, 1.0, 0.0, sample_rate as f32);
let high_filter_right = BiquadFilter::peaking(8000.0, 1.0, 0.0, sample_rate as f32);
Self {
name,
low_freq: 100.0,
low_gain_db: 0.0,
low_q: 1.0,
mid_freq: 1000.0,
mid_gain_db: 0.0,
mid_q: 0.707,
high_freq: 8000.0,
high_gain_db: 0.0,
high_q: 1.0,
low_filter_left,
low_filter_right,
mid_filter_left,
mid_filter_right,
high_filter_left,
high_filter_right,
sample_rate,
inputs,
outputs,
parameters,
}
}
fn update_filters(&mut self) {
let sr = self.sample_rate as f32;
// Update low band peaking filter
self.low_filter_left.set_peaking(self.low_freq, self.low_q, self.low_gain_db, sr);
self.low_filter_right.set_peaking(self.low_freq, self.low_q, self.low_gain_db, sr);
// Update mid band peaking filter
self.mid_filter_left.set_peaking(self.mid_freq, self.mid_q, self.mid_gain_db, sr);
self.mid_filter_right.set_peaking(self.mid_freq, self.mid_q, self.mid_gain_db, sr);
// Update high band peaking filter
self.high_filter_left.set_peaking(self.high_freq, self.high_q, self.high_gain_db, sr);
self.high_filter_right.set_peaking(self.high_freq, self.high_q, self.high_gain_db, sr);
}
}
impl AudioNode for EQNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_LOW_FREQ => {
self.low_freq = value;
self.update_filters();
}
PARAM_LOW_GAIN => {
self.low_gain_db = value;
self.update_filters();
}
PARAM_MID_FREQ => {
self.mid_freq = value;
self.update_filters();
}
PARAM_MID_GAIN => {
self.mid_gain_db = value;
self.update_filters();
}
PARAM_MID_Q => {
self.mid_q = value;
self.update_filters();
}
PARAM_HIGH_FREQ => {
self.high_freq = value;
self.update_filters();
}
PARAM_HIGH_GAIN => {
self.high_gain_db = value;
self.update_filters();
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_LOW_FREQ => self.low_freq,
PARAM_LOW_GAIN => self.low_gain_db,
PARAM_MID_FREQ => self.mid_freq,
PARAM_MID_GAIN => self.mid_gain_db,
PARAM_MID_Q => self.mid_q,
PARAM_HIGH_FREQ => self.high_freq,
PARAM_HIGH_GAIN => self.high_gain_db,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
self.update_filters();
}
let input = inputs[0];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
let frames = input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
for frame in 0..frames_to_process {
let mut left = input[frame * 2];
let mut right = input[frame * 2 + 1];
// Process through all three bands
left = self.low_filter_left.process_sample(left, 0);
left = self.mid_filter_left.process_sample(left, 0);
left = self.high_filter_left.process_sample(left, 0);
right = self.low_filter_right.process_sample(right, 1);
right = self.mid_filter_right.process_sample(right, 1);
right = self.high_filter_right.process_sample(right, 1);
output[frame * 2] = left;
output[frame * 2 + 1] = right;
}
}
fn reset(&mut self) {
self.low_filter_left.reset();
self.low_filter_right.reset();
self.mid_filter_left.reset();
self.mid_filter_right.reset();
self.high_filter_left.reset();
self.high_filter_right.reset();
}
fn node_type(&self) -> &str {
"EQ"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
let mut node = Self::new(self.name.clone());
node.low_freq = self.low_freq;
node.low_gain_db = self.low_gain_db;
node.mid_freq = self.mid_freq;
node.mid_gain_db = self.mid_gain_db;
node.mid_q = self.mid_q;
node.high_freq = self.high_freq;
node.high_gain_db = self.high_gain_db;
node.sample_rate = self.sample_rate;
node.update_filters();
Box::new(node)
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,231 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
use crate::dsp::biquad::BiquadFilter;
const PARAM_CUTOFF: u32 = 0;
const PARAM_RESONANCE: u32 = 1;
const PARAM_TYPE: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FilterType {
Lowpass = 0,
Highpass = 1,
}
impl FilterType {
fn from_f32(value: f32) -> Self {
match value.round() as i32 {
1 => FilterType::Highpass,
_ => FilterType::Lowpass,
}
}
}
/// Filter node using biquad implementation
pub struct FilterNode {
name: String,
filter: BiquadFilter,
cutoff: f32,
resonance: f32,
filter_type: FilterType,
sample_rate: u32,
/// Last cutoff frequency applied to filter coefficients (for change detection with CV modulation)
last_applied_cutoff: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl FilterNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
NodePort::new("Cutoff CV", SignalType::CV, 1),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_CUTOFF, "Cutoff", 20.0, 20000.0, 1000.0, ParameterUnit::Frequency),
Parameter::new(PARAM_RESONANCE, "Resonance", 0.1, 10.0, 0.707, ParameterUnit::Generic),
Parameter::new(PARAM_TYPE, "Type", 0.0, 1.0, 0.0, ParameterUnit::Generic),
];
let filter = BiquadFilter::lowpass(1000.0, 0.707, 44100.0);
Self {
name,
filter,
cutoff: 1000.0,
resonance: 0.707,
filter_type: FilterType::Lowpass,
sample_rate: 44100,
last_applied_cutoff: 1000.0,
inputs,
outputs,
parameters,
}
}
fn update_filter(&mut self) {
match self.filter_type {
FilterType::Lowpass => {
self.filter.set_lowpass(self.cutoff, self.resonance, self.sample_rate as f32);
}
FilterType::Highpass => {
self.filter.set_highpass(self.cutoff, self.resonance, self.sample_rate as f32);
}
}
}
}
impl AudioNode for FilterNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_CUTOFF => {
self.cutoff = value.clamp(20.0, 20000.0);
self.update_filter();
}
PARAM_RESONANCE => {
self.resonance = value.clamp(0.1, 10.0);
self.update_filter();
}
PARAM_TYPE => {
self.filter_type = FilterType::from_f32(value);
self.update_filter();
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_CUTOFF => self.cutoff,
PARAM_RESONANCE => self.resonance,
PARAM_TYPE => self.filter_type as i32 as f32,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
self.update_filter();
}
let input = inputs[0];
let output = &mut outputs[0];
let len = input.len().min(output.len());
// Copy input to output
output[..len].copy_from_slice(&input[..len]);
// Check for CV modulation (modulates cutoff)
// CV input (0..1) scales the cutoff: 0 = 20 Hz, 1 = base cutoff * 2
// Sample CV at the start of the buffer - per-sample would be too expensive
let cutoff_cv_raw = cv_input_or_default(inputs, 1, 0, f32::NAN);
let effective_cutoff = if cutoff_cv_raw.is_nan() {
self.cutoff
} else {
// Map CV (0..1) to frequency range around the base cutoff
// 0.5 = base cutoff, 0 = cutoff / 4, 1 = cutoff * 4 (two octaves each way)
let octave_shift = (cutoff_cv_raw.clamp(0.0, 1.0) - 0.5) * 4.0;
self.cutoff * 2.0_f32.powf(octave_shift)
};
if (effective_cutoff - self.last_applied_cutoff).abs() > 0.01 {
let new_cutoff = effective_cutoff.clamp(20.0, 20000.0);
self.last_applied_cutoff = new_cutoff;
match self.filter_type {
FilterType::Lowpass => {
self.filter.set_lowpass(new_cutoff, self.resonance, self.sample_rate as f32);
}
FilterType::Highpass => {
self.filter.set_highpass(new_cutoff, self.resonance, self.sample_rate as f32);
}
}
}
// Apply filter (processes stereo interleaved)
self.filter.process_buffer(&mut output[..len], 2);
}
fn reset(&mut self) {
self.filter.reset();
}
fn node_type(&self) -> &str {
"Filter"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
// Create new filter with same parameters but reset state
let mut new_filter = BiquadFilter::new();
// Set filter to match current type
match self.filter_type {
FilterType::Lowpass => {
new_filter.set_lowpass(self.cutoff, self.resonance, self.sample_rate as f32);
}
FilterType::Highpass => {
new_filter.set_highpass(self.cutoff, self.resonance, self.sample_rate as f32);
}
}
Box::new(Self {
name: self.name.clone(),
filter: new_filter,
cutoff: self.cutoff,
resonance: self.resonance,
filter_type: self.filter_type,
sample_rate: self.sample_rate,
last_applied_cutoff: self.cutoff,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,251 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use std::f32::consts::PI;
const PARAM_RATE: u32 = 0;
const PARAM_DEPTH: u32 = 1;
const PARAM_FEEDBACK: u32 = 2;
const PARAM_WET_DRY: u32 = 3;
const MAX_DELAY_MS: f32 = 10.0;
const BASE_DELAY_MS: f32 = 1.0;
/// Flanger effect using modulated delay lines with feedback
pub struct FlangerNode {
name: String,
rate: f32, // LFO rate in Hz (0.1 to 10 Hz)
depth: f32, // Modulation depth 0.0 to 1.0
feedback: f32, // Feedback amount -0.95 to 0.95
wet_dry: f32, // 0.0 = dry only, 1.0 = wet only
// Delay buffers for left and right channels
delay_buffer_left: Vec<f32>,
delay_buffer_right: Vec<f32>,
write_position: usize,
max_delay_samples: usize,
sample_rate: u32,
// LFO state
lfo_phase: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl FlangerNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_RATE, "Rate", 0.1, 10.0, 0.5, ParameterUnit::Frequency),
Parameter::new(PARAM_DEPTH, "Depth", 0.0, 1.0, 0.7, ParameterUnit::Generic),
Parameter::new(PARAM_FEEDBACK, "Feedback", -0.95, 0.95, 0.5, ParameterUnit::Generic),
Parameter::new(PARAM_WET_DRY, "Wet/Dry", 0.0, 1.0, 0.5, ParameterUnit::Generic),
];
// Allocate max delay buffer size
let max_delay_samples = ((MAX_DELAY_MS / 1000.0) * 48000.0) as usize;
Self {
name,
rate: 0.5,
depth: 0.7,
feedback: 0.5,
wet_dry: 0.5,
delay_buffer_left: vec![0.0; max_delay_samples],
delay_buffer_right: vec![0.0; max_delay_samples],
write_position: 0,
max_delay_samples,
sample_rate: 48000,
lfo_phase: 0.0,
inputs,
outputs,
parameters,
}
}
fn read_interpolated_sample(&self, buffer: &[f32], delay_samples: f32) -> f32 {
// Linear interpolation for smooth delay modulation
let delay_samples = delay_samples.clamp(0.0, (self.max_delay_samples - 1) as f32);
let read_pos_float = self.write_position as f32 - delay_samples;
let read_pos_float = if read_pos_float < 0.0 {
read_pos_float + self.max_delay_samples as f32
} else {
read_pos_float
};
let read_pos_int = read_pos_float.floor() as usize;
let frac = read_pos_float - read_pos_int as f32;
let sample1 = buffer[read_pos_int % self.max_delay_samples];
let sample2 = buffer[(read_pos_int + 1) % self.max_delay_samples];
sample1 * (1.0 - frac) + sample2 * frac
}
}
impl AudioNode for FlangerNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_RATE => {
self.rate = value.clamp(0.1, 10.0);
}
PARAM_DEPTH => {
self.depth = value.clamp(0.0, 1.0);
}
PARAM_FEEDBACK => {
self.feedback = value.clamp(-0.95, 0.95);
}
PARAM_WET_DRY => {
self.wet_dry = value.clamp(0.0, 1.0);
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_RATE => self.rate,
PARAM_DEPTH => self.depth,
PARAM_FEEDBACK => self.feedback,
PARAM_WET_DRY => self.wet_dry,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
self.max_delay_samples = ((MAX_DELAY_MS / 1000.0) * sample_rate as f32) as usize;
self.delay_buffer_left.resize(self.max_delay_samples, 0.0);
self.delay_buffer_right.resize(self.max_delay_samples, 0.0);
self.write_position = 0;
}
let input = inputs[0];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
let frames = input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
let dry_gain = 1.0 - self.wet_dry;
let wet_gain = self.wet_dry;
let base_delay_samples = (BASE_DELAY_MS / 1000.0) * self.sample_rate as f32;
let max_modulation_samples = (MAX_DELAY_MS - BASE_DELAY_MS) / 1000.0 * self.sample_rate as f32;
for frame in 0..frames_to_process {
let left_in = input[frame * 2];
let right_in = input[frame * 2 + 1];
// Generate LFO value (sine wave, 0 to 1)
let lfo_value = ((self.lfo_phase * 2.0 * PI).sin() * 0.5 + 0.5) * self.depth;
// Calculate modulated delay time
let delay_samples = base_delay_samples + lfo_value * max_modulation_samples;
// Read delayed samples with interpolation
let left_delayed = self.read_interpolated_sample(&self.delay_buffer_left, delay_samples);
let right_delayed = self.read_interpolated_sample(&self.delay_buffer_right, delay_samples);
// Mix dry and wet signals
output[frame * 2] = left_in * dry_gain + left_delayed * wet_gain;
output[frame * 2 + 1] = right_in * dry_gain + right_delayed * wet_gain;
// Write to delay buffer with feedback
self.delay_buffer_left[self.write_position] = left_in + left_delayed * self.feedback;
self.delay_buffer_right[self.write_position] = right_in + right_delayed * self.feedback;
// Advance write position
self.write_position = (self.write_position + 1) % self.max_delay_samples;
// Advance LFO phase
self.lfo_phase += self.rate / self.sample_rate as f32;
if self.lfo_phase >= 1.0 {
self.lfo_phase -= 1.0;
}
}
}
fn reset(&mut self) {
self.delay_buffer_left.fill(0.0);
self.delay_buffer_right.fill(0.0);
self.write_position = 0;
self.lfo_phase = 0.0;
}
fn node_type(&self) -> &str {
"Flanger"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
rate: self.rate,
depth: self.depth,
feedback: self.feedback,
wet_dry: self.wet_dry,
delay_buffer_left: vec![0.0; self.max_delay_samples],
delay_buffer_right: vec![0.0; self.max_delay_samples],
write_position: 0,
max_delay_samples: self.max_delay_samples,
sample_rate: self.sample_rate,
lfo_phase: 0.0,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,304 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
use std::f32::consts::PI;
// Parameters for the FM synth
const PARAM_ALGORITHM: u32 = 0;
const PARAM_OP1_RATIO: u32 = 1;
const PARAM_OP1_LEVEL: u32 = 2;
const PARAM_OP2_RATIO: u32 = 3;
const PARAM_OP2_LEVEL: u32 = 4;
const PARAM_OP3_RATIO: u32 = 5;
const PARAM_OP3_LEVEL: u32 = 6;
const PARAM_OP4_RATIO: u32 = 7;
const PARAM_OP4_LEVEL: u32 = 8;
/// FM Algorithm types (inspired by DX7)
/// Algorithm determines how operators modulate each other
#[derive(Debug, Clone, Copy, PartialEq)]
enum FMAlgorithm {
/// Stack: 1->2->3->4 (most harmonic)
Stack = 0,
/// Parallel: All operators to output (organ-like)
Parallel = 1,
/// Bell: 1->2, 3->4, both to output
Bell = 2,
/// Dual: 1->2->output, 3->4->output
Dual = 3,
}
impl FMAlgorithm {
fn from_u32(value: u32) -> Self {
match value {
0 => FMAlgorithm::Stack,
1 => FMAlgorithm::Parallel,
2 => FMAlgorithm::Bell,
3 => FMAlgorithm::Dual,
_ => FMAlgorithm::Stack,
}
}
}
/// Single FM operator (oscillator)
struct FMOperator {
phase: f32,
frequency_ratio: f32, // Multiplier of base frequency (e.g., 1.0, 2.0, 0.5)
level: f32, // Output amplitude 0.0-1.0
}
impl FMOperator {
fn new() -> Self {
Self {
phase: 0.0,
frequency_ratio: 1.0,
level: 1.0,
}
}
/// Process one sample with optional frequency modulation
fn process(&mut self, base_freq: f32, modulation: f32, sample_rate: f32) -> f32 {
let freq = base_freq * self.frequency_ratio;
// Phase modulation (PM, which sounds like FM)
let output = (self.phase * 2.0 * PI + modulation).sin() * self.level;
// Advance phase
self.phase += freq / sample_rate;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
output
}
fn reset(&mut self) {
self.phase = 0.0;
}
}
/// 4-operator FM synthesizer node
pub struct FMSynthNode {
name: String,
algorithm: FMAlgorithm,
// Four operators
operators: [FMOperator; 4],
// Current frequency from V/oct input
current_frequency: f32,
gate_active: bool,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl FMSynthNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("V/Oct", SignalType::CV, 0),
NodePort::new("Gate", SignalType::CV, 1),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_ALGORITHM, "Algorithm", 0.0, 3.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_OP1_RATIO, "Op1 Ratio", 0.25, 16.0, 1.0, ParameterUnit::Generic),
Parameter::new(PARAM_OP1_LEVEL, "Op1 Level", 0.0, 1.0, 1.0, ParameterUnit::Generic),
Parameter::new(PARAM_OP2_RATIO, "Op2 Ratio", 0.25, 16.0, 2.0, ParameterUnit::Generic),
Parameter::new(PARAM_OP2_LEVEL, "Op2 Level", 0.0, 1.0, 0.8, ParameterUnit::Generic),
Parameter::new(PARAM_OP3_RATIO, "Op3 Ratio", 0.25, 16.0, 3.0, ParameterUnit::Generic),
Parameter::new(PARAM_OP3_LEVEL, "Op3 Level", 0.0, 1.0, 0.6, ParameterUnit::Generic),
Parameter::new(PARAM_OP4_RATIO, "Op4 Ratio", 0.25, 16.0, 4.0, ParameterUnit::Generic),
Parameter::new(PARAM_OP4_LEVEL, "Op4 Level", 0.0, 1.0, 0.4, ParameterUnit::Generic),
];
Self {
name,
algorithm: FMAlgorithm::Stack,
operators: [
FMOperator::new(),
FMOperator::new(),
FMOperator::new(),
FMOperator::new(),
],
current_frequency: 440.0,
gate_active: false,
sample_rate: 48000,
inputs,
outputs,
parameters,
}
}
/// Convert V/oct CV to frequency
fn voct_to_freq(voct: f32) -> f32 {
440.0 * 2.0_f32.powf(voct)
}
/// Process FM synthesis based on current algorithm
fn process_algorithm(&mut self) -> f32 {
if !self.gate_active {
return 0.0;
}
let base_freq = self.current_frequency;
let sr = self.sample_rate as f32;
match self.algorithm {
FMAlgorithm::Stack => {
// 1 -> 2 -> 3 -> 4 -> output
let op4_out = self.operators[3].process(base_freq, 0.0, sr);
let op3_out = self.operators[2].process(base_freq, op4_out * 2.0, sr);
let op2_out = self.operators[1].process(base_freq, op3_out * 2.0, sr);
let op1_out = self.operators[0].process(base_freq, op2_out * 2.0, sr);
op1_out
}
FMAlgorithm::Parallel => {
// All operators output directly (no modulation)
let op1_out = self.operators[0].process(base_freq, 0.0, sr);
let op2_out = self.operators[1].process(base_freq, 0.0, sr);
let op3_out = self.operators[2].process(base_freq, 0.0, sr);
let op4_out = self.operators[3].process(base_freq, 0.0, sr);
(op1_out + op2_out + op3_out + op4_out) * 0.25
}
FMAlgorithm::Bell => {
// 1 -> 2, 3 -> 4, both to output
let op2_out = self.operators[1].process(base_freq, 0.0, sr);
let op1_out = self.operators[0].process(base_freq, op2_out * 2.0, sr);
let op4_out = self.operators[3].process(base_freq, 0.0, sr);
let op3_out = self.operators[2].process(base_freq, op4_out * 2.0, sr);
(op1_out + op3_out) * 0.5
}
FMAlgorithm::Dual => {
// 1 -> 2 -> output, 3 -> 4 -> output
let op2_out = self.operators[1].process(base_freq, 0.0, sr);
let op1_out = self.operators[0].process(base_freq, op2_out * 2.0, sr);
let op4_out = self.operators[3].process(base_freq, 0.0, sr);
let op3_out = self.operators[2].process(base_freq, op4_out * 2.0, sr);
(op1_out + op3_out) * 0.5
}
}
}
}
impl AudioNode for FMSynthNode {
fn category(&self) -> NodeCategory {
NodeCategory::Generator
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_ALGORITHM => {
self.algorithm = FMAlgorithm::from_u32(value as u32);
}
PARAM_OP1_RATIO => self.operators[0].frequency_ratio = value.clamp(0.25, 16.0),
PARAM_OP1_LEVEL => self.operators[0].level = value.clamp(0.0, 1.0),
PARAM_OP2_RATIO => self.operators[1].frequency_ratio = value.clamp(0.25, 16.0),
PARAM_OP2_LEVEL => self.operators[1].level = value.clamp(0.0, 1.0),
PARAM_OP3_RATIO => self.operators[2].frequency_ratio = value.clamp(0.25, 16.0),
PARAM_OP3_LEVEL => self.operators[2].level = value.clamp(0.0, 1.0),
PARAM_OP4_RATIO => self.operators[3].frequency_ratio = value.clamp(0.25, 16.0),
PARAM_OP4_LEVEL => self.operators[3].level = value.clamp(0.0, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_ALGORITHM => self.algorithm as u32 as f32,
PARAM_OP1_RATIO => self.operators[0].frequency_ratio,
PARAM_OP1_LEVEL => self.operators[0].level,
PARAM_OP2_RATIO => self.operators[1].frequency_ratio,
PARAM_OP2_LEVEL => self.operators[1].level,
PARAM_OP3_RATIO => self.operators[2].frequency_ratio,
PARAM_OP3_LEVEL => self.operators[2].level,
PARAM_OP4_RATIO => self.operators[3].frequency_ratio,
PARAM_OP4_LEVEL => self.operators[3].level,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
self.sample_rate = sample_rate;
let output = &mut outputs[0];
let frames = output.len() / 2;
for frame in 0..frames {
// Read CV inputs (both are mono signals)
// V/Oct: when unconnected, defaults to 0.0 (A4 440 Hz)
let voct = cv_input_or_default(inputs, 0, frame, 0.0);
// Gate: when unconnected, defaults to 0.0 (off)
let gate = cv_input_or_default(inputs, 1, frame, 0.0);
// Update state
self.current_frequency = Self::voct_to_freq(voct);
self.gate_active = gate > 0.5;
// Generate sample
let sample = self.process_algorithm() * 0.3; // Scale down to prevent clipping
// Output stereo (same signal to both channels)
output[frame * 2] = sample;
output[frame * 2 + 1] = sample;
}
}
fn reset(&mut self) {
for op in &mut self.operators {
op.reset();
}
self.gate_active = false;
}
fn node_type(&self) -> &str {
"FMSynth"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self::new(self.name.clone()))
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,134 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
const PARAM_GAIN: u32 = 0;
/// Gain/volume control node
pub struct GainNode {
name: String,
gain: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl GainNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
NodePort::new("Gain CV", SignalType::CV, 1),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_GAIN, "Gain", 0.0, 2.0, 1.0, ParameterUnit::Generic),
];
Self {
name,
gain: 1.0,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for GainNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_GAIN => self.gain = value.clamp(0.0, 2.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_GAIN => self.gain,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
let input = inputs[0];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
// Process by frames, not samples
let frames = input.len().min(output.len()) / 2;
for frame in 0..frames {
// CV input acts as a VCA (voltage-controlled amplifier)
// CV ranges from 0.0 (silence) to 1.0 (full gain parameter value)
// When unconnected (NaN), defaults to 1.0 (no modulation, use gain parameter as-is)
let cv = cv_input_or_default(inputs, 1, frame, 1.0);
let final_gain = self.gain * cv;
// Apply gain to both channels
output[frame * 2] = input[frame * 2] * final_gain; // Left
output[frame * 2 + 1] = input[frame * 2 + 1] * final_gain; // Right
}
}
fn reset(&mut self) {
// No state to reset
}
fn node_type(&self) -> &str {
"Gain"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
gain: self.gain,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,230 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use std::f32::consts::PI;
use rand::Rng;
const PARAM_FREQUENCY: u32 = 0;
const PARAM_AMPLITUDE: u32 = 1;
const PARAM_WAVEFORM: u32 = 2;
const PARAM_PHASE_OFFSET: u32 = 3;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LFOWaveform {
Sine = 0,
Triangle = 1,
Saw = 2,
Square = 3,
Random = 4,
}
impl LFOWaveform {
fn from_f32(value: f32) -> Self {
match value.round() as i32 {
1 => LFOWaveform::Triangle,
2 => LFOWaveform::Saw,
3 => LFOWaveform::Square,
4 => LFOWaveform::Random,
_ => LFOWaveform::Sine,
}
}
}
/// Low Frequency Oscillator node for modulation
pub struct LFONode {
name: String,
frequency: f32,
amplitude: f32,
waveform: LFOWaveform,
phase_offset: f32,
phase: f32,
last_random_value: f32,
next_random_value: f32,
random_phase: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl LFONode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![];
let outputs = vec![
NodePort::new("CV Out", SignalType::CV, 0),
];
let parameters = vec![
Parameter::new(PARAM_FREQUENCY, "Frequency", 0.01, 20.0, 1.0, ParameterUnit::Frequency),
Parameter::new(PARAM_AMPLITUDE, "Amplitude", 0.0, 1.0, 1.0, ParameterUnit::Generic),
Parameter::new(PARAM_WAVEFORM, "Waveform", 0.0, 4.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_PHASE_OFFSET, "Phase", 0.0, 1.0, 0.0, ParameterUnit::Generic),
];
let mut rng = rand::thread_rng();
Self {
name,
frequency: 1.0,
amplitude: 1.0,
waveform: LFOWaveform::Sine,
phase_offset: 0.0,
phase: 0.0,
last_random_value: rng.gen_range(-1.0..1.0),
next_random_value: rng.gen_range(-1.0..1.0),
random_phase: 0.0,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for LFONode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_FREQUENCY => self.frequency = value.clamp(0.01, 20.0),
PARAM_AMPLITUDE => self.amplitude = value.clamp(0.0, 1.0),
PARAM_WAVEFORM => self.waveform = LFOWaveform::from_f32(value),
PARAM_PHASE_OFFSET => self.phase_offset = value.clamp(0.0, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_FREQUENCY => self.frequency,
PARAM_AMPLITUDE => self.amplitude,
PARAM_WAVEFORM => self.waveform as i32 as f32,
PARAM_PHASE_OFFSET => self.phase_offset,
_ => 0.0,
}
}
fn process(
&mut self,
_inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let sample_rate_f32 = sample_rate as f32;
// CV signals are mono
for sample_idx in 0..output.len() {
let current_phase = (self.phase + self.phase_offset) % 1.0;
// Generate waveform sample based on waveform type
let raw_sample = match self.waveform {
LFOWaveform::Sine => (current_phase * 2.0 * PI).sin(),
LFOWaveform::Triangle => {
// Triangle: rises from -1 to 1, falls back to -1
4.0 * (current_phase - 0.5).abs() - 1.0
}
LFOWaveform::Saw => {
// Sawtooth: ramp from -1 to 1
2.0 * current_phase - 1.0
}
LFOWaveform::Square => {
if current_phase < 0.5 { 1.0 } else { -1.0 }
}
LFOWaveform::Random => {
// Sample & hold random values with smooth interpolation
// Interpolate between last and next random value
let t = self.random_phase;
self.last_random_value * (1.0 - t) + self.next_random_value * t
}
};
// Scale to 0-1 range and apply amplitude
let sample = (raw_sample * 0.5 + 0.5) * self.amplitude;
output[sample_idx] = sample;
// Update phase
self.phase += self.frequency / sample_rate_f32;
if self.phase >= 1.0 {
self.phase -= 1.0;
// For random waveform, generate new random value at each cycle
if self.waveform == LFOWaveform::Random {
self.last_random_value = self.next_random_value;
let mut rng = rand::thread_rng();
self.next_random_value = rng.gen_range(-1.0..1.0);
self.random_phase = 0.0;
}
}
// Update random interpolation phase
if self.waveform == LFOWaveform::Random {
self.random_phase += self.frequency / sample_rate_f32;
if self.random_phase >= 1.0 {
self.random_phase -= 1.0;
}
}
}
}
fn reset(&mut self) {
self.phase = 0.0;
self.random_phase = 0.0;
let mut rng = rand::thread_rng();
self.last_random_value = rng.gen_range(-1.0..1.0);
self.next_random_value = rng.gen_range(-1.0..1.0);
}
fn node_type(&self) -> &str {
"LFO"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
frequency: self.frequency,
amplitude: self.amplitude,
waveform: self.waveform,
phase_offset: self.phase_offset,
phase: 0.0, // Reset phase for new instance
last_random_value: self.last_random_value,
next_random_value: self.next_random_value,
random_phase: 0.0,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,223 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_THRESHOLD: u32 = 0;
const PARAM_RELEASE: u32 = 1;
const PARAM_CEILING: u32 = 2;
/// Limiter node for preventing audio peaks from exceeding a threshold
/// Essentially a compressor with infinite ratio and very fast attack
pub struct LimiterNode {
name: String,
threshold_db: f32,
release_ms: f32,
ceiling_db: f32,
// State
envelope: f32,
release_coeff: f32,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl LimiterNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_THRESHOLD, "Threshold", -60.0, 0.0, -1.0, ParameterUnit::Decibels),
Parameter::new(PARAM_RELEASE, "Release", 1.0, 500.0, 50.0, ParameterUnit::Time),
Parameter::new(PARAM_CEILING, "Ceiling", -60.0, 0.0, 0.0, ParameterUnit::Decibels),
];
let sample_rate = 44100;
let release_coeff = Self::ms_to_coeff(50.0, sample_rate);
Self {
name,
threshold_db: -1.0,
release_ms: 50.0,
ceiling_db: 0.0,
envelope: 0.0,
release_coeff,
sample_rate,
inputs,
outputs,
parameters,
}
}
/// Convert milliseconds to exponential smoothing coefficient
fn ms_to_coeff(time_ms: f32, sample_rate: u32) -> f32 {
let time_seconds = time_ms / 1000.0;
let samples = time_seconds * sample_rate as f32;
(-1.0 / samples).exp()
}
fn update_coefficients(&mut self) {
self.release_coeff = Self::ms_to_coeff(self.release_ms, self.sample_rate);
}
/// Convert linear amplitude to dB
fn linear_to_db(linear: f32) -> f32 {
if linear > 0.0 {
20.0 * linear.log10()
} else {
-160.0
}
}
/// Convert dB to linear gain
fn db_to_linear(db: f32) -> f32 {
10.0_f32.powf(db / 20.0)
}
fn process_sample(&mut self, input: f32) -> f32 {
// Detect input level (using absolute value as peak detector)
let input_level = input.abs();
// Convert to dB
let input_db = Self::linear_to_db(input_level);
// Calculate gain reduction needed
// If above threshold, apply infinite ratio (hard limit)
let target_gr_db = if input_db > self.threshold_db {
input_db - self.threshold_db // Amount of overshoot to reduce
} else {
0.0
};
let target_gr_linear = Self::db_to_linear(-target_gr_db);
// Very fast attack (instant for limiter), but slower release
// Attack coeff is very close to 0 for near-instant response
let attack_coeff = 0.0001; // Extremely fast attack
let coeff = if target_gr_linear < self.envelope {
attack_coeff // Attack (instant response to louder signal)
} else {
self.release_coeff // Release (slower recovery)
};
self.envelope = target_gr_linear + coeff * (self.envelope - target_gr_linear);
// Apply limiting and output ceiling
let limited = input * self.envelope;
let ceiling_linear = Self::db_to_linear(self.ceiling_db);
// Hard clip at ceiling
limited.clamp(-ceiling_linear, ceiling_linear)
}
}
impl AudioNode for LimiterNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_THRESHOLD => self.threshold_db = value,
PARAM_RELEASE => {
self.release_ms = value;
self.update_coefficients();
}
PARAM_CEILING => self.ceiling_db = value,
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_THRESHOLD => self.threshold_db,
PARAM_RELEASE => self.release_ms,
PARAM_CEILING => self.ceiling_db,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
self.update_coefficients();
}
let input = inputs[0];
let output = &mut outputs[0];
let len = input.len().min(output.len());
for i in 0..len {
output[i] = self.process_sample(input[i]);
}
}
fn reset(&mut self) {
self.envelope = 0.0;
}
fn node_type(&self) -> &str {
"Limiter"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
threshold_db: self.threshold_db,
release_ms: self.release_ms,
ceiling_db: self.ceiling_db,
envelope: 0.0, // Reset state for clone
release_coeff: self.release_coeff,
sample_rate: self.sample_rate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,172 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_OPERATION: u32 = 0;
/// Mathematical and logical operations on CV signals
/// Operations:
/// 0 = Add, 1 = Subtract, 2 = Multiply, 3 = Divide
/// 4 = Min, 5 = Max, 6 = Average
/// 7 = Invert (1.0 - x), 8 = Absolute Value
/// 9 = Clamp (0.0 to 1.0), 10 = Wrap (-1.0 to 1.0)
/// 11 = Greater Than, 12 = Less Than, 13 = Equal (with tolerance)
pub struct MathNode {
name: String,
operation: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl MathNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("CV In A", SignalType::CV, 0),
NodePort::new("CV In B", SignalType::CV, 1),
];
let outputs = vec![
NodePort::new("CV Out", SignalType::CV, 0),
];
let parameters = vec![
Parameter::new(PARAM_OPERATION, "Operation", 0.0, 13.0, 0.0, ParameterUnit::Generic),
];
Self {
name,
operation: 0,
inputs,
outputs,
parameters,
}
}
fn apply_operation(&self, a: f32, b: f32) -> f32 {
match self.operation {
0 => a + b, // Add
1 => a - b, // Subtract
2 => a * b, // Multiply
3 => if b.abs() > 0.0001 { a / b } else { 0.0 }, // Divide (with protection)
4 => a.min(b), // Min
5 => a.max(b), // Max
6 => (a + b) * 0.5, // Average
7 => 1.0 - a, // Invert (ignores b)
8 => a.abs(), // Absolute Value (ignores b)
9 => a.clamp(0.0, 1.0), // Clamp to 0-1 (ignores b)
10 => { // Wrap -1 to 1
let mut result = a;
while result > 1.0 {
result -= 2.0;
}
while result < -1.0 {
result += 2.0;
}
result
},
11 => if a > b { 1.0 } else { 0.0 }, // Greater Than
12 => if a < b { 1.0 } else { 0.0 }, // Less Than
13 => if (a - b).abs() < 0.01 { 1.0 } else { 0.0 }, // Equal (with tolerance)
_ => a, // Unknown operation - pass through
}
}
}
impl AudioNode for MathNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_OPERATION => self.operation = (value as u32).clamp(0, 13),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_OPERATION => self.operation as f32,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let length = output.len();
// Process each sample
for i in 0..length {
// Get input A (or 0.0 if not connected)
let a = if !inputs.is_empty() && i < inputs[0].len() {
inputs[0][i]
} else {
0.0
};
// Get input B (or 0.0 if not connected)
let b = if inputs.len() > 1 && i < inputs[1].len() {
inputs[1][i]
} else {
0.0
};
output[i] = self.apply_operation(a, b);
}
}
fn reset(&mut self) {
// No state to reset
}
fn node_type(&self) -> &str {
"Math"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
operation: self.operation,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,113 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType};
use crate::audio::midi::MidiEvent;
/// MIDI Input node - receives MIDI events from the track and passes them through
pub struct MidiInputNode {
name: String,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
pending_events: Vec<MidiEvent>,
}
impl MidiInputNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![];
let outputs = vec![
NodePort::new("MIDI Out", SignalType::Midi, 0),
];
Self {
name,
inputs,
outputs,
parameters: vec![],
pending_events: Vec::new(),
}
}
/// Add MIDI events to be processed
pub fn add_midi_events(&mut self, events: Vec<MidiEvent>) {
self.pending_events.extend(events);
}
/// Get pending MIDI events (used for routing to connected nodes)
pub fn take_midi_events(&mut self) -> Vec<MidiEvent> {
std::mem::take(&mut self.pending_events)
}
}
impl AudioNode for MidiInputNode {
fn category(&self) -> NodeCategory {
NodeCategory::Input
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, _id: u32, _value: f32) {
// No parameters
}
fn get_parameter(&self, _id: u32) -> f32 {
0.0
}
fn process(
&mut self,
_inputs: &[&[f32]],
_outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
// MidiInput receives MIDI from external sources (marked as MIDI target)
// and outputs it through the graph
// The MIDI was already placed in midi_outputs by the graph before calling process()
}
fn reset(&mut self) {
self.pending_events.clear();
}
fn node_type(&self) -> &str {
"MidiInput"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
pending_events: Vec::new(),
})
}
fn handle_midi(&mut self, event: &MidiEvent) {
self.pending_events.push(*event);
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,233 @@
use crate::audio::midi::MidiEvent;
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
const PARAM_PITCH_BEND_RANGE: u32 = 0;
/// MIDI to CV converter
/// Converts MIDI note events to control voltage signals
pub struct MidiToCVNode {
name: String,
note: u8, // Current MIDI note number
gate: f32, // Gate CV (1.0 when note on, 0.0 when off)
velocity: f32, // Velocity CV (0.0-1.0)
pitch_cv: f32, // Pitch CV (V/Oct: 0V = A4, ±1V per octave), without bend
pitch_bend_range: f32, // Pitch bend range in semitones (default 2.0)
current_bend: f32, // Current pitch bend, normalised -1.0..=1.0 (0 = centre)
current_mod: f32, // Current modulation (CC1), 0.0..=1.0
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl MidiToCVNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("MIDI In", SignalType::Midi, 0),
NodePort::new("Bend CV", SignalType::CV, 0), // External pitch bend in semitones
NodePort::new("Mod CV", SignalType::CV, 1), // External modulation 0.0..=1.0
];
let outputs = vec![
NodePort::new("V/Oct", SignalType::CV, 0), // V/Oct: 0V = A4, ±1V per octave (with bend applied)
NodePort::new("Gate", SignalType::CV, 1), // 1.0 = on, 0.0 = off
NodePort::new("Velocity", SignalType::CV, 2), // 0.0-1.0
NodePort::new("Bend", SignalType::CV, 3), // Total pitch bend in semitones (MIDI + CV)
NodePort::new("Mod", SignalType::CV, 4), // Total modulation 0.0..=1.0 (MIDI CC1 + CV)
];
let parameters = vec![
Parameter::new(
PARAM_PITCH_BEND_RANGE,
"Pitch Bend Range",
0.0, 48.0, 2.0,
ParameterUnit::Generic,
),
];
Self {
name,
note: 60,
gate: 0.0,
velocity: 0.0,
pitch_cv: Self::midi_note_to_voct(60),
pitch_bend_range: 2.0,
current_bend: 0.0,
current_mod: 0.0,
inputs,
outputs,
parameters,
}
}
/// Convert MIDI note to V/oct CV (proper V/Oct standard)
/// 0V = A4 (MIDI 69), ±1V per octave
/// Middle C (MIDI 60) = -0.75V, A5 (MIDI 81) = +1.0V
fn midi_note_to_voct(note: u8) -> f32 {
// Standard V/Oct: 0V at A4, 1V per octave (12 semitones)
(note as f32 - 69.0) / 12.0
}
fn apply_midi_event(&mut self, event: &MidiEvent) {
let status = event.status & 0xF0;
match status {
0x90 if event.data2 > 0 => {
// Note on — reset per-note expression so previous note's bend doesn't bleed in
self.note = event.data1;
self.pitch_cv = Self::midi_note_to_voct(self.note);
self.velocity = event.data2 as f32 / 127.0;
self.gate = 1.0;
self.current_bend = 0.0;
self.current_mod = 0.0;
}
0x80 | 0x90 => {
// Note off (or note on with velocity 0)
if event.data1 == self.note {
self.gate = 0.0;
}
}
0xE0 => {
// Pitch bend: 14-bit value, center = 8192
let bend_raw = ((event.data2 as i16) << 7) | (event.data1 as i16);
self.current_bend = (bend_raw - 8192) as f32 / 8192.0;
}
0xB0 if event.data1 == 1 => {
// CC1 (modulation wheel)
self.current_mod = event.data2 as f32 / 127.0;
}
_ => {}
}
}
}
impl AudioNode for MidiToCVNode {
fn category(&self) -> NodeCategory {
NodeCategory::Input
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
if id == PARAM_PITCH_BEND_RANGE {
self.pitch_bend_range = value.clamp(0.0, 48.0);
}
}
fn get_parameter(&self, id: u32) -> f32 {
if id == PARAM_PITCH_BEND_RANGE {
self.pitch_bend_range
} else {
0.0
}
}
fn handle_midi(&mut self, event: &MidiEvent) {
self.apply_midi_event(event);
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
// Process MIDI events from input buffer
if !midi_inputs.is_empty() {
for event in midi_inputs[0] {
self.apply_midi_event(event);
}
}
if outputs.len() < 5 {
return;
}
// Read CV inputs (use first sample of buffer). NaN = unconnected port → treat as 0.
let bend_cv = inputs.get(0).and_then(|b| b.first().copied())
.filter(|v| v.is_finite()).unwrap_or(0.0);
let mod_cv = inputs.get(1).and_then(|b| b.first().copied())
.filter(|v| v.is_finite()).unwrap_or(0.0);
// Total bend in semitones: MIDI bend + CV bend
let bend_semitones = self.current_bend * self.pitch_bend_range + bend_cv;
// Total mod: MIDI CC1 + CV mod, clamped to 0..1
let total_mod = (self.current_mod + mod_cv).clamp(0.0, 1.0);
// Pitch output includes bend
let pitch_out_val = self.pitch_cv + bend_semitones / 12.0;
// Use split_at_mut to get multiple mutable references
let (v0, rest) = outputs.split_at_mut(1);
let (v1, rest) = rest.split_at_mut(1);
let (v2, rest) = rest.split_at_mut(1);
let (v3, v4_slice) = rest.split_at_mut(1);
let pitch_out = &mut v0[0];
let gate_out = &mut v1[0];
let velocity_out = &mut v2[0];
let bend_out = &mut v3[0];
let mod_out = &mut v4_slice[0];
let frames = pitch_out.len();
// Output constant CV values for the entire buffer
for frame in 0..frames {
pitch_out[frame] = pitch_out_val;
gate_out[frame] = self.gate;
velocity_out[frame] = self.velocity;
bend_out[frame] = bend_semitones;
mod_out[frame] = total_mod;
}
}
fn reset(&mut self) {
self.gate = 0.0;
self.velocity = 0.0;
self.current_bend = 0.0;
self.current_mod = 0.0;
}
fn node_type(&self) -> &str {
"MidiToCV"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
note: 60,
gate: 0.0,
velocity: 0.0,
pitch_cv: Self::midi_note_to_voct(60),
pitch_bend_range: self.pitch_bend_range,
current_bend: 0.0,
current_mod: 0.0,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,164 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
/// Mixer node — combines N audio inputs with independent gain controls.
///
/// The number of input ports is dynamic: one spare unconnected port is always present
/// beyond however many are currently wired, so users can keep patching in without
/// manually adding inputs. Port count is managed by `AudioGraph::connect` /
/// `AudioGraph::disconnect` calling `ensure_min_ports` / `resize`.
///
/// Gain values are stored separately from the port list so they survive resize
/// operations and can be set via `set_parameter` before the port is visible.
pub struct MixerNode {
name: String,
/// Displayed input ports. Length = num_ports (connected + 1 spare).
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
/// Per-channel gains, indexed by port. May be longer than `inputs` if gains
/// were set before ports were created (handled gracefully).
gains: Vec<f32>,
/// Mirrored parameter list so `parameters()` stays in sync with `inputs`.
parameters: Vec<Parameter>,
}
impl MixerNode {
pub fn new(name: impl Into<String>) -> Self {
let mut node = Self {
name: name.into(),
inputs: Vec::new(),
outputs: vec![NodePort::new("Mixed Out", SignalType::Audio, 0)],
gains: Vec::new(),
parameters: Vec::new(),
};
node.resize(1); // start with one spare input
node
}
/// Return the current number of input ports (connected + 1 spare).
pub fn num_inputs(&self) -> usize {
self.inputs.len()
}
/// Set the exact number of input ports.
///
/// Existing gain values are preserved. Truncates spare gains when shrinking,
/// but gain slots that have already been written survive a grow-shrink-grow cycle.
pub fn resize(&mut self, n: usize) {
let n = n.max(1); // always at least one spare
self.inputs = (0..n)
.map(|i| NodePort::new(format!("Input {}", i + 1).as_str(), SignalType::Audio, i))
.collect();
// Extend gains with 1.0 for new slots; preserve existing values.
if self.gains.len() < n {
self.gains.resize(n, 1.0);
}
self.parameters = (0..n)
.map(|i| {
Parameter::new(i as u32, format!("Gain {}", i + 1).as_str(), 0.0, 2.0, 1.0, ParameterUnit::Generic)
})
.collect();
}
/// Ensure at least `n` input ports exist, growing if needed but never shrinking.
///
/// Called by `AudioGraph::connect` after adding a connection.
pub fn ensure_min_ports(&mut self, n: usize) {
if n > self.inputs.len() {
self.resize(n);
}
}
}
impl AudioNode for MixerNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
let idx = id as usize;
// Extend gains if this port hasn't been created yet (e.g. loaded from preset
// before connections are restored).
if idx >= self.gains.len() {
self.gains.resize(idx + 1, 1.0);
}
self.gains[idx] = value.clamp(0.0, 2.0);
}
fn get_parameter(&self, id: u32) -> f32 {
self.gains.get(id as usize).copied().unwrap_or(1.0)
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let frames = output.len() / 2;
output.fill(0.0);
for (input_idx, input) in inputs.iter().enumerate() {
let gain = self.gains.get(input_idx).copied().unwrap_or(1.0);
let input_frames = input.len() / 2;
let process_frames = frames.min(input_frames);
for frame in 0..process_frames {
output[frame * 2] += input[frame * 2] * gain; // Left
output[frame * 2 + 1] += input[frame * 2 + 1] * gain; // Right
}
}
}
fn reset(&mut self) {
// No per-frame state
}
fn node_type(&self) -> &str {
"Mixer"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
gains: self.gains.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,160 @@
mod amp_sim;
pub mod bundled_models;
mod adsr;
mod subtrack_inputs;
mod arpeggiator;
mod audio_input;
mod audio_to_cv;
mod automation_input;
mod beat;
mod bit_crusher;
mod bpm_detector;
mod chorus;
mod compressor;
mod constant;
mod echo;
mod distortion;
mod envelope_follower;
mod eq;
mod filter;
mod flanger;
mod limiter;
mod fm_synth;
mod gain;
mod lfo;
mod math;
mod midi_input;
mod midi_to_cv;
mod mixer;
mod multi_sampler;
mod noise;
mod oscillator;
mod oscilloscope;
mod output;
mod pan;
mod phaser;
mod quantizer;
mod reverb;
mod ring_modulator;
mod sample_hold;
mod script_node;
mod sequencer;
mod simple_sampler;
mod slew_limiter;
mod splitter;
mod svf;
mod template_io;
mod vibrato;
mod vocoder;
mod voice_allocator;
mod wavetable_oscillator;
pub use amp_sim::AmpSimNode;
pub use adsr::ADSRNode;
pub use arpeggiator::ArpeggiatorNode;
pub use audio_input::AudioInputNode;
pub use audio_to_cv::AudioToCVNode;
pub use automation_input::{AutomationInputNode, AutomationKeyframe, InterpolationType};
pub use beat::BeatNode;
pub use bit_crusher::BitCrusherNode;
pub use bpm_detector::BpmDetectorNode;
pub use chorus::ChorusNode;
pub use compressor::CompressorNode;
pub use constant::ConstantNode;
pub use echo::EchoNode;
pub use distortion::DistortionNode;
pub use envelope_follower::EnvelopeFollowerNode;
pub use eq::EQNode;
pub use filter::FilterNode;
pub use flanger::FlangerNode;
pub use limiter::LimiterNode;
pub use fm_synth::FMSynthNode;
pub use gain::GainNode;
pub use lfo::LFONode;
pub use math::MathNode;
pub use midi_input::MidiInputNode;
pub use midi_to_cv::MidiToCVNode;
pub use mixer::MixerNode;
pub use multi_sampler::{MultiSamplerNode, LoopMode};
pub use noise::NoiseGeneratorNode;
pub use oscillator::OscillatorNode;
pub use oscilloscope::OscilloscopeNode;
pub use output::AudioOutputNode;
pub use pan::PanNode;
pub use phaser::PhaserNode;
pub use quantizer::QuantizerNode;
pub use reverb::ReverbNode;
pub use ring_modulator::RingModulatorNode;
pub use sample_hold::SampleHoldNode;
pub use script_node::ScriptNode;
pub use sequencer::SequencerNode;
pub use simple_sampler::SimpleSamplerNode;
pub use slew_limiter::SlewLimiterNode;
pub use splitter::SplitterNode;
pub use svf::SVFNode;
pub use template_io::{TemplateInputNode, TemplateOutputNode};
pub use vibrato::VibratoNode;
pub use vocoder::VocoderNode;
pub use voice_allocator::VoiceAllocatorNode;
pub use wavetable_oscillator::WavetableOscillatorNode;
pub use subtrack_inputs::SubtrackInputsNode;
/// Create a node instance by type name string.
///
/// Returns `None` for unknown type names. `sample_rate` and `buffer_size`
/// are only used by VoiceAllocator; other nodes ignore them.
pub fn create_node(node_type: &str, sample_rate: u32, buffer_size: usize) -> Option<Box<dyn super::AudioNode>> {
Some(match node_type {
"Oscillator" => Box::new(OscillatorNode::new("Oscillator")),
"Gain" => Box::new(GainNode::new("Gain")),
"Mixer" => Box::new(MixerNode::new("Mixer")),
"Filter" => Box::new(FilterNode::new("Filter")),
"SVF" => Box::new(SVFNode::new("SVF")),
"ADSR" => Box::new(ADSRNode::new("ADSR")),
"LFO" => Box::new(LFONode::new("LFO")),
"NoiseGenerator" => Box::new(NoiseGeneratorNode::new("Noise")),
"Splitter" => Box::new(SplitterNode::new("Splitter")),
"Pan" => Box::new(PanNode::new("Pan")),
"Quantizer" => Box::new(QuantizerNode::new("Quantizer")),
"Echo" | "Delay" => Box::new(EchoNode::new("Echo")),
"Distortion" => Box::new(DistortionNode::new("Distortion")),
"Reverb" => Box::new(ReverbNode::new("Reverb")),
"Chorus" => Box::new(ChorusNode::new("Chorus")),
"Compressor" => Box::new(CompressorNode::new("Compressor")),
"Constant" => Box::new(ConstantNode::new("Constant")),
"BpmDetector" => Box::new(BpmDetectorNode::new("BPM Detector")),
"Beat" => Box::new(BeatNode::new("Beat")),
"Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator")),
"Sequencer" => Box::new(SequencerNode::new("Sequencer")),
"Script" => Box::new(ScriptNode::new("Script")),
"EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower")),
"Limiter" => Box::new(LimiterNode::new("Limiter")),
"Math" => Box::new(MathNode::new("Math")),
"EQ" => Box::new(EQNode::new("EQ")),
"Flanger" => Box::new(FlangerNode::new("Flanger")),
"FMSynth" => Box::new(FMSynthNode::new("FM Synth")),
"Phaser" => Box::new(PhaserNode::new("Phaser")),
"BitCrusher" => Box::new(BitCrusherNode::new("Bit Crusher")),
"Vocoder" => Box::new(VocoderNode::new("Vocoder")),
"RingModulator" => Box::new(RingModulatorNode::new("Ring Modulator")),
"SampleHold" => Box::new(SampleHoldNode::new("Sample & Hold")),
"WavetableOscillator" => Box::new(WavetableOscillatorNode::new("Wavetable")),
"SimpleSampler" => Box::new(SimpleSamplerNode::new("Sampler")),
"SlewLimiter" => Box::new(SlewLimiterNode::new("Slew Limiter")),
"MultiSampler" => Box::new(MultiSamplerNode::new("Multi Sampler")),
"MidiInput" => Box::new(MidiInputNode::new("MIDI Input")),
"MidiToCV" => Box::new(MidiToCVNode::new("MIDI→CV")),
"AudioToCV" => Box::new(AudioToCVNode::new("Audio→CV")),
"AudioInput" => Box::new(AudioInputNode::new("Audio Input")),
"AutomationInput" => Box::new(AutomationInputNode::new("Automation")),
"Oscilloscope" => Box::new(OscilloscopeNode::new("Oscilloscope")),
"TemplateInput" => Box::new(TemplateInputNode::new("Template Input")),
"TemplateOutput" => Box::new(TemplateOutputNode::new("Template Output")),
"VoiceAllocator" => Box::new(VoiceAllocatorNode::new("VoiceAllocator", sample_rate, buffer_size)),
"Vibrato" => Box::new(VibratoNode::new("Vibrato")),
"AmpSim" => Box::new(AmpSimNode::new("Amp Sim")),
"AudioOutput" => Box::new(AudioOutputNode::new("Output")),
"SubtrackInputs" => Box::new(SubtrackInputsNode::new("Subtrack Inputs", vec![])),
_ => return None,
})
}

View File

@ -0,0 +1,832 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
// Parameters
const PARAM_GAIN: u32 = 0;
const PARAM_ATTACK: u32 = 1;
const PARAM_RELEASE: u32 = 2;
const PARAM_TRANSPOSE: u32 = 3;
const PARAM_PITCH_BEND_RANGE: u32 = 4;
/// Loop playback mode
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LoopMode {
/// Play sample once, no looping
OneShot,
/// Loop continuously between loop_start and loop_end
Continuous,
}
/// Metadata about a loaded sample layer (for preset serialization)
#[derive(Clone, Debug)]
pub struct LayerInfo {
pub file_path: String,
pub key_min: u8,
pub key_max: u8,
pub root_key: u8,
pub velocity_min: u8,
pub velocity_max: u8,
pub loop_start: Option<usize>, // Loop start point in samples
pub loop_end: Option<usize>, // Loop end point in samples
pub loop_mode: LoopMode,
}
/// Single sample with velocity range and key range
#[derive(Clone)]
struct SampleLayer {
sample_data: Vec<f32>,
sample_rate: f32,
// Key range: C-1 = 0, C0 = 12, middle C (C4) = 60, C9 = 120
key_min: u8,
key_max: u8,
root_key: u8, // The original pitch of the sample
// Velocity range: 0-127
velocity_min: u8,
velocity_max: u8,
// Loop points (in samples)
loop_start: Option<usize>,
loop_end: Option<usize>,
loop_mode: LoopMode,
}
impl SampleLayer {
fn new(
sample_data: Vec<f32>,
sample_rate: f32,
key_min: u8,
key_max: u8,
root_key: u8,
velocity_min: u8,
velocity_max: u8,
loop_start: Option<usize>,
loop_end: Option<usize>,
loop_mode: LoopMode,
) -> Self {
Self {
sample_data,
sample_rate,
key_min,
key_max,
root_key,
velocity_min,
velocity_max,
loop_start,
loop_end,
loop_mode,
}
}
/// Check if this layer matches the given key and velocity
fn matches(&self, key: u8, velocity: u8) -> bool {
key >= self.key_min
&& key <= self.key_max
&& velocity >= self.velocity_min
&& velocity <= self.velocity_max
}
/// Auto-detect loop points using autocorrelation to find a good loop region
/// Returns (loop_start, loop_end) in samples
fn detect_loop_points(sample_data: &[f32], sample_rate: f32) -> Option<(usize, usize)> {
if sample_data.len() < (sample_rate * 0.5) as usize {
return None; // Need at least 0.5 seconds of audio
}
// Look for loop in the sustain region (skip attack/decay, avoid release)
// For sustained instruments, look in the middle 50% of the sample
let search_start = (sample_data.len() as f32 * 0.25) as usize;
let search_end = (sample_data.len() as f32 * 0.75) as usize;
if search_end <= search_start {
return None;
}
// Find the best loop point using autocorrelation
// For sustained instruments like brass/woodwind, we want longer loops
let min_loop_length = (sample_rate * 0.1) as usize; // Min 0.1s loop (more stable)
let max_loop_length = (sample_rate * 10.0) as usize; // Max 10 second loop
let mut best_correlation = -1.0;
let mut best_loop_start = search_start;
let mut best_loop_end = search_end;
// Try different loop lengths from LONGEST to SHORTEST
// This way we prefer longer loops and stop early if we find a good one
let length_step = ((sample_rate * 0.05) as usize).max(512); // 50ms steps
let actual_max_length = max_loop_length.min(search_end - search_start);
// Manually iterate backwards since step_by().rev() doesn't work on RangeInclusive<usize>
let mut loop_length = actual_max_length;
while loop_length >= min_loop_length {
// Try different starting points in the sustain region (finer steps)
let start_step = ((sample_rate * 0.02) as usize).max(256); // 20ms steps
for start in (search_start..search_end - loop_length).step_by(start_step) {
let end = start + loop_length;
if end > search_end {
break;
}
// Calculate correlation between loop end and loop start
let correlation = Self::calculate_loop_correlation(sample_data, start, end);
if correlation > best_correlation {
best_correlation = correlation;
best_loop_start = start;
best_loop_end = end;
}
}
// If we found a good enough loop, stop searching shorter ones
if best_correlation > 0.8 {
break;
}
// Decrement loop_length, with underflow protection
if loop_length < length_step {
break;
}
loop_length -= length_step;
}
// Lower threshold since longer loops are harder to match perfectly
if best_correlation > 0.6 {
Some((best_loop_start, best_loop_end))
} else {
// Fallback: use a reasonable chunk of the sustain region
let fallback_length = ((search_end - search_start) / 2).max(min_loop_length);
Some((search_start, search_start + fallback_length))
}
}
/// Calculate how well the audio loops at the given points
/// Returns correlation value between -1.0 and 1.0 (higher is better)
fn calculate_loop_correlation(sample_data: &[f32], loop_start: usize, loop_end: usize) -> f32 {
let loop_length = loop_end - loop_start;
let window_size = (loop_length / 10).max(128).min(2048); // Compare last 10% of loop
if loop_end + window_size >= sample_data.len() {
return -1.0;
}
// Compare the end of the loop region with the beginning
let region1_start = loop_end - window_size;
let region2_start = loop_start;
let mut sum_xy = 0.0;
let mut sum_x2 = 0.0;
let mut sum_y2 = 0.0;
for i in 0..window_size {
let x = sample_data[region1_start + i];
let y = sample_data[region2_start + i];
sum_xy += x * y;
sum_x2 += x * x;
sum_y2 += y * y;
}
// Pearson correlation coefficient
let denominator = (sum_x2 * sum_y2).sqrt();
if denominator > 0.0 {
sum_xy / denominator
} else {
-1.0
}
}
}
/// Active voice playing a sample
struct Voice {
layer_index: usize,
playhead: f32,
note: u8,
channel: u8, // MIDI channel this voice was activated on
velocity: u8,
is_active: bool,
// Envelope
envelope_phase: EnvelopePhase,
envelope_value: f32,
// Loop crossfade state
crossfade_buffer: Vec<f32>, // Stores samples from before loop_start for crossfading
crossfade_length: usize, // Length of crossfade in samples (e.g., 100 samples = ~2ms @ 48kHz)
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum EnvelopePhase {
Attack,
Sustain,
Release,
}
impl Voice {
fn new(layer_index: usize, note: u8, channel: u8, velocity: u8) -> Self {
Self {
layer_index,
playhead: 0.0,
note,
channel,
velocity,
is_active: true,
envelope_phase: EnvelopePhase::Attack,
envelope_value: 0.0,
crossfade_buffer: Vec::new(),
crossfade_length: 4800, // ~100ms at 48kHz — hides loop seams in sustained instruments
}
}
}
/// Multi-sample instrument with velocity layers and key zones
pub struct MultiSamplerNode {
name: String,
// Sample layers
layers: Vec<SampleLayer>,
layer_infos: Vec<LayerInfo>, // Metadata about loaded layers
// Voice management
voices: Vec<Voice>,
max_voices: usize,
// Parameters
gain: f32,
attack_time: f32, // seconds
release_time: f32, // seconds
transpose: i8, // semitones
pitch_bend_range: f32, // semitones (default 2.0)
// Live MIDI state
bend_per_channel: [f32; 16], // Pitch bend per MIDI channel; ch0 = global broadcast
current_mod: f32, // MIDI CC1 modulation 0.0..=1.0
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl MultiSamplerNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("MIDI In", SignalType::Midi, 0),
NodePort::new("Bend CV", SignalType::CV, 0), // External pitch bend in semitones
NodePort::new("Mod CV", SignalType::CV, 1), // External modulation 0.0..=1.0
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_GAIN, "Gain", 0.0, 2.0, 1.0, ParameterUnit::Generic),
Parameter::new(PARAM_ATTACK, "Attack", 0.001, 1.0, 0.01, ParameterUnit::Time),
Parameter::new(PARAM_RELEASE, "Release", 0.01, 5.0, 0.1, ParameterUnit::Time),
Parameter::new(PARAM_TRANSPOSE, "Transpose", -24.0, 24.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_PITCH_BEND_RANGE, "Pitch Bend Range", 0.0, 48.0, 2.0, ParameterUnit::Generic),
];
Self {
name,
layers: Vec::new(),
layer_infos: Vec::new(),
voices: Vec::new(),
max_voices: 16,
gain: 1.0,
attack_time: 0.01,
release_time: 0.1,
transpose: 0,
pitch_bend_range: 2.0,
bend_per_channel: [0.0; 16],
current_mod: 0.0,
inputs,
outputs,
parameters,
}
}
/// Add a sample layer
pub fn add_layer(
&mut self,
sample_data: Vec<f32>,
sample_rate: f32,
key_min: u8,
key_max: u8,
root_key: u8,
velocity_min: u8,
velocity_max: u8,
loop_start: Option<usize>,
loop_end: Option<usize>,
loop_mode: LoopMode,
) {
let layer = SampleLayer::new(
sample_data,
sample_rate,
key_min,
key_max,
root_key,
velocity_min,
velocity_max,
loop_start,
loop_end,
loop_mode,
);
self.layers.push(layer);
}
/// Load a sample layer from a file path
pub fn load_layer_from_file(
&mut self,
path: &str,
key_min: u8,
key_max: u8,
root_key: u8,
velocity_min: u8,
velocity_max: u8,
loop_start: Option<usize>,
loop_end: Option<usize>,
loop_mode: LoopMode,
) -> Result<(), String> {
use crate::audio::sample_loader::load_audio_file;
let sample_data = load_audio_file(path)?;
// Auto-detect loop points if not provided and mode is Continuous
let (final_loop_start, final_loop_end) = if loop_mode == LoopMode::Continuous && loop_start.is_none() && loop_end.is_none() {
if let Some((start, end)) = SampleLayer::detect_loop_points(&sample_data.samples, sample_data.sample_rate as f32) {
(Some(start), Some(end))
} else {
(None, None)
}
} else {
(loop_start, loop_end)
};
self.add_layer(
sample_data.samples,
sample_data.sample_rate as f32,
key_min,
key_max,
root_key,
velocity_min,
velocity_max,
final_loop_start,
final_loop_end,
loop_mode,
);
// Store layer metadata for preset serialization
self.layer_infos.push(LayerInfo {
file_path: path.to_string(),
key_min,
key_max,
root_key,
velocity_min,
velocity_max,
loop_start: final_loop_start,
loop_end: final_loop_end,
loop_mode,
});
Ok(())
}
/// Get information about all loaded layers
pub fn get_layers_info(&self) -> &[LayerInfo] {
&self.layer_infos
}
/// Get sample data for a specific layer (for preset embedding)
pub fn get_layer_data(&self, layer_index: usize) -> Option<(Vec<f32>, f32)> {
self.layers.get(layer_index).map(|layer| {
(layer.sample_data.clone(), layer.sample_rate)
})
}
/// Update a layer's configuration
pub fn update_layer(
&mut self,
layer_index: usize,
key_min: u8,
key_max: u8,
root_key: u8,
velocity_min: u8,
velocity_max: u8,
loop_start: Option<usize>,
loop_end: Option<usize>,
loop_mode: LoopMode,
) -> Result<(), String> {
if layer_index >= self.layers.len() {
return Err("Layer index out of bounds".to_string());
}
// Update the layer data
self.layers[layer_index].key_min = key_min;
self.layers[layer_index].key_max = key_max;
self.layers[layer_index].root_key = root_key;
self.layers[layer_index].velocity_min = velocity_min;
self.layers[layer_index].velocity_max = velocity_max;
self.layers[layer_index].loop_start = loop_start;
self.layers[layer_index].loop_end = loop_end;
self.layers[layer_index].loop_mode = loop_mode;
// Update the layer info
if layer_index < self.layer_infos.len() {
self.layer_infos[layer_index].key_min = key_min;
self.layer_infos[layer_index].key_max = key_max;
self.layer_infos[layer_index].root_key = root_key;
self.layer_infos[layer_index].velocity_min = velocity_min;
self.layer_infos[layer_index].velocity_max = velocity_max;
self.layer_infos[layer_index].loop_start = loop_start;
self.layer_infos[layer_index].loop_end = loop_end;
self.layer_infos[layer_index].loop_mode = loop_mode;
}
Ok(())
}
/// Remove a layer
pub fn remove_layer(&mut self, layer_index: usize) -> Result<(), String> {
if layer_index >= self.layers.len() {
return Err("Layer index out of bounds".to_string());
}
self.layers.remove(layer_index);
if layer_index < self.layer_infos.len() {
self.layer_infos.remove(layer_index);
}
// Stop any voices playing this layer
for voice in &mut self.voices {
if voice.layer_index == layer_index {
voice.is_active = false;
} else if voice.layer_index > layer_index {
// Adjust indices for layers that were shifted down
voice.layer_index -= 1;
}
}
Ok(())
}
/// Remove all layers
pub fn clear_layers(&mut self) {
self.layers.clear();
self.layer_infos.clear();
// Stop all active voices
for voice in &mut self.voices {
voice.is_active = false;
}
}
/// Find the best matching layer for a given note and velocity
fn find_layer(&self, note: u8, velocity: u8) -> Option<usize> {
self.layers
.iter()
.enumerate()
.find(|(_, layer)| layer.matches(note, velocity))
.map(|(index, _)| index)
}
/// Trigger a note
fn note_on(&mut self, note: u8, channel: u8, velocity: u8) {
// Reset per-channel bend on note-on so a previous note's bend doesn't bleed in
self.bend_per_channel[channel as usize] = 0.0;
let transposed_note = (note as i16 + self.transpose as i16).clamp(0, 127) as u8;
if let Some(layer_index) = self.find_layer(transposed_note, velocity) {
// Find an inactive voice or reuse the oldest one
let voice_index = self
.voices
.iter()
.position(|v| !v.is_active)
.unwrap_or_else(|| {
// All voices active, reuse the first one
if self.voices.len() < self.max_voices {
self.voices.len()
} else {
0
}
});
let voice = Voice::new(layer_index, note, channel, velocity);
if voice_index < self.voices.len() {
self.voices[voice_index] = voice;
} else {
self.voices.push(voice);
}
}
}
/// Release a note
fn note_off(&mut self, note: u8) {
for voice in &mut self.voices {
if voice.note == note && voice.is_active {
voice.envelope_phase = EnvelopePhase::Release;
}
}
}
}
impl AudioNode for MultiSamplerNode {
fn category(&self) -> NodeCategory {
NodeCategory::Generator
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_GAIN => {
self.gain = value.clamp(0.0, 2.0);
}
PARAM_ATTACK => {
self.attack_time = value.clamp(0.001, 1.0);
}
PARAM_RELEASE => {
self.release_time = value.clamp(0.01, 5.0);
}
PARAM_TRANSPOSE => {
self.transpose = value.clamp(-24.0, 24.0) as i8;
}
PARAM_PITCH_BEND_RANGE => {
self.pitch_bend_range = value.clamp(0.0, 48.0);
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_GAIN => self.gain,
PARAM_ATTACK => self.attack_time,
PARAM_RELEASE => self.release_time,
PARAM_TRANSPOSE => self.transpose as f32,
PARAM_PITCH_BEND_RANGE => self.pitch_bend_range,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let frames = output.len() / 2;
// Clear output
output.fill(0.0);
// Process MIDI events
if !midi_inputs.is_empty() {
for event in midi_inputs[0].iter() {
let status = event.status & 0xF0;
match status {
_ if event.is_note_on() => self.note_on(event.data1, event.status & 0x0F, event.data2),
_ if event.is_note_off() => self.note_off(event.data1),
0xE0 => {
// Pitch bend: 14-bit value, center = 8192; stored per-channel
let bend_raw = ((event.data2 as i16) << 7) | (event.data1 as i16);
let ch = (event.status & 0x0F) as usize;
self.bend_per_channel[ch] = (bend_raw - 8192) as f32 / 8192.0;
}
0xB0 if event.data1 == 1 => {
// CC1 (modulation wheel)
self.current_mod = event.data2 as f32 / 127.0;
}
_ => {}
}
}
}
// Read CV inputs. NaN = unconnected port → treat as 0.
let bend_cv = inputs.get(0).and_then(|b| b.first().copied())
.filter(|v| v.is_finite()).unwrap_or(0.0);
// Global bend (channel 0) applies to all voices; per-channel bend is added per-voice below.
let global_bend_norm = self.bend_per_channel[0];
let bend_per_channel = self.bend_per_channel;
// Extract parameters needed for processing
let gain = self.gain;
let attack_time = self.attack_time;
let release_time = self.release_time;
// Process all active voices
for voice in &mut self.voices {
if !voice.is_active {
continue;
}
if voice.layer_index >= self.layers.len() {
continue;
}
let layer = &self.layers[voice.layer_index];
// Calculate playback speed (includes pitch bend)
// Channel-0 = global; voice's own channel bend is added on top.
let voice_bend_norm = global_bend_norm + bend_per_channel[voice.channel as usize];
let total_bend_semitones = voice_bend_norm * self.pitch_bend_range + bend_cv;
let semitone_diff = voice.note as i16 - layer.root_key as i16;
let speed = 2.0_f32.powf((semitone_diff as f32 + total_bend_semitones) / 12.0);
let speed_adjusted = speed * (layer.sample_rate / sample_rate as f32);
for frame in 0..frames {
// Read sample with linear interpolation and loop handling
let playhead = voice.playhead;
let mut sample = 0.0;
if !layer.sample_data.is_empty() && playhead >= 0.0 {
let index = playhead.floor() as usize;
// Check if we need to handle looping
if layer.loop_mode == LoopMode::Continuous {
if let (Some(loop_start), Some(loop_end)) = (layer.loop_start, layer.loop_end) {
// Validate loop points
if loop_start < loop_end && loop_end <= layer.sample_data.len() {
// Fill crossfade buffer on first loop with samples just before loop_start
// These will be crossfaded with the beginning of the loop for seamless looping
if voice.crossfade_buffer.is_empty() && loop_start >= voice.crossfade_length {
let crossfade_start = loop_start.saturating_sub(voice.crossfade_length);
voice.crossfade_buffer = layer.sample_data[crossfade_start..loop_start].to_vec();
}
// Check if we've reached the loop end
if index >= loop_end {
// Wrap around to loop start
let loop_length = loop_end - loop_start;
let offset_from_end = index - loop_end;
let wrapped_index = loop_start + (offset_from_end % loop_length);
voice.playhead = wrapped_index as f32 + (playhead - playhead.floor());
}
// Read sample at current position
let current_index = voice.playhead.floor() as usize;
if current_index < layer.sample_data.len() {
let frac = voice.playhead - voice.playhead.floor();
let sample1 = layer.sample_data[current_index];
let sample2 = if current_index + 1 < layer.sample_data.len() {
layer.sample_data[current_index + 1]
} else {
layer.sample_data[loop_start] // Wrap to loop start for interpolation
};
sample = sample1 + (sample2 - sample1) * frac;
// Apply crossfade only at the END of loop
// Crossfade the end of loop with samples BEFORE loop_start
if current_index >= loop_start && current_index < loop_end {
if !voice.crossfade_buffer.is_empty() {
let crossfade_len = voice.crossfade_length.min(voice.crossfade_buffer.len());
// Only crossfade at loop end (last crossfade_length samples)
// This blends end samples (i,j,k) with pre-loop samples (a,b,c)
if current_index >= loop_end - crossfade_len && current_index < loop_end {
let crossfade_pos = current_index - (loop_end - crossfade_len);
if crossfade_pos < voice.crossfade_buffer.len() {
let end_sample = sample; // Current sample at end of loop (i, j, or k)
let pre_loop_sample = voice.crossfade_buffer[crossfade_pos]; // Corresponding pre-loop sample (a, b, or c)
// Equal-power crossfade: fade out end, fade in pre-loop
let fade_ratio = crossfade_pos as f32 / crossfade_len as f32;
let fade_out = (1.0 - fade_ratio).sqrt();
let fade_in = fade_ratio.sqrt();
sample = end_sample * fade_out + pre_loop_sample * fade_in;
}
}
}
}
}
} else {
// Invalid loop points, play normally
if index < layer.sample_data.len() {
let frac = playhead - playhead.floor();
let sample1 = layer.sample_data[index];
let sample2 = if index + 1 < layer.sample_data.len() {
layer.sample_data[index + 1]
} else {
0.0
};
sample = sample1 + (sample2 - sample1) * frac;
}
}
} else {
// No loop points defined, play normally
if index < layer.sample_data.len() {
let frac = playhead - playhead.floor();
let sample1 = layer.sample_data[index];
let sample2 = if index + 1 < layer.sample_data.len() {
layer.sample_data[index + 1]
} else {
0.0
};
sample = sample1 + (sample2 - sample1) * frac;
}
}
} else {
// OneShot mode - play normally without looping
if index < layer.sample_data.len() {
let frac = playhead - playhead.floor();
let sample1 = layer.sample_data[index];
let sample2 = if index + 1 < layer.sample_data.len() {
layer.sample_data[index + 1]
} else {
0.0
};
sample = sample1 + (sample2 - sample1) * frac;
}
}
}
// Process envelope
match voice.envelope_phase {
EnvelopePhase::Attack => {
let attack_samples = attack_time * sample_rate as f32;
voice.envelope_value += 1.0 / attack_samples;
if voice.envelope_value >= 1.0 {
voice.envelope_value = 1.0;
voice.envelope_phase = EnvelopePhase::Sustain;
}
}
EnvelopePhase::Sustain => {
voice.envelope_value = 1.0;
}
EnvelopePhase::Release => {
let release_samples = release_time * sample_rate as f32;
voice.envelope_value -= 1.0 / release_samples;
if voice.envelope_value <= 0.0 {
voice.envelope_value = 0.0;
voice.is_active = false;
}
}
}
let envelope = voice.envelope_value.clamp(0.0, 1.0);
// Apply velocity scaling (0-127 -> 0-1)
let velocity_scale = voice.velocity as f32 / 127.0;
// Mix into output
let final_sample = sample * envelope * velocity_scale * gain;
output[frame * 2] += final_sample;
output[frame * 2 + 1] += final_sample;
// Advance playhead
voice.playhead += speed_adjusted;
// Stop if we've reached the end (only for OneShot mode)
if layer.loop_mode == LoopMode::OneShot {
if voice.playhead >= layer.sample_data.len() as f32 {
voice.is_active = false;
break;
}
}
}
}
}
fn reset(&mut self) {
self.voices.clear();
self.bend_per_channel = [0.0; 16];
self.current_mod = 0.0;
}
fn node_type(&self) -> &str {
"MultiSampler"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self::new(self.name.clone()))
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,205 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use rand::Rng;
const PARAM_AMPLITUDE: u32 = 0;
const PARAM_COLOR: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum NoiseColor {
White = 0,
Pink = 1,
}
impl NoiseColor {
fn from_f32(value: f32) -> Self {
match value.round() as i32 {
1 => NoiseColor::Pink,
_ => NoiseColor::White,
}
}
}
/// Noise generator node with white and pink noise
pub struct NoiseGeneratorNode {
name: String,
amplitude: f32,
color: NoiseColor,
// Pink noise state (Paul Kellet's pink noise algorithm)
pink_b0: f32,
pink_b1: f32,
pink_b2: f32,
pink_b3: f32,
pink_b4: f32,
pink_b5: f32,
pink_b6: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl NoiseGeneratorNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_AMPLITUDE, "Amplitude", 0.0, 1.0, 0.5, ParameterUnit::Generic),
Parameter::new(PARAM_COLOR, "Color", 0.0, 1.0, 0.0, ParameterUnit::Generic),
];
Self {
name,
amplitude: 0.5,
color: NoiseColor::White,
pink_b0: 0.0,
pink_b1: 0.0,
pink_b2: 0.0,
pink_b3: 0.0,
pink_b4: 0.0,
pink_b5: 0.0,
pink_b6: 0.0,
inputs,
outputs,
parameters,
}
}
/// Generate white noise sample
fn generate_white(&self) -> f32 {
let mut rng = rand::thread_rng();
rng.gen_range(-1.0..1.0)
}
/// Generate pink noise sample using Paul Kellet's algorithm
fn generate_pink(&mut self) -> f32 {
let mut rng = rand::thread_rng();
let white: f32 = rng.gen_range(-1.0..1.0);
self.pink_b0 = 0.99886 * self.pink_b0 + white * 0.0555179;
self.pink_b1 = 0.99332 * self.pink_b1 + white * 0.0750759;
self.pink_b2 = 0.96900 * self.pink_b2 + white * 0.1538520;
self.pink_b3 = 0.86650 * self.pink_b3 + white * 0.3104856;
self.pink_b4 = 0.55000 * self.pink_b4 + white * 0.5329522;
self.pink_b5 = -0.7616 * self.pink_b5 - white * 0.0168980;
let pink = self.pink_b0 + self.pink_b1 + self.pink_b2 + self.pink_b3 + self.pink_b4 + self.pink_b5 + self.pink_b6 + white * 0.5362;
self.pink_b6 = white * 0.115926;
// Scale to approximately -1 to 1
pink * 0.11
}
}
impl AudioNode for NoiseGeneratorNode {
fn category(&self) -> NodeCategory {
NodeCategory::Generator
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_AMPLITUDE => self.amplitude = value.clamp(0.0, 1.0),
PARAM_COLOR => self.color = NoiseColor::from_f32(value),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_AMPLITUDE => self.amplitude,
PARAM_COLOR => self.color as i32 as f32,
_ => 0.0,
}
}
fn process(
&mut self,
_inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
// Process by frames, not samples
let frames = output.len() / 2;
for frame in 0..frames {
let sample = match self.color {
NoiseColor::White => self.generate_white(),
NoiseColor::Pink => self.generate_pink(),
} * self.amplitude;
// Write to both channels (mono source duplicated to stereo)
output[frame * 2] = sample; // Left
output[frame * 2 + 1] = sample; // Right
}
}
fn reset(&mut self) {
self.pink_b0 = 0.0;
self.pink_b1 = 0.0;
self.pink_b2 = 0.0;
self.pink_b3 = 0.0;
self.pink_b4 = 0.0;
self.pink_b5 = 0.0;
self.pink_b6 = 0.0;
}
fn node_type(&self) -> &str {
"NoiseGenerator"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
amplitude: self.amplitude,
color: self.color,
pink_b0: 0.0,
pink_b1: 0.0,
pink_b2: 0.0,
pink_b3: 0.0,
pink_b4: 0.0,
pink_b5: 0.0,
pink_b6: 0.0,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,207 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
use std::f32::consts::PI;
const PARAM_FREQUENCY: u32 = 0;
const PARAM_AMPLITUDE: u32 = 1;
const PARAM_WAVEFORM: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Waveform {
Sine = 0,
Saw = 1,
Square = 2,
Triangle = 3,
}
impl Waveform {
fn from_f32(value: f32) -> Self {
match value.round() as i32 {
1 => Waveform::Saw,
2 => Waveform::Square,
3 => Waveform::Triangle,
_ => Waveform::Sine,
}
}
}
/// Oscillator node with multiple waveforms
pub struct OscillatorNode {
name: String,
frequency: f32,
amplitude: f32,
waveform: Waveform,
phase: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl OscillatorNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("V/Oct", SignalType::CV, 0),
NodePort::new("FM", SignalType::CV, 1),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_FREQUENCY, "Frequency", 20.0, 20000.0, 440.0, ParameterUnit::Frequency),
Parameter::new(PARAM_AMPLITUDE, "Amplitude", 0.0, 1.0, 0.5, ParameterUnit::Generic),
Parameter::new(PARAM_WAVEFORM, "Waveform", 0.0, 3.0, 0.0, ParameterUnit::Generic),
];
Self {
name,
frequency: 440.0,
amplitude: 0.5,
waveform: Waveform::Sine,
phase: 0.0,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for OscillatorNode {
fn category(&self) -> NodeCategory {
NodeCategory::Generator
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_FREQUENCY => self.frequency = value.clamp(20.0, 20000.0),
PARAM_AMPLITUDE => self.amplitude = value.clamp(0.0, 1.0),
PARAM_WAVEFORM => self.waveform = Waveform::from_f32(value),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_FREQUENCY => self.frequency,
PARAM_AMPLITUDE => self.amplitude,
PARAM_WAVEFORM => self.waveform as i32 as f32,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let sample_rate_f32 = sample_rate as f32;
// Audio signals are stereo (interleaved L/R)
// Process by frames, not samples
let frames = output.len() / 2;
for frame in 0..frames {
// V/Oct input: Standard V/Oct (0V = A4 440Hz, ±1V per octave)
// Port 0: V/Oct CV input
// If connected, interprets the CV signal as V/Oct (440 * 2^voct)
// If unconnected, uses self.frequency directly as Hz
let voct = cv_input_or_default(inputs, 0, frame, f32::NAN);
let base_frequency = if voct.is_nan() {
// Unconnected: use frequency parameter directly
self.frequency
} else {
// Connected: convert V/Oct to frequency
// voct = 0.0 -> 440 Hz (A4)
// voct = 1.0 -> 880 Hz (A5)
// voct = -0.75 -> 261.6 Hz (C4, middle C)
440.0 * 2.0_f32.powf(voct)
};
// FM input: modulates the frequency
// Port 1: FM CV input
// If connected, applies FM modulation (multiply by 1 + fm)
// If unconnected, no modulation (fm = 0.0)
let fm = cv_input_or_default(inputs, 1, frame, 0.0);
let freq_mod = base_frequency * (1.0 + fm);
// Generate waveform sample based on waveform type
let sample = match self.waveform {
Waveform::Sine => (self.phase * 2.0 * PI).sin(),
Waveform::Saw => 2.0 * self.phase - 1.0, // Ramp from -1 to 1
Waveform::Square => {
if self.phase < 0.5 { 1.0 } else { -1.0 }
}
Waveform::Triangle => {
// Triangle: rises from -1 to 1, falls back to -1
4.0 * (self.phase - 0.5).abs() - 1.0
}
} * self.amplitude;
// Write to both channels (mono source duplicated to stereo)
output[frame * 2] = sample; // Left
output[frame * 2 + 1] = sample; // Right
// Update phase once per frame
self.phase += freq_mod / sample_rate_f32;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
}
}
fn reset(&mut self) {
self.phase = 0.0;
}
fn node_type(&self) -> &str {
"Oscillator"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
frequency: self.frequency,
amplitude: self.amplitude,
waveform: self.waveform,
phase: 0.0, // Reset phase for new instance
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,321 @@
use crate::audio::midi::MidiEvent;
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use std::sync::{Arc, Mutex};
const PARAM_TIME_SCALE: u32 = 0;
const PARAM_TRIGGER_MODE: u32 = 1;
const PARAM_TRIGGER_LEVEL: u32 = 2;
const BUFFER_SIZE: usize = 96000; // 2 seconds at 48kHz (stereo)
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TriggerMode {
FreeRunning = 0,
RisingEdge = 1,
FallingEdge = 2,
VoltPerOctave = 3,
}
impl TriggerMode {
fn from_f32(value: f32) -> Self {
match value.round() as i32 {
1 => TriggerMode::RisingEdge,
2 => TriggerMode::FallingEdge,
3 => TriggerMode::VoltPerOctave,
_ => TriggerMode::FreeRunning,
}
}
}
/// Circular buffer for storing audio samples
pub struct CircularBuffer {
buffer: Vec<f32>,
write_pos: usize,
capacity: usize,
}
impl CircularBuffer {
fn new(capacity: usize) -> Self {
Self {
buffer: vec![0.0; capacity],
write_pos: 0,
capacity,
}
}
fn write(&mut self, samples: &[f32]) {
for &sample in samples {
self.buffer[self.write_pos] = sample;
self.write_pos = (self.write_pos + 1) % self.capacity;
}
}
fn read(&self, count: usize) -> Vec<f32> {
let count = count.min(self.capacity);
let mut result = Vec::with_capacity(count);
// Read backwards from current write position
let start_pos = if self.write_pos >= count {
self.write_pos - count
} else {
self.capacity - (count - self.write_pos)
};
for i in 0..count {
let pos = (start_pos + i) % self.capacity;
result.push(self.buffer[pos]);
}
result
}
fn clear(&mut self) {
self.buffer.fill(0.0);
self.write_pos = 0;
}
}
/// Oscilloscope node for visualizing audio and CV signals
pub struct OscilloscopeNode {
name: String,
time_scale: f32, // Milliseconds to display (10-1000ms)
trigger_mode: TriggerMode,
trigger_level: f32, // -1.0 to 1.0
last_sample: f32, // For edge detection
voct_value: f32, // Current V/oct input value
sample_counter: usize, // Counter for V/oct triggering
trigger_period: usize, // Period in samples for V/oct triggering
// Shared buffers for reading from Tauri commands
buffer: Arc<Mutex<CircularBuffer>>, // Audio buffer (mono downmix)
cv_buffer: Arc<Mutex<CircularBuffer>>, // CV buffer
mono_buf: Vec<f32>, // Scratch buffer for stereo-to-mono downmix
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl OscilloscopeNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
NodePort::new("CV In", SignalType::CV, 1),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_TIME_SCALE, "Time Scale", 10.0, 1000.0, 100.0, ParameterUnit::Time),
Parameter::new(PARAM_TRIGGER_MODE, "Trigger", 0.0, 3.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_TRIGGER_LEVEL, "Trigger Level", -1.0, 1.0, 0.0, ParameterUnit::Generic),
];
Self {
name,
time_scale: 100.0,
trigger_mode: TriggerMode::FreeRunning,
trigger_level: 0.0,
last_sample: 0.0,
voct_value: 0.0,
sample_counter: 0,
trigger_period: 480, // Default to ~100Hz at 48kHz
buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))),
cv_buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))),
mono_buf: vec![0.0; 2048],
inputs,
outputs,
parameters,
}
}
/// Get a clone of the buffer Arc for reading from external code (Tauri commands)
pub fn get_buffer(&self) -> Arc<Mutex<CircularBuffer>> {
Arc::clone(&self.buffer)
}
/// Read samples from the buffer (for Tauri commands)
pub fn read_samples(&self, count: usize) -> Vec<f32> {
if let Ok(buffer) = self.buffer.lock() {
buffer.read(count)
} else {
vec![0.0; count]
}
}
/// Read CV samples from the CV buffer (for Tauri commands)
pub fn read_cv_samples(&self, count: usize) -> Vec<f32> {
if let Ok(buffer) = self.cv_buffer.lock() {
buffer.read(count)
} else {
vec![0.0; count]
}
}
/// Clear the buffer
pub fn clear_buffer(&self) {
if let Ok(mut buffer) = self.buffer.lock() {
buffer.clear();
}
if let Ok(mut cv_buffer) = self.cv_buffer.lock() {
cv_buffer.clear();
}
}
/// Convert V/oct to frequency in Hz (matches oscillator convention)
/// 0V = A4 (440 Hz), ±1V per octave
fn voct_to_frequency(voct: f32) -> f32 {
440.0 * 2.0_f32.powf(voct)
}
}
impl AudioNode for OscilloscopeNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_TIME_SCALE => self.time_scale = value.clamp(10.0, 1000.0),
PARAM_TRIGGER_MODE => self.trigger_mode = TriggerMode::from_f32(value),
PARAM_TRIGGER_LEVEL => self.trigger_level = value.clamp(-1.0, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_TIME_SCALE => self.time_scale,
PARAM_TRIGGER_MODE => self.trigger_mode as i32 as f32,
PARAM_TRIGGER_LEVEL => self.trigger_level,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
let input = inputs[0];
let output = &mut outputs[0];
let stereo_len = input.len().min(output.len());
let frame_count = stereo_len / 2;
// Read CV input if available (port 1) — used for both display and V/Oct triggering
if inputs.len() > 1 && !inputs[1].is_empty() {
let cv_input = inputs[1];
let cv_len = frame_count.min(cv_input.len());
// Check if connected (not NaN sentinel)
if cv_len > 0 && !cv_input[0].is_nan() {
// Update V/Oct trigger period from CV value
self.voct_value = cv_input[0];
let frequency = Self::voct_to_frequency(self.voct_value);
let period_samples = (sample_rate as f32 / frequency).max(1.0);
self.trigger_period = period_samples as usize;
// Capture CV samples to buffer
if let Ok(mut cv_buffer) = self.cv_buffer.lock() {
cv_buffer.write(&cv_input[..cv_len]);
}
}
}
// Update sample counter for V/oct triggering
if self.trigger_mode == TriggerMode::VoltPerOctave {
self.sample_counter = (self.sample_counter + frame_count) % self.trigger_period;
}
// Pass through audio (copy input to output)
output[..stereo_len].copy_from_slice(&input[..stereo_len]);
// Capture audio as mono downmix to match CV time scale
if let Ok(mut buffer) = self.buffer.lock() {
for frame in 0..frame_count {
let left = input[frame * 2];
let right = input[frame * 2 + 1];
self.mono_buf[frame] = (left + right) * 0.5;
}
buffer.write(&self.mono_buf[..frame_count]);
}
// Update last sample for trigger detection
if frame_count > 0 {
self.last_sample = (input[0] + input[1]) * 0.5;
}
}
fn reset(&mut self) {
self.last_sample = 0.0;
self.voct_value = 0.0;
self.sample_counter = 0;
self.clear_buffer();
}
fn node_type(&self) -> &str {
"Oscilloscope"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
time_scale: self.time_scale,
trigger_mode: self.trigger_mode,
trigger_level: self.trigger_level,
last_sample: 0.0,
voct_value: 0.0,
sample_counter: 0,
trigger_period: 480,
buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))),
cv_buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))),
mono_buf: vec![0.0; 2048],
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn get_oscilloscope_data(&self, sample_count: usize) -> Option<Vec<f32>> {
Some(self.read_samples(sample_count))
}
fn get_oscilloscope_cv_data(&self, sample_count: usize) -> Option<Vec<f32>> {
Some(self.read_cv_samples(sample_count))
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,104 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType};
use crate::audio::midi::MidiEvent;
/// Audio output node - collects audio and passes it to the main output
pub struct AudioOutputNode {
name: String,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
}
impl AudioOutputNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
// Output node has an output for graph consistency, but it's typically the final node
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
Self {
name,
inputs,
outputs,
}
}
}
impl AudioNode for AudioOutputNode {
fn category(&self) -> NodeCategory {
NodeCategory::Output
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&[] // No parameters
}
fn set_parameter(&mut self, _id: u32, _value: f32) {
// No parameters
}
fn get_parameter(&self, _id: u32) -> f32 {
0.0
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Simply pass through the input to the output
let input = inputs[0];
let output = &mut outputs[0];
let len = input.len().min(output.len());
output[..len].copy_from_slice(&input[..len]);
}
fn reset(&mut self) {
// No state to reset
}
fn node_type(&self) -> &str {
"AudioOutput"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,168 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
use std::f32::consts::PI;
const PARAM_PAN: u32 = 0;
/// Stereo panning node using constant-power panning law
/// Converts mono audio to stereo with controllable pan position
pub struct PanNode {
name: String,
pan: f32,
left_gain: f32,
right_gain: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl PanNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
NodePort::new("Pan CV", SignalType::CV, 1),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_PAN, "Pan", -1.0, 1.0, 0.0, ParameterUnit::Generic),
];
let mut node = Self {
name,
pan: 0.0,
left_gain: 1.0,
right_gain: 1.0,
inputs,
outputs,
parameters,
};
node.update_gains();
node
}
/// Update left/right gains using constant-power panning law
fn update_gains(&mut self) {
// Constant-power panning: pan from -1 to +1 maps to angle 0 to PI/2
let angle = (self.pan + 1.0) * 0.5 * PI / 2.0;
self.left_gain = angle.cos();
self.right_gain = angle.sin();
}
}
impl AudioNode for PanNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_PAN => {
self.pan = value.clamp(-1.0, 1.0);
self.update_gains();
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_PAN => self.pan,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
let audio_input = inputs[0];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
// Process by frames, not samples
let frames = audio_input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
for frame in 0..frames_to_process {
// Pan CV input: -1..+1 directly (0 = center), defaults to parameter value when unconnected
let pan = cv_input_or_default(inputs, 1, frame, self.pan).clamp(-1.0, 1.0);
// Calculate gains using constant-power panning law
let angle = (pan + 1.0) * 0.5 * PI / 2.0;
let left_gain = angle.cos();
let right_gain = angle.sin();
// Read stereo input
let left_in = audio_input[frame * 2];
let right_in = audio_input[frame * 2 + 1];
// Mix both input channels with panning
// When pan is -1 (full left), left gets full signal, right gets nothing
// When pan is 0 (center), both get equal signal
// When pan is +1 (full right), right gets full signal, left gets nothing
output[frame * 2] = (left_in + right_in) * left_gain; // Left
output[frame * 2 + 1] = (left_in + right_in) * right_gain; // Right
}
}
fn reset(&mut self) {
// No state to reset
}
fn node_type(&self) -> &str {
"Pan"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
pan: self.pan,
left_gain: self.left_gain,
right_gain: self.right_gain,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,297 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use std::f32::consts::PI;
const PARAM_RATE: u32 = 0;
const PARAM_DEPTH: u32 = 1;
const PARAM_STAGES: u32 = 2;
const PARAM_FEEDBACK: u32 = 3;
const PARAM_WET_DRY: u32 = 4;
const MAX_STAGES: usize = 8;
/// First-order all-pass filter for phaser
struct AllPassFilter {
a1: f32,
zm1_left: f32,
zm1_right: f32,
}
impl AllPassFilter {
fn new() -> Self {
Self {
a1: 0.0,
zm1_left: 0.0,
zm1_right: 0.0,
}
}
fn set_coefficient(&mut self, frequency: f32, sample_rate: f32) {
// First-order all-pass coefficient
// a1 = (tan(π*f/fs) - 1) / (tan(π*f/fs) + 1)
let tan_val = ((PI * frequency) / sample_rate).tan();
self.a1 = (tan_val - 1.0) / (tan_val + 1.0);
}
fn process(&mut self, input: f32, is_left: bool) -> f32 {
let zm1 = if is_left {
&mut self.zm1_left
} else {
&mut self.zm1_right
};
// All-pass filter: y[n] = a1*x[n] + x[n-1] - a1*y[n-1]
let output = self.a1 * input + *zm1;
*zm1 = input - self.a1 * output;
output
}
fn reset(&mut self) {
self.zm1_left = 0.0;
self.zm1_right = 0.0;
}
}
/// Phaser effect using cascaded all-pass filters
pub struct PhaserNode {
name: String,
rate: f32, // LFO rate in Hz (0.1 to 10 Hz)
depth: f32, // Modulation depth 0.0 to 1.0
stages: usize, // Number of all-pass stages (2, 4, 6, or 8)
feedback: f32, // Feedback amount -0.95 to 0.95
wet_dry: f32, // 0.0 = dry only, 1.0 = wet only
// All-pass filters
filters: Vec<AllPassFilter>,
// Feedback buffers
feedback_left: f32,
feedback_right: f32,
// LFO state
lfo_phase: f32,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl PhaserNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_RATE, "Rate", 0.1, 10.0, 0.5, ParameterUnit::Frequency),
Parameter::new(PARAM_DEPTH, "Depth", 0.0, 1.0, 0.7, ParameterUnit::Generic),
Parameter::new(PARAM_STAGES, "Stages", 2.0, 8.0, 6.0, ParameterUnit::Generic),
Parameter::new(PARAM_FEEDBACK, "Feedback", -0.95, 0.95, 0.5, ParameterUnit::Generic),
Parameter::new(PARAM_WET_DRY, "Wet/Dry", 0.0, 1.0, 0.5, ParameterUnit::Generic),
];
let mut filters = Vec::with_capacity(MAX_STAGES);
for _ in 0..MAX_STAGES {
filters.push(AllPassFilter::new());
}
Self {
name,
rate: 0.5,
depth: 0.7,
stages: 6,
feedback: 0.5,
wet_dry: 0.5,
filters,
feedback_left: 0.0,
feedback_right: 0.0,
lfo_phase: 0.0,
sample_rate: 48000,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for PhaserNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_RATE => {
self.rate = value.clamp(0.1, 10.0);
}
PARAM_DEPTH => {
self.depth = value.clamp(0.0, 1.0);
}
PARAM_STAGES => {
// Round to even numbers: 2, 4, 6, 8
let stages = (value.round() as usize).clamp(2, 8);
self.stages = if stages % 2 == 0 { stages } else { stages + 1 };
}
PARAM_FEEDBACK => {
self.feedback = value.clamp(-0.95, 0.95);
}
PARAM_WET_DRY => {
self.wet_dry = value.clamp(0.0, 1.0);
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_RATE => self.rate,
PARAM_DEPTH => self.depth,
PARAM_STAGES => self.stages as f32,
PARAM_FEEDBACK => self.feedback,
PARAM_WET_DRY => self.wet_dry,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
}
let input = inputs[0];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
let frames = input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
let dry_gain = 1.0 - self.wet_dry;
let wet_gain = self.wet_dry;
// Frequency range for all-pass filters (200 Hz to 2000 Hz)
let min_freq = 200.0;
let max_freq = 2000.0;
for frame in 0..frames_to_process {
let left_in = input[frame * 2];
let right_in = input[frame * 2 + 1];
// Generate LFO value (sine wave, 0 to 1)
let lfo_value = (self.lfo_phase * 2.0 * PI).sin() * 0.5 + 0.5;
// Calculate modulated frequency
let frequency = min_freq + (max_freq - min_freq) * lfo_value * self.depth;
// Update all filter coefficients
for filter in self.filters.iter_mut().take(self.stages) {
filter.set_coefficient(frequency, self.sample_rate as f32);
}
// Add feedback
let mut left_sig = left_in + self.feedback_left * self.feedback;
let mut right_sig = right_in + self.feedback_right * self.feedback;
// Process through all-pass filter chain
for i in 0..self.stages {
left_sig = self.filters[i].process(left_sig, true);
right_sig = self.filters[i].process(right_sig, false);
}
// Store feedback
self.feedback_left = left_sig;
self.feedback_right = right_sig;
// Mix dry and wet signals
output[frame * 2] = left_in * dry_gain + left_sig * wet_gain;
output[frame * 2 + 1] = right_in * dry_gain + right_sig * wet_gain;
// Advance LFO phase
self.lfo_phase += self.rate / self.sample_rate as f32;
if self.lfo_phase >= 1.0 {
self.lfo_phase -= 1.0;
}
}
}
fn reset(&mut self) {
for filter in &mut self.filters {
filter.reset();
}
self.feedback_left = 0.0;
self.feedback_right = 0.0;
self.lfo_phase = 0.0;
}
fn node_type(&self) -> &str {
"Phaser"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
let mut filters = Vec::with_capacity(MAX_STAGES);
for _ in 0..MAX_STAGES {
filters.push(AllPassFilter::new());
}
Box::new(Self {
name: self.name.clone(),
rate: self.rate,
depth: self.depth,
stages: self.stages,
feedback: self.feedback,
wet_dry: self.wet_dry,
filters,
feedback_left: 0.0,
feedback_right: 0.0,
lfo_phase: 0.0,
sample_rate: self.sample_rate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,232 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_SCALE: u32 = 0;
const PARAM_ROOT_NOTE: u32 = 1;
/// Quantizer - snaps CV values to musical scales
/// Converts continuous CV into discrete pitch values based on a scale
/// Scale parameter:
/// 0 = Chromatic (all 12 notes)
/// 1 = Major scale
/// 2 = Minor scale (natural)
/// 3 = Pentatonic major
/// 4 = Pentatonic minor
/// 5 = Dorian
/// 6 = Phrygian
/// 7 = Lydian
/// 8 = Mixolydian
/// 9 = Whole tone
/// 10 = Octaves only
pub struct QuantizerNode {
name: String,
scale: u32,
root_note: u32, // 0-11 (C-B)
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl QuantizerNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("CV In", SignalType::CV, 0),
];
let outputs = vec![
NodePort::new("CV Out", SignalType::CV, 0),
NodePort::new("Gate Out", SignalType::CV, 1), // Trigger when note changes
];
let parameters = vec![
Parameter::new(PARAM_SCALE, "Scale", 0.0, 10.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_ROOT_NOTE, "Root", 0.0, 11.0, 0.0, ParameterUnit::Generic),
];
Self {
name,
scale: 0,
root_note: 0,
inputs,
outputs,
parameters,
}
}
/// Get the scale intervals (semitones from root)
fn get_scale_intervals(&self) -> Vec<u32> {
match self.scale {
0 => vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], // Chromatic
1 => vec![0, 2, 4, 5, 7, 9, 11], // Major
2 => vec![0, 2, 3, 5, 7, 8, 10], // Minor (natural)
3 => vec![0, 2, 4, 7, 9], // Pentatonic major
4 => vec![0, 3, 5, 7, 10], // Pentatonic minor
5 => vec![0, 2, 3, 5, 7, 9, 10], // Dorian
6 => vec![0, 1, 3, 5, 7, 8, 10], // Phrygian
7 => vec![0, 2, 4, 6, 7, 9, 11], // Lydian
8 => vec![0, 2, 4, 5, 7, 9, 10], // Mixolydian
9 => vec![0, 2, 4, 6, 8, 10], // Whole tone
10 => vec![0], // Octaves only
_ => vec![0, 2, 4, 5, 7, 9, 11], // Default to major
}
}
/// Quantize a CV value to the nearest note in the scale
fn quantize(&self, cv: f32) -> f32 {
// Convert V/Oct to MIDI note (standard: 0V = A4 = MIDI 69)
// cv = (midi_note - 69) / 12.0
// midi_note = cv * 12.0 + 69
let input_midi_note = cv * 12.0 + 69.0;
// Clamp to reasonable range
let input_midi_note = input_midi_note.clamp(0.0, 127.0);
// Get scale intervals (relative to root)
let intervals = self.get_scale_intervals();
// Find which octave we're in (relative to C)
let octave = (input_midi_note / 12.0).floor() as i32;
let note_in_octave = input_midi_note % 12.0;
// Adjust note relative to root (e.g., if root is D (2), then C becomes 10, D becomes 0)
let note_relative_to_root = (note_in_octave - self.root_note as f32 + 12.0) % 12.0;
// Find the nearest note in the scale (scale intervals are relative to root)
let mut closest_interval = intervals[0];
let mut min_distance = (note_relative_to_root - closest_interval as f32).abs();
for &interval in &intervals {
let distance = (note_relative_to_root - interval as f32).abs();
if distance < min_distance {
min_distance = distance;
closest_interval = interval;
}
}
// Calculate final MIDI note
// The scale interval is relative to root, so add root back to get absolute note
let quantized_note_in_octave = (self.root_note + closest_interval) % 12;
let quantized_midi_note = (octave * 12) as f32 + quantized_note_in_octave as f32;
// Clamp result
let quantized_midi_note = quantized_midi_note.clamp(0.0, 127.0);
// Convert back to V/Oct: voct = (midi_note - 69) / 12.0
(quantized_midi_note - 69.0) / 12.0
}
}
impl AudioNode for QuantizerNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_SCALE => self.scale = (value as u32).clamp(0, 10),
PARAM_ROOT_NOTE => self.root_note = (value as u32).clamp(0, 11),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_SCALE => self.scale as f32,
PARAM_ROOT_NOTE => self.root_note as f32,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
let input = inputs[0];
let length = input.len().min(outputs[0].len());
// Split outputs to avoid borrow conflicts
if outputs.len() > 1 {
let (cv_out, gate_out) = outputs.split_at_mut(1);
let cv_output = &mut cv_out[0];
let gate_output = &mut gate_out[0];
let gate_length = length.min(gate_output.len());
let mut last_note: Option<f32> = None;
for i in 0..length {
let quantized = self.quantize(input[i]);
cv_output[i] = quantized;
// Generate gate trigger when note changes
if i < gate_length {
if let Some(prev) = last_note {
gate_output[i] = if (quantized - prev).abs() > 0.001 { 1.0 } else { 0.0 };
} else {
gate_output[i] = 1.0; // First note triggers gate
}
}
last_note = Some(quantized);
}
} else {
// No gate output, just quantize CV
let cv_output = &mut outputs[0];
for i in 0..length {
cv_output[i] = self.quantize(input[i]);
}
}
}
fn reset(&mut self) {
// No state to reset
}
fn node_type(&self) -> &str {
"Quantizer"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
scale: self.scale,
root_note: self.root_note,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,321 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_ROOM_SIZE: u32 = 0;
const PARAM_DAMPING: u32 = 1;
const PARAM_WET_DRY: u32 = 2;
// Schroeder reverb uses a parallel bank of comb filters followed by series all-pass filters
// Comb filter delays (in samples at 48kHz)
const COMB_DELAYS: [usize; 8] = [1557, 1617, 1491, 1422, 1277, 1356, 1188, 1116];
// All-pass filter delays (in samples at 48kHz)
const ALLPASS_DELAYS: [usize; 4] = [225, 556, 441, 341];
/// Process a single channel through comb and all-pass filters
fn process_channel(
input: f32,
comb_filters: &mut [CombFilter],
allpass_filters: &mut [AllPassFilter],
) -> f32 {
// Sum parallel comb filters and scale down to prevent excessive gain
// With 8 comb filters, we need to scale the output significantly
let mut output = 0.0;
for comb in comb_filters.iter_mut() {
output += comb.process(input);
}
output *= 0.015; // Scale down the summed comb output
// Series all-pass filters
for allpass in allpass_filters.iter_mut() {
output = allpass.process(output);
}
output
}
/// Single comb filter for reverb
struct CombFilter {
buffer: Vec<f32>,
buffer_size: usize,
filter_store: f32,
write_pos: usize,
damp: f32,
feedback: f32,
}
impl CombFilter {
fn new(size: usize) -> Self {
Self {
buffer: vec![0.0; size],
buffer_size: size,
filter_store: 0.0,
write_pos: 0,
damp: 0.5,
feedback: 0.5,
}
}
fn process(&mut self, input: f32) -> f32 {
let output = self.buffer[self.write_pos];
// One-pole lowpass filter
self.filter_store = output * (1.0 - self.damp) + self.filter_store * self.damp;
self.buffer[self.write_pos] = input + self.filter_store * self.feedback;
self.write_pos = (self.write_pos + 1) % self.buffer_size;
output
}
fn mute(&mut self) {
self.buffer.fill(0.0);
self.filter_store = 0.0;
}
fn set_damp(&mut self, val: f32) {
self.damp = val;
}
fn set_feedback(&mut self, val: f32) {
self.feedback = val;
}
}
/// Single all-pass filter for reverb
struct AllPassFilter {
buffer: Vec<f32>,
buffer_size: usize,
write_pos: usize,
}
impl AllPassFilter {
fn new(size: usize) -> Self {
Self {
buffer: vec![0.0; size],
buffer_size: size,
write_pos: 0,
}
}
fn process(&mut self, input: f32) -> f32 {
let delayed = self.buffer[self.write_pos];
let output = -input + delayed;
self.buffer[self.write_pos] = input + delayed * 0.5;
self.write_pos = (self.write_pos + 1) % self.buffer_size;
output
}
fn mute(&mut self) {
self.buffer.fill(0.0);
}
}
/// Schroeder reverb node with room size and damping controls
pub struct ReverbNode {
name: String,
room_size: f32, // 0.0 to 1.0
damping: f32, // 0.0 to 1.0
wet_dry: f32, // 0.0 = dry only, 1.0 = wet only
// Left channel filters
comb_filters_left: Vec<CombFilter>,
allpass_filters_left: Vec<AllPassFilter>,
// Right channel filters
comb_filters_right: Vec<CombFilter>,
allpass_filters_right: Vec<AllPassFilter>,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl ReverbNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_ROOM_SIZE, "Room Size", 0.0, 1.0, 0.5, ParameterUnit::Generic),
Parameter::new(PARAM_DAMPING, "Damping", 0.0, 1.0, 0.5, ParameterUnit::Generic),
Parameter::new(PARAM_WET_DRY, "Wet/Dry", 0.0, 1.0, 0.3, ParameterUnit::Generic),
];
// Create comb filters for both channels
// Right channel has slightly different delays to create stereo effect
let comb_filters_left: Vec<CombFilter> = COMB_DELAYS.iter().map(|&d| CombFilter::new(d)).collect();
let comb_filters_right: Vec<CombFilter> = COMB_DELAYS.iter().map(|&d| CombFilter::new(d + 23)).collect();
// Create all-pass filters for both channels
let allpass_filters_left: Vec<AllPassFilter> = ALLPASS_DELAYS.iter().map(|&d| AllPassFilter::new(d)).collect();
let allpass_filters_right: Vec<AllPassFilter> = ALLPASS_DELAYS.iter().map(|&d| AllPassFilter::new(d + 23)).collect();
let mut node = Self {
name,
room_size: 0.5,
damping: 0.5,
wet_dry: 0.3,
comb_filters_left,
allpass_filters_left,
comb_filters_right,
allpass_filters_right,
inputs,
outputs,
parameters,
};
node.update_filters();
node
}
fn update_filters(&mut self) {
// Room size affects feedback (larger room = more feedback)
let feedback = 0.28 + self.room_size * 0.7;
// Update all comb filters
for comb in &mut self.comb_filters_left {
comb.set_feedback(feedback);
comb.set_damp(self.damping);
}
for comb in &mut self.comb_filters_right {
comb.set_feedback(feedback);
comb.set_damp(self.damping);
}
}
}
impl AudioNode for ReverbNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_ROOM_SIZE => {
self.room_size = value.clamp(0.0, 1.0);
self.update_filters();
}
PARAM_DAMPING => {
self.damping = value.clamp(0.0, 1.0);
self.update_filters();
}
PARAM_WET_DRY => {
self.wet_dry = value.clamp(0.0, 1.0);
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_ROOM_SIZE => self.room_size,
PARAM_DAMPING => self.damping,
PARAM_WET_DRY => self.wet_dry,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
let input = inputs[0];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
let frames = input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
let dry_gain = 1.0 - self.wet_dry;
let wet_gain = self.wet_dry;
for frame in 0..frames_to_process {
let left_in = input[frame * 2];
let right_in = input[frame * 2 + 1];
// Process both channels
let left_wet = process_channel(
left_in,
&mut self.comb_filters_left,
&mut self.allpass_filters_left,
);
let right_wet = process_channel(
right_in,
&mut self.comb_filters_right,
&mut self.allpass_filters_right,
);
// Mix dry and wet signals
output[frame * 2] = left_in * dry_gain + left_wet * wet_gain;
output[frame * 2 + 1] = right_in * dry_gain + right_wet * wet_gain;
}
}
fn reset(&mut self) {
for comb in &mut self.comb_filters_left {
comb.mute();
}
for comb in &mut self.comb_filters_right {
comb.mute();
}
for allpass in &mut self.allpass_filters_left {
allpass.mute();
}
for allpass in &mut self.allpass_filters_right {
allpass.mute();
}
}
fn node_type(&self) -> &str {
"Reverb"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self::new(self.name.clone()))
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,145 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
const PARAM_MIX: u32 = 0;
/// Ring Modulator - multiplies two signals together
/// Creates metallic, inharmonic timbres by multiplying carrier and modulator
pub struct RingModulatorNode {
name: String,
mix: f32, // 0.0 = dry (carrier only), 1.0 = fully modulated
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl RingModulatorNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Carrier", SignalType::Audio, 0),
NodePort::new("Modulator", SignalType::Audio, 1),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic),
];
Self {
name,
mix: 1.0,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for RingModulatorNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_MIX => self.mix = value.clamp(0.0, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_MIX => self.mix,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let length = output.len();
// Get carrier input
let carrier = if !inputs.is_empty() && !inputs[0].is_empty() {
inputs[0]
} else {
&[]
};
// Get modulator input
let modulator = if inputs.len() > 1 && !inputs[1].is_empty() {
inputs[1]
} else {
&[]
};
// Process each sample
for i in 0..length {
let carrier_sample = if i < carrier.len() { carrier[i] } else { 0.0 };
let modulator_sample = if i < modulator.len() { modulator[i] } else { 0.0 };
// Ring modulation: multiply the two signals
let modulated = carrier_sample * modulator_sample;
// Mix between dry (carrier) and wet (modulated)
output[i] = carrier_sample * (1.0 - self.mix) + modulated * self.mix;
}
}
fn reset(&mut self) {
// No state to reset
}
fn node_type(&self) -> &str {
"RingModulator"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
mix: self.mix,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,145 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType};
use crate::audio::midi::MidiEvent;
/// Sample & Hold - samples input CV when triggered by a gate signal
/// Classic modular synth utility for creating stepped sequences
pub struct SampleHoldNode {
name: String,
held_value: f32,
last_gate: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl SampleHoldNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("CV In", SignalType::CV, 0),
NodePort::new("Gate In", SignalType::CV, 1),
];
let outputs = vec![
NodePort::new("CV Out", SignalType::CV, 0),
];
let parameters = vec![];
Self {
name,
held_value: 0.0,
last_gate: 0.0,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for SampleHoldNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, _id: u32, _value: f32) {
// No parameters
}
fn get_parameter(&self, _id: u32) -> f32 {
0.0
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let length = output.len();
// Get CV input
let cv_input = if !inputs.is_empty() && !inputs[0].is_empty() {
inputs[0]
} else {
&[]
};
// Get Gate input
let gate_input = if inputs.len() > 1 && !inputs[1].is_empty() {
inputs[1]
} else {
&[]
};
// Process each sample
for i in 0..length {
let cv = if i < cv_input.len() { cv_input[i] } else { 0.0 };
let gate = if i < gate_input.len() { gate_input[i] } else { 0.0 };
// Detect rising edge (trigger)
let gate_active = gate > 0.5;
let last_gate_active = self.last_gate > 0.5;
if gate_active && !last_gate_active {
// Rising edge detected - sample the input
self.held_value = cv;
}
self.last_gate = gate;
output[i] = self.held_value;
}
}
fn reset(&mut self) {
self.held_value = 0.0;
self.last_gate = 0.0;
}
fn node_type(&self) -> &str {
"SampleHold"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
held_value: self.held_value,
last_gate: self.last_gate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,229 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use beamdsp::{ScriptVM, SampleSlot};
/// A user-scriptable audio node powered by the BeamDSP VM
pub struct ScriptNode {
name: String,
script_name: String,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
category: NodeCategory,
vm: ScriptVM,
source_code: String,
ui_declaration: beamdsp::UiDeclaration,
}
impl ScriptNode {
/// Create a default empty Script node (compiles a passthrough on first script set)
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
// Default: single audio in, single audio out, no params
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
// Create a minimal VM that just halts (no-op)
let vm = ScriptVM::new(
vec![255], // Halt
Vec::new(),
Vec::new(),
0,
&[],
0,
&[],
0,
);
Self {
name,
script_name: "Script".into(),
inputs,
outputs,
parameters: Vec::new(),
category: NodeCategory::Effect,
vm,
source_code: String::new(),
ui_declaration: beamdsp::UiDeclaration { elements: Vec::new() },
}
}
/// Compile and load a new script, replacing the current one.
/// Returns Ok(ui_declaration) on success, or Err(error_message) on failure.
pub fn set_script(&mut self, source: &str) -> Result<beamdsp::UiDeclaration, String> {
let compiled = beamdsp::compile(source).map_err(|e| e.to_string())?;
// Update ports
self.inputs = compiled.input_ports.iter().enumerate().map(|(i, p)| {
let sig = match p.signal {
beamdsp::ast::SignalKind::Audio => SignalType::Audio,
beamdsp::ast::SignalKind::Cv => SignalType::CV,
beamdsp::ast::SignalKind::Midi => SignalType::Midi,
};
NodePort::new(&p.name, sig, i)
}).collect();
self.outputs = compiled.output_ports.iter().enumerate().map(|(i, p)| {
let sig = match p.signal {
beamdsp::ast::SignalKind::Audio => SignalType::Audio,
beamdsp::ast::SignalKind::Cv => SignalType::CV,
beamdsp::ast::SignalKind::Midi => SignalType::Midi,
};
NodePort::new(&p.name, sig, i)
}).collect();
// Update parameters
self.parameters = compiled.parameters.iter().enumerate().map(|(i, p)| {
let unit = if p.unit == "dB" {
ParameterUnit::Decibels
} else if p.unit == "Hz" {
ParameterUnit::Frequency
} else if p.unit == "s" {
ParameterUnit::Time
} else if p.unit == "%" {
ParameterUnit::Percent
} else {
ParameterUnit::Generic
};
Parameter::new(i as u32, &p.name, p.min, p.max, p.default, unit)
}).collect();
// Update category
self.category = match compiled.category {
beamdsp::ast::CategoryKind::Generator => NodeCategory::Generator,
beamdsp::ast::CategoryKind::Effect => NodeCategory::Effect,
beamdsp::ast::CategoryKind::Utility => NodeCategory::Utility,
};
self.script_name = compiled.name.clone();
self.vm = compiled.vm;
self.source_code = compiled.source;
self.ui_declaration = compiled.ui_declaration.clone();
Ok(compiled.ui_declaration)
}
/// Set audio data for a sample slot
pub fn set_sample(&mut self, slot_index: usize, data: Vec<f32>, sample_rate: u32, name: String) {
if slot_index < self.vm.sample_slots.len() {
let frame_count = data.len() / 2;
self.vm.sample_slots[slot_index] = SampleSlot {
data,
frame_count,
sample_rate,
name,
};
}
}
pub fn source_code(&self) -> &str {
&self.source_code
}
pub fn ui_declaration(&self) -> &beamdsp::UiDeclaration {
&self.ui_declaration
}
pub fn sample_slot_names(&self) -> Vec<String> {
self.vm.sample_slots.iter().map(|s| s.name.clone()).collect()
}
}
impl AudioNode for ScriptNode {
fn category(&self) -> NodeCategory {
self.category
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
let idx = id as usize;
let params = self.vm.params_mut();
if idx < params.len() {
params[idx] = value;
}
}
fn get_parameter(&self, id: u32) -> f32 {
let idx = id as usize;
let params = self.vm.params();
if idx < params.len() {
params[idx]
} else {
0.0
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
// Determine buffer size from output buffer length
let buffer_size = outputs[0].len();
// Execute VM — on error, zero all outputs (fail silent on audio thread)
if let Err(_) = self.vm.execute(inputs, outputs, sample_rate, buffer_size) {
for out in outputs.iter_mut() {
out.fill(0.0);
}
}
}
fn reset(&mut self) {
self.vm.reset_state();
}
fn node_type(&self) -> &str {
"Script"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
let mut cloned = Self {
name: self.name.clone(),
script_name: self.script_name.clone(),
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
category: self.category,
vm: self.vm.clone(),
source_code: self.source_code.clone(),
ui_declaration: self.ui_declaration.clone(),
};
cloned.vm.reset_state();
Box::new(cloned)
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,308 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
use crate::time::Beats;
const PARAM_MODE: u32 = 0;
const PARAM_STEPS: u32 = 1;
const PARAM_SCALE_MODE: u32 = 2;
const PARAM_KEY: u32 = 3;
const PARAM_SCALE_TYPE: u32 = 4;
const PARAM_OCTAVE: u32 = 5;
const PARAM_VELOCITY: u32 = 6;
const PARAM_ROW_BASE: u32 = 7;
const NUM_ROWS: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq)]
enum SeqMode {
OnePerCycle = 0,
AllPerCycle = 1,
}
impl SeqMode {
fn from_f32(v: f32) -> Self {
if v.round() as i32 >= 1 { SeqMode::AllPerCycle } else { SeqMode::OnePerCycle }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ScaleMode {
Chromatic = 0,
Diatonic = 1,
}
impl ScaleMode {
fn from_f32(v: f32) -> Self {
if v.round() as i32 >= 1 { ScaleMode::Diatonic } else { ScaleMode::Chromatic }
}
}
/// Scale interval patterns (semitones from root)
const SCALES: &[&[u8]] = &[
&[0, 2, 4, 5, 7, 9, 11], // Major
&[0, 2, 3, 5, 7, 8, 10], // Minor
&[0, 2, 3, 5, 7, 9, 10], // Dorian
&[0, 2, 4, 5, 7, 9, 10], // Mixolydian
&[0, 2, 4, 7, 9], // Pentatonic Major
&[0, 3, 5, 7, 10], // Pentatonic Minor
&[0, 3, 5, 6, 7, 10], // Blues
&[0, 2, 3, 5, 7, 8, 11], // Harmonic Minor
];
/// Step Sequencer node — MxN grid of note triggers with CV phase input and MIDI output.
pub struct SequencerNode {
name: String,
/// Grid state: row_patterns[row] is a u16 bitmask (bit N = step N active)
row_patterns: [u16; 16],
num_steps: usize,
/// Scale mapping
scale_mode: ScaleMode,
key: u8,
scale_type: usize,
base_octave: u8,
velocity: u8,
/// Playback state
mode: SeqMode,
current_step: usize,
prev_phase: f32,
/// Notes currently "on" from the previous step
prev_active_notes: Vec<u8>,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl SequencerNode {
pub fn new(name: impl Into<String>) -> Self {
let inputs = vec![
NodePort::new("Phase", SignalType::CV, 0),
];
let outputs = vec![
NodePort::new("MIDI Out", SignalType::Midi, 0),
];
let mut parameters = vec![
Parameter::new(PARAM_MODE, "Mode", 0.0, 1.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_STEPS, "Steps", 0.0, 2.0, 2.0, ParameterUnit::Generic),
Parameter::new(PARAM_SCALE_MODE, "Scale Mode", 0.0, 1.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_KEY, "Key", 0.0, 11.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_SCALE_TYPE, "Scale", 0.0, 7.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_OCTAVE, "Octave", 0.0, 8.0, 4.0, ParameterUnit::Generic),
Parameter::new(PARAM_VELOCITY, "Velocity", 1.0, 127.0, 100.0, ParameterUnit::Generic),
];
// Row bitmask parameters
for row in 0..16u32 {
parameters.push(Parameter::new(
PARAM_ROW_BASE + row,
"Row",
0.0,
65535.0,
0.0,
ParameterUnit::Generic,
));
}
Self {
name: name.into(),
row_patterns: [0u16; 16],
num_steps: 16,
scale_mode: ScaleMode::Chromatic,
key: 0,
scale_type: 0,
base_octave: 4,
velocity: 100,
mode: SeqMode::OnePerCycle,
current_step: 0,
prev_phase: 0.0,
prev_active_notes: Vec::new(),
inputs,
outputs,
parameters,
}
}
fn steps_from_param(v: f32) -> usize {
match v.round() as i32 {
0 => 4,
1 => 8,
_ => 16,
}
}
fn row_to_midi_note(&self, row: usize) -> u8 {
let base = self.key as u16 + self.base_octave as u16 * 12;
let note = match self.scale_mode {
ScaleMode::Chromatic => base + row as u16,
ScaleMode::Diatonic => {
let scale = SCALES[self.scale_type.min(SCALES.len() - 1)];
let octave_offset = row / scale.len();
let degree = row % scale.len();
base + octave_offset as u16 * 12 + scale[degree] as u16
}
};
(note as u8).min(127)
}
}
impl AudioNode for SequencerNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_MODE => self.mode = SeqMode::from_f32(value),
PARAM_STEPS => self.num_steps = Self::steps_from_param(value),
PARAM_SCALE_MODE => self.scale_mode = ScaleMode::from_f32(value),
PARAM_KEY => self.key = (value.round() as u8).min(11),
PARAM_SCALE_TYPE => self.scale_type = (value.round() as usize).min(SCALES.len() - 1),
PARAM_OCTAVE => self.base_octave = (value.round() as u8).min(8),
PARAM_VELOCITY => self.velocity = (value.round() as u8).clamp(1, 127),
id if id >= PARAM_ROW_BASE && id < PARAM_ROW_BASE + 16 => {
let row = (id - PARAM_ROW_BASE) as usize;
self.row_patterns[row] = value.round() as u16;
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_MODE => self.mode as i32 as f32,
PARAM_STEPS => match self.num_steps {
4 => 0.0,
8 => 1.0,
_ => 2.0,
},
PARAM_SCALE_MODE => self.scale_mode as i32 as f32,
PARAM_KEY => self.key as f32,
PARAM_SCALE_TYPE => self.scale_type as f32,
PARAM_OCTAVE => self.base_octave as f32,
PARAM_VELOCITY => self.velocity as f32,
id if id >= PARAM_ROW_BASE && id < PARAM_ROW_BASE + 16 => {
let row = (id - PARAM_ROW_BASE) as usize;
self.row_patterns[row] as f32
}
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
_outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if midi_outputs.is_empty() {
return;
}
let len = if !inputs.is_empty() { inputs[0].len() } else { return };
for i in 0..len {
let phase = cv_input_or_default(inputs, 0, i, 0.0).clamp(0.0, 1.0);
let new_step = match self.mode {
SeqMode::OnePerCycle => {
if self.prev_phase > 0.7 && phase < 0.3 {
(self.current_step + 1) % self.num_steps
} else {
self.current_step
}
}
SeqMode::AllPerCycle => {
((phase * self.num_steps as f32).floor() as usize)
.min(self.num_steps - 1)
}
};
if new_step != self.current_step {
// Compute active notes for the new step
let mut new_notes = Vec::new();
for row in 0..NUM_ROWS {
if self.row_patterns[row] & (1 << new_step) != 0 {
let note = self.row_to_midi_note(row);
new_notes.push(note);
}
}
// Note-off for notes no longer active
for &note in &self.prev_active_notes {
if !new_notes.contains(&note) {
midi_outputs[0].push(MidiEvent::note_off(Beats::ZERO, 0, note, 0));
}
}
// Note-on for newly active notes
for &note in &new_notes {
if !self.prev_active_notes.contains(&note) {
midi_outputs[0].push(MidiEvent::note_on(Beats::ZERO, 0, note, self.velocity));
}
}
self.prev_active_notes = new_notes;
self.current_step = new_step;
}
self.prev_phase = phase;
}
}
fn reset(&mut self) {
self.current_step = 0;
self.prev_phase = 0.0;
self.prev_active_notes.clear();
}
fn node_type(&self) -> &str {
"Sequencer"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
row_patterns: self.row_patterns,
num_steps: self.num_steps,
scale_mode: self.scale_mode,
key: self.key,
scale_type: self.scale_type,
base_octave: self.base_octave,
velocity: self.velocity,
mode: self.mode,
current_step: 0,
prev_phase: 0.0,
prev_active_notes: Vec::new(),
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,293 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
use std::sync::{Arc, Mutex};
// Parameters
const PARAM_GAIN: u32 = 0;
const PARAM_LOOP: u32 = 1;
const PARAM_PITCH_SHIFT: u32 = 2;
/// Simple single-sample playback node with pitch shifting
pub struct SimpleSamplerNode {
name: String,
// Sample data (shared, can be set externally)
sample_data: Arc<Mutex<Vec<f32>>>,
sample_rate_original: f32,
sample_path: Option<String>, // Path to loaded sample file
// Playback state
playhead: f32, // Fractional position in sample
is_playing: bool,
gate_prev: bool,
// Parameters
gain: f32,
loop_enabled: bool,
pitch_shift: f32, // Additional pitch shift in semitones
root_note: u8, // MIDI note for original pitch playback (default 69 = A4)
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl SimpleSamplerNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("V/Oct", SignalType::CV, 0),
NodePort::new("Gate", SignalType::CV, 1),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_GAIN, "Gain", 0.0, 2.0, 1.0, ParameterUnit::Generic),
Parameter::new(PARAM_LOOP, "Loop", 0.0, 1.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_PITCH_SHIFT, "Pitch Shift", -12.0, 12.0, 0.0, ParameterUnit::Generic),
];
Self {
name,
sample_data: Arc::new(Mutex::new(Vec::new())),
sample_rate_original: 48000.0,
sample_path: None,
playhead: 0.0,
is_playing: false,
gate_prev: false,
gain: 1.0,
loop_enabled: false,
pitch_shift: 0.0,
root_note: 69, // A4 — V/Oct 0.0 from MIDI-to-CV
inputs,
outputs,
parameters,
}
}
/// Set the sample data (mono)
pub fn set_sample(&mut self, data: Vec<f32>, sample_rate: f32) {
let mut sample = self.sample_data.lock().unwrap();
*sample = data;
self.sample_rate_original = sample_rate;
}
/// Get the sample data reference (for external loading)
pub fn get_sample_data(&self) -> Arc<Mutex<Vec<f32>>> {
Arc::clone(&self.sample_data)
}
/// Load a sample from a file path
pub fn load_sample_from_file(&mut self, path: &str) -> Result<(), String> {
use crate::audio::sample_loader::load_audio_file;
let sample_data = load_audio_file(path)?;
self.set_sample(sample_data.samples, sample_data.sample_rate as f32);
self.sample_path = Some(path.to_string());
Ok(())
}
/// Get the currently loaded sample path
pub fn get_sample_path(&self) -> Option<&str> {
self.sample_path.as_deref()
}
/// Get the current sample data and sample rate (for preset embedding)
pub fn get_sample_data_for_embedding(&self) -> (Vec<f32>, f32) {
let sample = self.sample_data.lock().unwrap();
(sample.clone(), self.sample_rate_original)
}
/// Convert V/oct CV to playback speed multiplier
/// Accounts for root_note: when the incoming MIDI note matches root_note,
/// the sample plays at original speed. V/Oct 0.0 = A4 (MIDI 69) by convention.
fn voct_to_speed(&self, voct: f32) -> f32 {
// Offset so root_note plays at original speed
let root_offset = (self.root_note as f32 - 69.0) / 12.0;
let total_semitones = (voct - root_offset) * 12.0 + self.pitch_shift;
2.0_f32.powf(total_semitones / 12.0)
}
/// Set the root note (MIDI note number for original-pitch playback)
pub fn set_root_note(&mut self, note: u8) {
self.root_note = note.min(127);
}
/// Get the current root note
pub fn root_note(&self) -> u8 {
self.root_note
}
/// Read sample at playhead with linear interpolation
fn read_sample(&self, playhead: f32, sample: &[f32]) -> f32 {
if sample.is_empty() {
return 0.0;
}
let index = playhead.floor() as usize;
let frac = playhead - playhead.floor();
if index >= sample.len() {
return 0.0;
}
let sample1 = sample[index];
let sample2 = if index + 1 < sample.len() {
sample[index + 1]
} else if self.loop_enabled {
sample[0] // Loop back to start
} else {
0.0
};
// Linear interpolation
sample1 + (sample2 - sample1) * frac
}
}
impl AudioNode for SimpleSamplerNode {
fn category(&self) -> NodeCategory {
NodeCategory::Generator
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_GAIN => {
self.gain = value.clamp(0.0, 2.0);
}
PARAM_LOOP => {
self.loop_enabled = value > 0.5;
}
PARAM_PITCH_SHIFT => {
self.pitch_shift = value.clamp(-12.0, 12.0);
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_GAIN => self.gain,
PARAM_LOOP => if self.loop_enabled { 1.0 } else { 0.0 },
PARAM_PITCH_SHIFT => self.pitch_shift,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
// Lock the sample data
let sample_data = self.sample_data.lock().unwrap();
if sample_data.is_empty() {
// No sample loaded, output silence
for output in outputs.iter_mut() {
output.fill(0.0);
}
return;
}
let output = &mut outputs[0];
let frames = output.len() / 2;
for frame in 0..frames {
// Read CV inputs (both are mono signals)
// V/Oct: when unconnected, defaults to 0.0 (original pitch)
let voct = cv_input_or_default(inputs, 0, frame, 0.0);
// Gate: when unconnected, defaults to 0.0 (off)
let gate = cv_input_or_default(inputs, 1, frame, 0.0);
// Detect gate trigger (rising edge)
let gate_active = gate > 0.5;
if gate_active && !self.gate_prev {
// Trigger: start playback from beginning
self.playhead = 0.0;
self.is_playing = true;
}
self.gate_prev = gate_active;
// Generate sample
let sample = if self.is_playing {
let s = self.read_sample(self.playhead, &sample_data);
// Calculate playback speed from V/Oct
let speed = self.voct_to_speed(voct);
// Advance playhead with resampling
let speed_adjusted = speed * (self.sample_rate_original / sample_rate as f32);
self.playhead += speed_adjusted;
// Check if we've reached the end
if self.playhead >= sample_data.len() as f32 {
if self.loop_enabled {
// Loop back to start
self.playhead = self.playhead % sample_data.len() as f32;
} else {
// Stop playback
self.is_playing = false;
self.playhead = 0.0;
}
}
s * self.gain
} else {
0.0
};
// Output stereo (same signal to both channels)
output[frame * 2] = sample;
output[frame * 2 + 1] = sample;
}
}
fn reset(&mut self) {
self.playhead = 0.0;
self.is_playing = false;
self.gate_prev = false;
}
fn node_type(&self) -> &str {
"SimpleSampler"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self::new(self.name.clone()))
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,165 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
const PARAM_RISE_TIME: u32 = 0;
const PARAM_FALL_TIME: u32 = 1;
/// Slew limiter - limits the rate of change of a CV signal
/// Useful for creating portamento/glide effects and smoothing control signals
pub struct SlewLimiterNode {
name: String,
rise_time: f32, // Time in seconds to rise from 0 to 1
fall_time: f32, // Time in seconds to fall from 1 to 0
last_value: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl SlewLimiterNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("CV In", SignalType::CV, 0),
];
let outputs = vec![
NodePort::new("CV Out", SignalType::CV, 0),
];
let parameters = vec![
Parameter::new(PARAM_RISE_TIME, "Rise Time", 0.0, 5.0, 0.01, ParameterUnit::Time),
Parameter::new(PARAM_FALL_TIME, "Fall Time", 0.0, 5.0, 0.01, ParameterUnit::Time),
];
Self {
name,
rise_time: 0.01,
fall_time: 0.01,
last_value: 0.0,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for SlewLimiterNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_RISE_TIME => self.rise_time = value.clamp(0.0, 5.0),
PARAM_FALL_TIME => self.fall_time = value.clamp(0.0, 5.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_RISE_TIME => self.rise_time,
PARAM_FALL_TIME => self.fall_time,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let length = output.len();
// Calculate maximum change per sample
let sample_duration = 1.0 / sample_rate as f32;
// Rise/fall rates (units per second)
let rise_rate = if self.rise_time > 0.0001 {
1.0 / self.rise_time
} else {
f32::MAX // No limiting
};
let fall_rate = if self.fall_time > 0.0001 {
1.0 / self.fall_time
} else {
f32::MAX // No limiting
};
for i in 0..length {
// Use cv_input_or_default to handle unconnected inputs (NaN sentinel)
// Default to last_value so output holds steady when unconnected
let target = cv_input_or_default(inputs, 0, i, self.last_value);
let difference = target - self.last_value;
let max_change = if difference > 0.0 {
// Rising
rise_rate * sample_duration
} else {
// Falling
fall_rate * sample_duration
};
// Limit the change
let limited_difference = difference.clamp(-max_change, max_change);
self.last_value += limited_difference;
output[i] = self.last_value;
}
}
fn reset(&mut self) {
self.last_value = 0.0;
}
fn node_type(&self) -> &str {
"SlewLimiter"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
rise_time: self.rise_time,
fall_time: self.fall_time,
last_value: self.last_value,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,112 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType};
use crate::audio::midi::MidiEvent;
/// Splitter node - copies input to multiple outputs for parallel routing
pub struct SplitterNode {
name: String,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl SplitterNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Out 1", SignalType::Audio, 0),
NodePort::new("Out 2", SignalType::Audio, 1),
NodePort::new("Out 3", SignalType::Audio, 2),
NodePort::new("Out 4", SignalType::Audio, 3),
];
let parameters = vec![];
Self {
name,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for SplitterNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, _id: u32, _value: f32) {
// No parameters
}
fn get_parameter(&self, _id: u32) -> f32 {
0.0
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
let input = inputs[0];
// Copy input to all outputs
for output in outputs.iter_mut() {
let len = input.len().min(output.len());
output[..len].copy_from_slice(&input[..len]);
}
}
fn reset(&mut self) {
// No state to reset
}
fn node_type(&self) -> &str {
"Splitter"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,177 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType};
use crate::audio::midi::MidiEvent;
use crate::audio::track::TrackId;
/// Subtrack inputs node for metatracks.
///
/// Exposes one output port per child track so users can route individual subtracks
/// independently in the mixing graph (e.g., for sidechain effects).
///
/// Audio is injected into pre-allocated per-slot buffers by the render system before
/// the graph is processed — no heap allocation occurs during audio rendering.
pub struct SubtrackInputsNode {
name: String,
/// Ordered list of (TrackId, display_name) for each subtrack slot.
/// TrackId is used by the render system to match the right buffer to the right slot.
subtracks: Vec<(TrackId, String)>,
/// Output port descriptors — rebuilt whenever subtracks changes.
outputs: Vec<NodePort>,
/// Pre-allocated audio buffers, one per subtrack slot (stereo interleaved, length = buffer_size * 2).
/// Filled by `inject_subtrack_audio` before graph processing; no alloc per frame.
buffers: Vec<Vec<f32>>,
/// The buffer size this node was last sized for.
buffer_size: usize,
}
impl SubtrackInputsNode {
pub fn new(name: impl Into<String>, subtracks: Vec<(TrackId, String)>) -> Self {
let outputs = Self::build_outputs(&subtracks);
let n = subtracks.len();
Self {
name: name.into(),
subtracks,
outputs,
buffers: vec![Vec::new(); n],
buffer_size: 0,
}
}
fn build_outputs(subtracks: &[(TrackId, String)]) -> Vec<NodePort> {
subtracks
.iter()
.enumerate()
.map(|(i, (_, name))| NodePort::new(name.as_str(), SignalType::Audio, i))
.collect()
}
/// Inject audio from a child track into its pre-allocated slot.
///
/// `idx` is the slot index (matching the order in `subtracks`).
/// Called by the render system once per child per frame — no allocation.
pub fn inject_subtrack_audio(&mut self, idx: usize, audio: &[f32]) {
if let Some(buf) = self.buffers.get_mut(idx) {
let len = buf.len().min(audio.len());
buf[..len].copy_from_slice(&audio[..len]);
// Zero any remaining samples if audio is shorter than the buffer
if audio.len() < buf.len() {
buf[audio.len()..].fill(0.0);
}
}
}
/// Rebuild ports and resize pre-allocated buffers.
///
/// Only reallocates when the subtrack list actually changes in size or content.
/// Pass `buffer_size` in frames (stereo buffers will be `buffer_size * 2` samples).
pub fn update_subtracks(&mut self, subtracks: Vec<(TrackId, String)>, buffer_size: usize) {
let n = subtracks.len();
self.outputs = Self::build_outputs(&subtracks);
self.subtracks = subtracks;
self.buffer_size = buffer_size;
// Resize buffers: keep existing allocations where possible
self.buffers.resize_with(n, Vec::new);
for buf in &mut self.buffers {
let target = buffer_size * 2; // stereo interleaved
if buf.len() != target {
buf.resize(target, 0.0);
}
}
}
/// Return the slot index for the given TrackId, or None if not found.
pub fn subtrack_index_for(&self, track_id: TrackId) -> Option<usize> {
self.subtracks.iter().position(|(id, _)| *id == track_id)
}
/// Return the number of subtrack slots.
pub fn num_subtracks(&self) -> usize {
self.subtracks.len()
}
/// Return the ordered subtrack list.
pub fn subtracks(&self) -> &[(TrackId, String)] {
&self.subtracks
}
}
impl AudioNode for SubtrackInputsNode {
fn category(&self) -> NodeCategory {
NodeCategory::Input
}
fn inputs(&self) -> &[NodePort] {
&[] // No inputs — audio is injected externally
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&[] // No user-facing parameters; port count is stored via num_ports in serialization
}
fn set_parameter(&mut self, _id: u32, _value: f32) {}
fn get_parameter(&self, _id: u32) -> f32 {
0.0
}
fn process(
&mut self,
_inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
// Copy each pre-filled buffer to its output port
for (i, output) in outputs.iter_mut().enumerate() {
if let Some(buf) = self.buffers.get(i) {
let len = output.len().min(buf.len());
if len > 0 {
output[..len].copy_from_slice(&buf[..len]);
}
if output.len() > len {
output[len..].fill(0.0);
}
} else {
output.fill(0.0);
}
}
}
fn reset(&mut self) {
for buf in &mut self.buffers {
buf.fill(0.0);
}
}
fn node_type(&self) -> &str {
"SubtrackInputs"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
subtracks: self.subtracks.clone(),
outputs: self.outputs.clone(),
// Don't clone audio buffers; fresh node starts silent
buffers: vec![vec![0.0; self.buffer_size * 2]; self.subtracks.len()],
buffer_size: self.buffer_size,
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,199 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
use crate::dsp::svf::SvfFilter;
const PARAM_CUTOFF: u32 = 0;
const PARAM_RESONANCE: u32 = 1;
/// State Variable Filter node — simultaneously outputs lowpass, highpass,
/// bandpass, and notch from one filter, with per-sample CV modulation of
/// cutoff and resonance.
pub struct SVFNode {
name: String,
filter: SvfFilter,
cutoff: f32,
resonance: f32,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl SVFNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
NodePort::new("Cutoff CV", SignalType::CV, 1),
NodePort::new("Resonance CV", SignalType::CV, 2),
];
let outputs = vec![
NodePort::new("Lowpass", SignalType::Audio, 0),
NodePort::new("Highpass", SignalType::Audio, 1),
NodePort::new("Bandpass", SignalType::Audio, 2),
NodePort::new("Notch", SignalType::Audio, 3),
];
let parameters = vec![
Parameter::new(PARAM_CUTOFF, "Cutoff", 20.0, 20000.0, 1000.0, ParameterUnit::Frequency),
Parameter::new(PARAM_RESONANCE, "Resonance", 0.0, 1.0, 0.0, ParameterUnit::Generic),
];
let mut filter = SvfFilter::new();
filter.set_params(1000.0, 0.0, 44100.0);
Self {
name,
filter,
cutoff: 1000.0,
resonance: 0.0,
sample_rate: 44100,
inputs,
outputs,
parameters,
}
}
}
impl AudioNode for SVFNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_CUTOFF => {
self.cutoff = value.clamp(20.0, 20000.0);
self.filter.set_params(self.cutoff, self.resonance, self.sample_rate as f32);
}
PARAM_RESONANCE => {
self.resonance = value.clamp(0.0, 1.0);
self.filter.set_params(self.cutoff, self.resonance, self.sample_rate as f32);
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_CUTOFF => self.cutoff,
PARAM_RESONANCE => self.resonance,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.len() < 4 {
return;
}
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
self.filter.set_params(self.cutoff, self.resonance, sample_rate as f32);
}
let input = inputs[0];
// All 4 outputs are stereo interleaved
let frames = input.len() / 2;
let sr = self.sample_rate as f32;
// Check if CV inputs are connected (sample first frame to detect NaN)
let has_cutoff_cv = !cv_input_or_default(inputs, 1, 0, f32::NAN).is_nan();
let has_resonance_cv = !cv_input_or_default(inputs, 2, 0, f32::NAN).is_nan();
let mut last_cutoff = self.cutoff;
let mut last_resonance = self.resonance;
for frame in 0..frames {
// Update coefficients from CV if connected
if has_cutoff_cv || has_resonance_cv {
let cutoff = if has_cutoff_cv {
let cv = cv_input_or_default(inputs, 1, frame, 0.5);
let octave_shift = (cv.clamp(0.0, 1.0) - 0.5) * 4.0;
(self.cutoff * 2.0_f32.powf(octave_shift)).clamp(20.0, 20000.0)
} else {
self.cutoff
};
let resonance = if has_resonance_cv {
cv_input_or_default(inputs, 2, frame, self.resonance).clamp(0.0, 1.0)
} else {
self.resonance
};
if cutoff != last_cutoff || resonance != last_resonance {
self.filter.set_params(cutoff, resonance, sr);
last_cutoff = cutoff;
last_resonance = resonance;
}
}
// Process both channels, writing all 4 outputs
for ch in 0..2 {
let idx = frame * 2 + ch;
let (lp, hp, bp, notch) = self.filter.process_sample_quad(input[idx], ch);
outputs[0][idx] = lp;
outputs[1][idx] = hp;
outputs[2][idx] = bp;
outputs[3][idx] = notch;
}
}
}
fn reset(&mut self) {
self.filter.reset();
}
fn node_type(&self) -> &str {
"SVF"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
let mut filter = SvfFilter::new();
filter.set_params(self.cutoff, self.resonance, self.sample_rate as f32);
Box::new(Self {
name: self.name.clone(),
filter,
cutoff: self.cutoff,
resonance: self.resonance,
sample_rate: self.sample_rate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,192 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType};
use crate::audio::midi::MidiEvent;
/// Template Input node - represents the MIDI input for one voice in a VoiceAllocator
pub struct TemplateInputNode {
name: String,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl TemplateInputNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![];
let outputs = vec![
NodePort::new("MIDI Out", SignalType::Midi, 0),
];
Self {
name,
inputs,
outputs,
parameters: vec![],
}
}
}
impl AudioNode for TemplateInputNode {
fn category(&self) -> NodeCategory {
NodeCategory::Input
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, _id: u32, _value: f32) {}
fn get_parameter(&self, _id: u32) -> f32 {
0.0
}
fn process(
&mut self,
_inputs: &[&[f32]],
_outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
// TemplateInput receives MIDI from VoiceAllocator and outputs it
// The MIDI was already placed in midi_outputs by the graph before calling process()
// So there's nothing to do here - the MIDI is already in the output buffer
}
fn reset(&mut self) {}
fn node_type(&self) -> &str {
"TemplateInput"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn handle_midi(&mut self, _event: &MidiEvent) {
// Pass through to connected nodes
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
/// Template Output node - represents the audio output from one voice in a VoiceAllocator
pub struct TemplateOutputNode {
name: String,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl TemplateOutputNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
Self {
name,
inputs,
outputs,
parameters: vec![],
}
}
}
impl AudioNode for TemplateOutputNode {
fn category(&self) -> NodeCategory {
NodeCategory::Output
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, _id: u32, _value: f32) {}
fn get_parameter(&self, _id: u32) -> f32 {
0.0
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
// Copy input to output - the graph reads from output buffers
if !inputs.is_empty() && !outputs.is_empty() {
let input = inputs[0];
let output = &mut outputs[0];
let len = input.len().min(output.len());
output[..len].copy_from_slice(&input[..len]);
}
}
fn reset(&mut self) {}
fn node_type(&self) -> &str {
"TemplateOutput"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,270 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use std::f32::consts::PI;
const PARAM_RATE: u32 = 0;
const PARAM_DEPTH: u32 = 1;
const MAX_DELAY_MS: f32 = 7.0;
const BASE_DELAY_MS: f32 = 0.5;
/// Vibrato effect — periodic pitch modulation via a short modulated delay line.
///
/// 100% wet signal (no dry mix). Supports an external Mod CV input that, when
/// connected, replaces the internal sine LFO with the incoming CV signal.
pub struct VibratoNode {
name: String,
rate: f32,
depth: f32,
delay_buffer_left: Vec<f32>,
delay_buffer_right: Vec<f32>,
write_position: usize,
max_delay_samples: usize,
sample_rate: u32,
lfo_phase: f32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl VibratoNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0),
NodePort::new("Mod CV In", SignalType::CV, 1),
NodePort::new("Rate CV In", SignalType::CV, 2),
NodePort::new("Depth CV In", SignalType::CV, 3),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_RATE, "Rate", 0.1, 14.0, 5.0, ParameterUnit::Frequency),
Parameter::new(PARAM_DEPTH, "Depth", 0.0, 1.0, 0.5, ParameterUnit::Generic),
];
let max_delay_samples = ((MAX_DELAY_MS / 1000.0) * 48000.0) as usize;
Self {
name,
rate: 5.0,
depth: 0.5,
delay_buffer_left: vec![0.0; max_delay_samples],
delay_buffer_right: vec![0.0; max_delay_samples],
write_position: 0,
max_delay_samples,
sample_rate: 48000,
lfo_phase: 0.0,
inputs,
outputs,
parameters,
}
}
fn read_interpolated_sample(&self, buffer: &[f32], delay_samples: f32) -> f32 {
let delay_samples = delay_samples.clamp(0.0, (self.max_delay_samples - 1) as f32);
let read_pos_float = self.write_position as f32 - delay_samples;
let read_pos_float = if read_pos_float < 0.0 {
read_pos_float + self.max_delay_samples as f32
} else {
read_pos_float
};
let read_pos_int = read_pos_float.floor() as usize;
let frac = read_pos_float - read_pos_int as f32;
let sample1 = buffer[read_pos_int % self.max_delay_samples];
let sample2 = buffer[(read_pos_int + 1) % self.max_delay_samples];
sample1 * (1.0 - frac) + sample2 * frac
}
}
impl AudioNode for VibratoNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_RATE => {
self.rate = value.clamp(0.1, 14.0);
}
PARAM_DEPTH => {
self.depth = value.clamp(0.0, 1.0);
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_RATE => self.rate,
PARAM_DEPTH => self.depth,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.is_empty() || outputs.is_empty() {
return;
}
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
self.max_delay_samples = ((MAX_DELAY_MS / 1000.0) * sample_rate as f32) as usize;
self.delay_buffer_left.resize(self.max_delay_samples, 0.0);
self.delay_buffer_right.resize(self.max_delay_samples, 0.0);
self.write_position = 0;
}
let input = inputs[0];
let output = &mut outputs[0];
// CV inputs — unconnected ports are filled with NaN
let mod_cv = inputs.get(1);
let rate_cv = inputs.get(2);
let depth_cv = inputs.get(3);
let frames = input.len() / 2;
let output_frames = output.len() / 2;
let frames_to_process = frames.min(output_frames);
let base_delay_samples = (BASE_DELAY_MS / 1000.0) * self.sample_rate as f32;
let max_modulation_samples = (MAX_DELAY_MS - BASE_DELAY_MS) / 1000.0 * self.sample_rate as f32;
for frame in 0..frames_to_process {
let left_in = input[frame * 2];
let right_in = input[frame * 2 + 1];
// Resolve depth: CV overrides knob when connected
let depth = if let Some(cv) = depth_cv {
let cv_val = cv.get(frame).copied().unwrap_or(f32::NAN);
if cv_val.is_nan() {
self.depth
} else {
cv_val.clamp(0.0, 1.0)
}
} else {
self.depth
};
// Determine modulation value (0..1 range, pre-depth)
let mod_value = if let Some(cv) = mod_cv {
let cv_val = cv.get(frame).copied().unwrap_or(f32::NAN);
if cv_val.is_nan() {
// No external mod — use internal LFO
None
} else {
Some(cv_val.clamp(0.0, 1.0))
}
} else {
None
};
let modulation = if let Some(ext) = mod_value {
// External modulation: CV value scaled by depth
ext * depth
} else {
// Internal LFO: resolve rate with CV
let rate = if let Some(cv) = rate_cv {
let cv_val = cv.get(frame).copied().unwrap_or(f32::NAN);
if cv_val.is_nan() {
self.rate
} else {
(self.rate + cv_val * 14.0).clamp(0.1, 14.0)
}
} else {
self.rate
};
let lfo_value = (self.lfo_phase * 2.0 * PI).sin() * 0.5 + 0.5;
self.lfo_phase += rate / self.sample_rate as f32;
if self.lfo_phase >= 1.0 {
self.lfo_phase -= 1.0;
}
lfo_value * depth
};
let delay_samples = base_delay_samples + modulation * max_modulation_samples;
// 100% wet — output is only the delayed signal
output[frame * 2] = self.read_interpolated_sample(&self.delay_buffer_left, delay_samples);
output[frame * 2 + 1] = self.read_interpolated_sample(&self.delay_buffer_right, delay_samples);
self.delay_buffer_left[self.write_position] = left_in;
self.delay_buffer_right[self.write_position] = right_in;
self.write_position = (self.write_position + 1) % self.max_delay_samples;
}
}
fn reset(&mut self) {
self.delay_buffer_left.fill(0.0);
self.delay_buffer_right.fill(0.0);
self.write_position = 0;
self.lfo_phase = 0.0;
}
fn node_type(&self) -> &str {
"Vibrato"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self {
name: self.name.clone(),
rate: self.rate,
depth: self.depth,
delay_buffer_left: vec![0.0; self.max_delay_samples],
delay_buffer_right: vec![0.0; self.max_delay_samples],
write_position: 0,
max_delay_samples: self.max_delay_samples,
sample_rate: self.sample_rate,
lfo_phase: 0.0,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,370 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use std::f32::consts::PI;
const PARAM_BANDS: u32 = 0;
const PARAM_ATTACK: u32 = 1;
const PARAM_RELEASE: u32 = 2;
const PARAM_MIX: u32 = 3;
const MAX_BANDS: usize = 32;
/// Simple bandpass filter using biquad
struct BandpassFilter {
// Biquad coefficients
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
// State variables (separate for modulator and carrier, L/R channels)
mod_z1_left: f32,
mod_z2_left: f32,
mod_z1_right: f32,
mod_z2_right: f32,
car_z1_left: f32,
car_z2_left: f32,
car_z1_right: f32,
car_z2_right: f32,
}
impl BandpassFilter {
fn new() -> Self {
Self {
b0: 0.0,
b1: 0.0,
b2: 0.0,
a1: 0.0,
a2: 0.0,
mod_z1_left: 0.0,
mod_z2_left: 0.0,
mod_z1_right: 0.0,
mod_z2_right: 0.0,
car_z1_left: 0.0,
car_z2_left: 0.0,
car_z1_right: 0.0,
car_z2_right: 0.0,
}
}
fn set_bandpass(&mut self, frequency: f32, q: f32, sample_rate: f32) {
let omega = 2.0 * PI * frequency / sample_rate;
let sin_omega = omega.sin();
let cos_omega = omega.cos();
let alpha = sin_omega / (2.0 * q);
let a0 = 1.0 + alpha;
self.b0 = alpha / a0;
self.b1 = 0.0;
self.b2 = -alpha / a0;
self.a1 = -2.0 * cos_omega / a0;
self.a2 = (1.0 - alpha) / a0;
}
fn process_modulator(&mut self, input: f32, is_left: bool) -> f32 {
let (z1, z2) = if is_left {
(&mut self.mod_z1_left, &mut self.mod_z2_left)
} else {
(&mut self.mod_z1_right, &mut self.mod_z2_right)
};
let output = self.b0 * input + self.b1 * *z1 + self.b2 * *z2 - self.a1 * *z1 - self.a2 * *z2;
*z2 = *z1;
*z1 = output;
output
}
fn process_carrier(&mut self, input: f32, is_left: bool) -> f32 {
let (z1, z2) = if is_left {
(&mut self.car_z1_left, &mut self.car_z2_left)
} else {
(&mut self.car_z1_right, &mut self.car_z2_right)
};
let output = self.b0 * input + self.b1 * *z1 + self.b2 * *z2 - self.a1 * *z1 - self.a2 * *z2;
*z2 = *z1;
*z1 = output;
output
}
fn reset(&mut self) {
self.mod_z1_left = 0.0;
self.mod_z2_left = 0.0;
self.mod_z1_right = 0.0;
self.mod_z2_right = 0.0;
self.car_z1_left = 0.0;
self.car_z2_left = 0.0;
self.car_z1_right = 0.0;
self.car_z2_right = 0.0;
}
}
/// Vocoder band with filter and envelope follower
struct VocoderBand {
filter: BandpassFilter,
envelope_left: f32,
envelope_right: f32,
}
impl VocoderBand {
fn new() -> Self {
Self {
filter: BandpassFilter::new(),
envelope_left: 0.0,
envelope_right: 0.0,
}
}
fn reset(&mut self) {
self.filter.reset();
self.envelope_left = 0.0;
self.envelope_right = 0.0;
}
}
/// Vocoder effect - imposes spectral envelope of modulator onto carrier
pub struct VocoderNode {
name: String,
num_bands: usize, // 8 to 32 bands
attack_time: f32, // 0.001 to 0.1 seconds
release_time: f32, // 0.001 to 1.0 seconds
mix: f32, // 0.0 to 1.0
bands: Vec<VocoderBand>,
sample_rate: u32,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl VocoderNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("Modulator", SignalType::Audio, 0),
NodePort::new("Carrier", SignalType::Audio, 1),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_BANDS, "Bands", 8.0, 32.0, 16.0, ParameterUnit::Generic),
Parameter::new(PARAM_ATTACK, "Attack", 0.001, 0.1, 0.01, ParameterUnit::Time),
Parameter::new(PARAM_RELEASE, "Release", 0.001, 1.0, 0.05, ParameterUnit::Time),
Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic),
];
let mut bands = Vec::with_capacity(MAX_BANDS);
for _ in 0..MAX_BANDS {
bands.push(VocoderBand::new());
}
Self {
name,
num_bands: 16,
attack_time: 0.01,
release_time: 0.05,
mix: 1.0,
bands,
sample_rate: 48000,
inputs,
outputs,
parameters,
}
}
fn setup_bands(&mut self) {
// Distribute bands logarithmically from 200 Hz to 5000 Hz
let min_freq: f32 = 200.0;
let max_freq: f32 = 5000.0;
let q: f32 = 4.0; // Fairly narrow bands
for i in 0..self.num_bands {
let t = i as f32 / (self.num_bands - 1) as f32;
let freq = min_freq * (max_freq / min_freq).powf(t);
self.bands[i].filter.set_bandpass(freq, q, self.sample_rate as f32);
}
}
}
impl AudioNode for VocoderNode {
fn category(&self) -> NodeCategory {
NodeCategory::Effect
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_BANDS => {
let bands = (value.round() as usize).clamp(8, 32);
if bands != self.num_bands {
self.num_bands = bands;
self.setup_bands();
}
}
PARAM_ATTACK => self.attack_time = value.clamp(0.001, 0.1),
PARAM_RELEASE => self.release_time = value.clamp(0.001, 1.0),
PARAM_MIX => self.mix = value.clamp(0.0, 1.0),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_BANDS => self.num_bands as f32,
PARAM_ATTACK => self.attack_time,
PARAM_RELEASE => self.release_time,
PARAM_MIX => self.mix,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if inputs.len() < 2 || outputs.is_empty() {
return;
}
// Update sample rate if changed
if self.sample_rate != sample_rate {
self.sample_rate = sample_rate;
self.setup_bands();
}
let modulator = inputs[0];
let carrier = inputs[1];
let output = &mut outputs[0];
// Audio signals are stereo (interleaved L/R)
let mod_frames = modulator.len() / 2;
let car_frames = carrier.len() / 2;
let out_frames = output.len() / 2;
let frames_to_process = mod_frames.min(car_frames).min(out_frames);
// Calculate envelope follower coefficients
let sample_duration = 1.0 / self.sample_rate as f32;
let attack_coeff = (sample_duration / self.attack_time).min(1.0);
let release_coeff = (sample_duration / self.release_time).min(1.0);
for frame in 0..frames_to_process {
let mod_left = modulator[frame * 2];
let mod_right = modulator[frame * 2 + 1];
let car_left = carrier[frame * 2];
let car_right = carrier[frame * 2 + 1];
let mut out_left = 0.0;
let mut out_right = 0.0;
// Process each band
for i in 0..self.num_bands {
let band = &mut self.bands[i];
// Filter modulator and carrier through bandpass
let mod_band_left = band.filter.process_modulator(mod_left, true);
let mod_band_right = band.filter.process_modulator(mod_right, false);
let car_band_left = band.filter.process_carrier(car_left, true);
let car_band_right = band.filter.process_carrier(car_right, false);
// Extract envelope from modulator band (rectify + smooth)
let mod_level_left = mod_band_left.abs();
let mod_level_right = mod_band_right.abs();
// Envelope follower
let coeff_left = if mod_level_left > band.envelope_left {
attack_coeff
} else {
release_coeff
};
let coeff_right = if mod_level_right > band.envelope_right {
attack_coeff
} else {
release_coeff
};
band.envelope_left += (mod_level_left - band.envelope_left) * coeff_left;
band.envelope_right += (mod_level_right - band.envelope_right) * coeff_right;
// Apply envelope to carrier band
out_left += car_band_left * band.envelope_left;
out_right += car_band_right * band.envelope_right;
}
// Normalize output (roughly compensate for band summing)
let norm_factor = 1.0 / (self.num_bands as f32).sqrt();
out_left *= norm_factor;
out_right *= norm_factor;
// Mix with carrier (dry signal)
output[frame * 2] = car_left * (1.0 - self.mix) + out_left * self.mix;
output[frame * 2 + 1] = car_right * (1.0 - self.mix) + out_right * self.mix;
}
}
fn reset(&mut self) {
for band in &mut self.bands {
band.reset();
}
}
fn node_type(&self) -> &str {
"Vocoder"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
let mut bands = Vec::with_capacity(MAX_BANDS);
for _ in 0..MAX_BANDS {
bands.push(VocoderBand::new());
}
let mut node = Self {
name: self.name.clone(),
num_bands: self.num_bands,
attack_time: self.attack_time,
release_time: self.release_time,
mix: self.mix,
bands,
sample_rate: self.sample_rate,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
};
node.setup_bands();
Box::new(node)
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,414 @@
use crate::audio::midi::MidiEvent;
use crate::audio::node_graph::{AudioNode, AudioGraph, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
const PARAM_VOICE_COUNT: u32 = 0;
const MAX_VOICES: usize = 16; // Maximum allowed voices
const DEFAULT_VOICES: usize = 8;
/// Voice state for voice allocation
#[derive(Clone)]
struct VoiceState {
active: bool,
releasing: bool, // Note-off received, still processing (e.g. ADSR release)
note: u8,
note_channel: u8, // MIDI channel this voice was allocated on (0 = global/unset)
age: u32, // For voice stealing
pending_events: Vec<MidiEvent>, // MIDI events to send to this voice
}
impl VoiceState {
fn new() -> Self {
Self {
active: false,
releasing: false,
note: 0,
note_channel: 0,
age: 0,
pending_events: Vec::new(),
}
}
}
/// VoiceAllocatorNode - A group node that creates N polyphonic instances of its internal graph
///
/// This node acts as a container for a "voice template" graph. At runtime, it creates
/// N instances of that graph (one per voice) and routes MIDI note events to them.
/// All voice outputs are mixed together into a single output.
pub struct VoiceAllocatorNode {
name: String,
/// The template graph (edited by user via UI)
template_graph: AudioGraph,
/// Runtime voice instances (clones of template)
voice_instances: Vec<AudioGraph>,
/// Voice allocation state
voices: [VoiceState; MAX_VOICES],
/// Number of active voices (configurable parameter)
voice_count: usize,
/// Mix buffer for combining voice outputs
mix_buffer: Vec<f32>,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl VoiceAllocatorNode {
pub fn new(name: impl Into<String>, sample_rate: u32, buffer_size: usize) -> Self {
let name = name.into();
// MIDI input for receiving note events
let inputs = vec![
NodePort::new("MIDI In", SignalType::Midi, 0),
];
// Single mixed audio output
let outputs = vec![
NodePort::new("Mixed Out", SignalType::Audio, 0),
];
// Voice count parameter
let parameters = vec![
Parameter::new(PARAM_VOICE_COUNT, "Voices", 1.0, MAX_VOICES as f32, DEFAULT_VOICES as f32, ParameterUnit::Generic),
];
// Create template graph with default TemplateInput and TemplateOutput nodes
let mut template_graph = AudioGraph::new(sample_rate, buffer_size);
{
use super::template_io::{TemplateInputNode, TemplateOutputNode};
let input_node = Box::new(TemplateInputNode::new("Template Input"));
let output_node = Box::new(TemplateOutputNode::new("Template Output"));
let input_idx = template_graph.add_node(input_node);
let output_idx = template_graph.add_node(output_node);
template_graph.set_node_position(input_idx, -200.0, 0.0);
template_graph.set_node_position(output_idx, 200.0, 0.0);
template_graph.set_midi_target(input_idx, true);
template_graph.set_output_node(Some(output_idx));
}
// Create voice instances (initially empty clones of template)
let voice_instances: Vec<AudioGraph> = (0..MAX_VOICES)
.map(|_| AudioGraph::new(sample_rate, buffer_size))
.collect();
Self {
name,
template_graph,
voice_instances,
voices: std::array::from_fn(|_| VoiceState::new()),
voice_count: DEFAULT_VOICES,
mix_buffer: vec![0.0; buffer_size * 2], // Stereo
inputs,
outputs,
parameters,
}
}
/// Get mutable reference to template graph (for UI editing)
pub fn template_graph_mut(&mut self) -> &mut AudioGraph {
&mut self.template_graph
}
/// Get reference to template graph (for serialization)
pub fn template_graph(&self) -> &AudioGraph {
&self.template_graph
}
/// Rebuild voice instances from template (called after template is edited)
pub fn rebuild_voices(&mut self) {
// Clone template to all voice instances
for voice in &mut self.voice_instances {
*voice = self.template_graph.clone_graph();
// Find TemplateInput and TemplateOutput nodes
let mut template_input_idx = None;
let mut template_output_idx = None;
for node_idx in voice.node_indices() {
if let Some(node) = voice.get_node(node_idx) {
match node.node_type() {
"TemplateInput" => template_input_idx = Some(node_idx),
"TemplateOutput" => template_output_idx = Some(node_idx),
_ => {}
}
}
}
// Mark ONLY TemplateInput as a MIDI target
// MIDI will flow through graph connections to other nodes (like MidiToCV)
if let Some(input_idx) = template_input_idx {
voice.set_midi_target(input_idx, true);
}
// Set TemplateOutput as output node
voice.set_output_node(template_output_idx);
}
}
/// Find a free voice, or steal one
/// Priority: inactive → oldest releasing → oldest held
fn find_voice_for_note_on(&mut self) -> usize {
// First, look for an inactive voice
for (i, voice) in self.voices[..self.voice_count].iter().enumerate() {
if !voice.active {
return i;
}
}
// No inactive voices — steal the oldest releasing voice
if let Some((i, _)) = self.voices[..self.voice_count]
.iter()
.enumerate()
.filter(|(_, v)| v.releasing)
.max_by_key(|(_, v)| v.age)
{
return i;
}
// No releasing voices either — steal the oldest held voice
self.voices[..self.voice_count]
.iter()
.enumerate()
.max_by_key(|(_, v)| v.age)
.map(|(i, _)| i)
.unwrap_or(0)
}
/// Get oscilloscope data from the most relevant voice's subgraph.
/// Priority: first active voice → first releasing voice → first voice.
pub fn get_voice_oscilloscope_data(&self, node_id: u32, sample_count: usize) -> Option<(Vec<f32>, Vec<f32>)> {
let voice_idx = self.best_voice_index();
let graph = &self.voice_instances[voice_idx];
let node_idx = petgraph::stable_graph::NodeIndex::new(node_id as usize);
let audio = graph.get_oscilloscope_data(node_idx, sample_count)?;
let cv = graph.get_oscilloscope_cv_data(node_idx, sample_count).unwrap_or_default();
Some((audio, cv))
}
/// Find the best voice index to observe: first active → first releasing → 0
fn best_voice_index(&self) -> usize {
// First active (non-releasing) voice
for (i, v) in self.voices[..self.voice_count].iter().enumerate() {
if v.active && !v.releasing {
return i;
}
}
// First releasing voice
for (i, v) in self.voices[..self.voice_count].iter().enumerate() {
if v.active && v.releasing {
return i;
}
}
// Fallback to first voice
0
}
/// Find all voices playing a specific note (held, not yet releasing)
fn find_voices_for_note_off(&self, note: u8) -> Vec<usize> {
self.voices[..self.voice_count]
.iter()
.enumerate()
.filter_map(|(i, v)| {
if v.active && !v.releasing && v.note == note {
Some(i)
} else {
None
}
})
.collect()
}
}
impl AudioNode for VoiceAllocatorNode {
fn category(&self) -> NodeCategory {
NodeCategory::Utility
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_VOICE_COUNT => {
let new_count = (value.round() as usize).clamp(1, MAX_VOICES);
if new_count != self.voice_count {
self.voice_count = new_count;
// Stop voices beyond the new count
for voice in &mut self.voices[new_count..] {
voice.active = false;
voice.releasing = false;
}
}
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_VOICE_COUNT => self.voice_count as f32,
_ => 0.0,
}
}
fn handle_midi(&mut self, event: &MidiEvent) {
let status = event.status & 0xF0;
match status {
0x90 => {
// Note on
if event.data2 > 0 {
let voice_idx = self.find_voice_for_note_on();
self.voices[voice_idx].active = true;
self.voices[voice_idx].releasing = false;
self.voices[voice_idx].note = event.data1;
self.voices[voice_idx].note_channel = event.status & 0x0F;
self.voices[voice_idx].age = 0;
// Store MIDI event for this voice to process
self.voices[voice_idx].pending_events.push(*event);
} else {
// Velocity = 0 means note off — mark releasing, keep active for ADSR release
let voice_indices = self.find_voices_for_note_off(event.data1);
for voice_idx in voice_indices {
self.voices[voice_idx].releasing = true;
self.voices[voice_idx].pending_events.push(*event);
}
}
}
0x80 => {
// Note off — mark releasing, keep active for ADSR release
let voice_indices = self.find_voices_for_note_off(event.data1);
for voice_idx in voice_indices {
self.voices[voice_idx].releasing = true;
self.voices[voice_idx].pending_events.push(*event);
}
}
_ => {
// Route to matching-channel voices; channel 0 = global broadcast
let event_channel = event.status & 0x0F;
for voice_idx in 0..self.voice_count {
let voice = &mut self.voices[voice_idx];
if voice.active && (event_channel == 0 || voice.note_channel == event_channel) {
voice.pending_events.push(*event);
}
}
}
}
}
fn process(
&mut self,
_inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32,
) {
// Process MIDI events from input (allocate notes to voices)
if !midi_inputs.is_empty() {
for event in midi_inputs[0] {
self.handle_midi(event);
}
}
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let output_len = output.len();
// Process each active voice and mix (only up to voice_count)
for voice_idx in 0..self.voice_count {
let voice_state = &mut self.voices[voice_idx];
if voice_state.active {
voice_state.age = voice_state.age.saturating_add(1);
// Get pending MIDI events for this voice
let midi_events = std::mem::take(&mut voice_state.pending_events);
// IMPORTANT: Process only the slice of mix_buffer that matches output size
// This prevents phase discontinuities in oscillators
let mix_slice = &mut self.mix_buffer[..output_len];
mix_slice.fill(0.0);
// Process this voice's graph with its MIDI events
// Note: playback_time is 0.0 since voice allocator doesn't track time
self.voice_instances[voice_idx].process(mix_slice, &midi_events, crate::time::Beats::ZERO);
// Auto-deactivate releasing voices that have gone silent
if voice_state.releasing {
let peak = mix_slice.iter().fold(0.0f32, |max, &s| max.max(s.abs()));
if peak < 1e-6 {
voice_state.active = false;
voice_state.releasing = false;
continue; // Don't mix silent output
}
}
// Mix into output (accumulate)
for (i, sample) in mix_slice.iter().enumerate() {
output[i] += sample;
}
}
}
}
fn reset(&mut self) {
for voice in &mut self.voices {
voice.active = false;
voice.releasing = false;
voice.pending_events.clear();
}
for graph in &mut self.voice_instances {
graph.reset();
}
self.template_graph.reset();
}
fn node_type(&self) -> &str {
"VoiceAllocator"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
// Clone creates a new VoiceAllocator with the same template graph
// Voice instances will be rebuilt when rebuild_voices() is called
Box::new(Self {
name: self.name.clone(),
template_graph: self.template_graph.clone_graph(),
voice_instances: self.voice_instances.iter().map(|g| g.clone_graph()).collect(),
voices: std::array::from_fn(|_| VoiceState::new()), // Reset voices
voice_count: self.voice_count,
mix_buffer: vec![0.0; self.mix_buffer.len()],
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,291 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
use std::f32::consts::PI;
const WAVETABLE_SIZE: usize = 256;
// Parameters
const PARAM_WAVETABLE: u32 = 0;
const PARAM_FINE_TUNE: u32 = 1;
const PARAM_POSITION: u32 = 2;
/// Types of preset wavetables
#[derive(Debug, Clone, Copy, PartialEq)]
enum WavetableType {
Sine = 0,
Saw = 1,
Square = 2,
Triangle = 3,
PWM = 4, // Pulse Width Modulated
Harmonic = 5, // Rich harmonics
Inharmonic = 6, // Metallic/bell-like
Digital = 7, // Stepped/digital artifacts
}
impl WavetableType {
fn from_u32(value: u32) -> Self {
match value {
0 => WavetableType::Sine,
1 => WavetableType::Saw,
2 => WavetableType::Square,
3 => WavetableType::Triangle,
4 => WavetableType::PWM,
5 => WavetableType::Harmonic,
6 => WavetableType::Inharmonic,
7 => WavetableType::Digital,
_ => WavetableType::Sine,
}
}
}
/// Generate a wavetable of the specified type
fn generate_wavetable(wave_type: WavetableType) -> Vec<f32> {
let mut table = vec![0.0; WAVETABLE_SIZE];
match wave_type {
WavetableType::Sine => {
for i in 0..WAVETABLE_SIZE {
let phase = (i as f32 / WAVETABLE_SIZE as f32) * 2.0 * PI;
table[i] = phase.sin();
}
}
WavetableType::Saw => {
for i in 0..WAVETABLE_SIZE {
let t = i as f32 / WAVETABLE_SIZE as f32;
table[i] = 2.0 * t - 1.0;
}
}
WavetableType::Square => {
for i in 0..WAVETABLE_SIZE {
table[i] = if i < WAVETABLE_SIZE / 2 { 1.0 } else { -1.0 };
}
}
WavetableType::Triangle => {
for i in 0..WAVETABLE_SIZE {
let t = i as f32 / WAVETABLE_SIZE as f32;
table[i] = if t < 0.5 {
4.0 * t - 1.0
} else {
-4.0 * t + 3.0
};
}
}
WavetableType::PWM => {
// Variable pulse width
for i in 0..WAVETABLE_SIZE {
let duty = 0.25; // 25% duty cycle
table[i] = if (i as f32 / WAVETABLE_SIZE as f32) < duty { 1.0 } else { -1.0 };
}
}
WavetableType::Harmonic => {
// Multiple harmonics for rich sound
for i in 0..WAVETABLE_SIZE {
let phase = (i as f32 / WAVETABLE_SIZE as f32) * 2.0 * PI;
table[i] = phase.sin() * 0.5
+ (phase * 2.0).sin() * 0.25
+ (phase * 3.0).sin() * 0.125
+ (phase * 4.0).sin() * 0.0625;
}
}
WavetableType::Inharmonic => {
// Non-integer harmonics for metallic/bell-like sounds
for i in 0..WAVETABLE_SIZE {
let phase = (i as f32 / WAVETABLE_SIZE as f32) * 2.0 * PI;
table[i] = phase.sin() * 0.4
+ (phase * 2.13).sin() * 0.3
+ (phase * 3.76).sin() * 0.2
+ (phase * 5.41).sin() * 0.1;
}
}
WavetableType::Digital => {
// Stepped waveform with digital artifacts
for i in 0..WAVETABLE_SIZE {
let steps = 8;
let step = (i * steps / WAVETABLE_SIZE) as f32 / steps as f32;
table[i] = step * 2.0 - 1.0;
}
}
}
table
}
/// Wavetable oscillator node
pub struct WavetableOscillatorNode {
name: String,
// Current wavetable
wavetable_type: WavetableType,
wavetable: Vec<f32>,
// Oscillator state
phase: f32,
fine_tune: f32, // -1.0 to 1.0 semitones
position: f32, // 0.0 to 1.0 (for future multi-cycle wavetables)
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl WavetableOscillatorNode {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let inputs = vec![
NodePort::new("V/Oct", SignalType::CV, 0),
];
let outputs = vec![
NodePort::new("Audio Out", SignalType::Audio, 0),
];
let parameters = vec![
Parameter::new(PARAM_WAVETABLE, "Wavetable", 0.0, 7.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_FINE_TUNE, "Fine Tune", -1.0, 1.0, 0.0, ParameterUnit::Generic),
Parameter::new(PARAM_POSITION, "Position", 0.0, 1.0, 0.0, ParameterUnit::Generic),
];
let wavetable_type = WavetableType::Sine;
let wavetable = generate_wavetable(wavetable_type);
Self {
name,
wavetable_type,
wavetable,
phase: 0.0,
fine_tune: 0.0,
position: 0.0,
inputs,
outputs,
parameters,
}
}
/// Convert V/oct CV to frequency with fine tune
fn voct_to_freq(&self, voct: f32) -> f32 {
let semitones = voct * 12.0 + self.fine_tune;
440.0 * 2.0_f32.powf(semitones / 12.0)
}
/// Read from wavetable with linear interpolation
fn read_wavetable(&self, phase: f32) -> f32 {
let index = phase * WAVETABLE_SIZE as f32;
let index_floor = index.floor() as usize % WAVETABLE_SIZE;
let index_ceil = (index_floor + 1) % WAVETABLE_SIZE;
let frac = index - index.floor();
// Linear interpolation
let sample1 = self.wavetable[index_floor];
let sample2 = self.wavetable[index_ceil];
sample1 + (sample2 - sample1) * frac
}
}
impl AudioNode for WavetableOscillatorNode {
fn category(&self) -> NodeCategory {
NodeCategory::Generator
}
fn inputs(&self) -> &[NodePort] {
&self.inputs
}
fn outputs(&self) -> &[NodePort] {
&self.outputs
}
fn parameters(&self) -> &[Parameter] {
&self.parameters
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
PARAM_WAVETABLE => {
let new_type = WavetableType::from_u32(value as u32);
if new_type != self.wavetable_type {
self.wavetable_type = new_type;
self.wavetable = generate_wavetable(new_type);
}
}
PARAM_FINE_TUNE => {
self.fine_tune = value.clamp(-1.0, 1.0);
}
PARAM_POSITION => {
self.position = value.clamp(0.0, 1.0);
}
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
PARAM_WAVETABLE => self.wavetable_type as u32 as f32,
PARAM_FINE_TUNE => self.fine_tune,
PARAM_POSITION => self.position,
_ => 0.0,
}
}
fn process(
&mut self,
inputs: &[&[f32]],
outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>],
sample_rate: u32,
) {
if outputs.is_empty() {
return;
}
let output = &mut outputs[0];
let frames = output.len() / 2;
for frame in 0..frames {
// V/Oct input: when unconnected, defaults to 0.0 (A4 440 Hz)
// CV signals are mono, so read from frame index directly
let voct = cv_input_or_default(inputs, 0, frame, 0.0);
// Calculate frequency from V/Oct
let freq = self.voct_to_freq(voct);
// Read from wavetable
let sample = self.read_wavetable(self.phase);
// Advance phase
self.phase += freq / sample_rate as f32;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
// Output stereo (same signal to both channels)
output[frame * 2] = sample * 0.5; // Scale down to prevent clipping
output[frame * 2 + 1] = sample * 0.5;
}
}
fn reset(&mut self) {
self.phase = 0.0;
}
fn node_type(&self) -> &str {
"WavetableOscillator"
}
fn name(&self) -> &str {
&self.name
}
fn clone_node(&self) -> Box<dyn AudioNode> {
Box::new(Self::new(self.name.clone()))
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -0,0 +1,274 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::nodes::LoopMode;
/// Sample data for preset serialization
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SampleData {
#[serde(rename = "simple_sampler")]
SimpleSampler {
#[serde(skip_serializing_if = "Option::is_none")]
file_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
embedded_data: Option<EmbeddedSampleData>,
},
#[serde(rename = "multi_sampler")]
MultiSampler { layers: Vec<LayerData> },
}
/// Embedded sample data (base64-encoded for JSON compatibility)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddedSampleData {
/// Base64-encoded audio samples (f32 little-endian)
pub data_base64: String,
/// Original sample rate
pub sample_rate: u32,
}
/// Layer data for MultiSampler
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayerData {
#[serde(skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embedded_data: Option<EmbeddedSampleData>,
pub key_min: u8,
pub key_max: u8,
pub root_key: u8,
pub velocity_min: u8,
pub velocity_max: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub loop_start: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub loop_end: Option<usize>,
#[serde(default = "default_loop_mode")]
pub loop_mode: LoopMode,
}
fn default_loop_mode() -> LoopMode {
LoopMode::OneShot
}
/// Serializable representation of a node graph preset
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphPreset {
/// Preset metadata
pub metadata: PresetMetadata,
/// Nodes in the graph
pub nodes: Vec<SerializedNode>,
/// Connections between nodes
pub connections: Vec<SerializedConnection>,
/// Which node indices are MIDI targets
pub midi_targets: Vec<u32>,
/// Which node index is the audio output (None if not set)
pub output_node: Option<u32>,
/// Frontend-only group definitions (backend stores opaquely, does not interpret)
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<SerializedGroup>,
}
/// Metadata about the preset
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresetMetadata {
/// Preset name
pub name: String,
/// Description of what the preset sounds like
#[serde(default)]
pub description: String,
/// Preset author
#[serde(default)]
pub author: String,
/// Preset version (for compatibility)
#[serde(default = "default_version")]
pub version: u32,
/// Tags for categorization (e.g., "bass", "lead", "pad")
#[serde(default)]
pub tags: Vec<String>,
}
fn default_version() -> u32 {
1
}
/// Serialized keyframe for AutomationInput nodes
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedKeyframe {
pub time: f64,
pub value: f32,
pub interpolation: String,
pub ease_out: (f32, f32),
pub ease_in: (f32, f32),
}
/// Serialized node representation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedNode {
/// Unique ID (node index in the graph)
pub id: u32,
/// Node type (e.g., "Oscillator", "Filter", "ADSR")
pub node_type: String,
/// Parameter values (param_id -> value)
pub parameters: HashMap<u32, f32>,
/// UI position (for visual editor)
#[serde(default)]
pub position: (f32, f32),
/// For VoiceAllocator nodes: the nested template graph
#[serde(skip_serializing_if = "Option::is_none")]
pub template_graph: Option<Box<GraphPreset>>,
/// For sampler nodes: loaded sample data
#[serde(skip_serializing_if = "Option::is_none")]
pub sample_data: Option<SampleData>,
/// For Script nodes: BeamDSP source code
#[serde(skip_serializing_if = "Option::is_none")]
pub script_source: Option<String>,
/// For AmpSim nodes: path to the .nam model file
#[serde(skip_serializing_if = "Option::is_none")]
pub nam_model_path: Option<String>,
/// For dynamic-port nodes (Mixer, SubtrackInputs): saved port count so ports
/// round-trip correctly through save/load independent of connection order.
#[serde(skip_serializing_if = "Option::is_none")]
pub num_ports: Option<u32>,
/// For SubtrackInputs: ordered port names (one per subtrack slot).
/// Allows the UI to display actual track names on the node's output ports.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub port_names: Vec<String>,
/// For AutomationInput nodes: user-visible display name
#[serde(skip_serializing_if = "Option::is_none")]
pub automation_display_name: Option<String>,
/// For AutomationInput nodes: saved keyframes
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub automation_keyframes: Vec<SerializedKeyframe>,
}
/// Serialized group definition (frontend-only visual grouping, stored opaquely by backend)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedGroup {
pub id: u32,
pub name: String,
pub member_nodes: Vec<u32>,
pub position: (f32, f32),
pub boundary_inputs: Vec<SerializedBoundaryConnection>,
pub boundary_outputs: Vec<SerializedBoundaryConnection>,
/// Parent group ID for nested groups (None = top-level group)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_group_id: Option<u32>,
}
/// Serialized boundary connection for group definitions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedBoundaryConnection {
pub external_node: u32,
pub external_port: usize,
pub internal_node: u32,
pub internal_port: usize,
pub port_name: String,
/// Signal type as string ("Audio", "Midi", "CV")
pub data_type: String,
}
/// Serialized connection between nodes
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedConnection {
/// Source node ID
pub from_node: u32,
/// Source port index
pub from_port: usize,
/// Destination node ID
pub to_node: u32,
/// Destination port index
pub to_port: usize,
}
impl GraphPreset {
/// Create a new preset with the given name
pub fn new(name: impl Into<String>) -> Self {
Self {
metadata: PresetMetadata {
name: name.into(),
description: String::new(),
author: String::new(),
version: 1,
tags: Vec::new(),
},
nodes: Vec::new(),
connections: Vec::new(),
midi_targets: Vec::new(),
output_node: None,
groups: Vec::new(),
}
}
/// Serialize to JSON string
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
/// Deserialize from JSON string
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
/// Add a node to the preset
pub fn add_node(&mut self, node: SerializedNode) {
self.nodes.push(node);
}
/// Add a connection to the preset
pub fn add_connection(&mut self, connection: SerializedConnection) {
self.connections.push(connection);
}
}
impl SerializedNode {
/// Create a new serialized node
pub fn new(id: u32, node_type: impl Into<String>) -> Self {
Self {
id,
node_type: node_type.into(),
parameters: HashMap::new(),
position: (0.0, 0.0),
template_graph: None,
sample_data: None,
script_source: None,
nam_model_path: None,
num_ports: None,
port_names: Vec::new(),
automation_display_name: None,
automation_keyframes: Vec::new(),
}
}
/// Set a parameter value
pub fn set_parameter(&mut self, param_id: u32, value: f32) {
self.parameters.insert(param_id, value);
}
/// Set UI position
pub fn set_position(&mut self, x: f32, y: f32) {
self.position = (x, y);
}
}

View File

@ -0,0 +1,96 @@
use serde::{Deserialize, Serialize};
/// Three distinct signal types for graph edges
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SignalType {
/// Audio-rate signals (-1.0 to 1.0 typically) - Blue in UI
Audio,
/// MIDI events (discrete messages) - Green in UI
Midi,
/// Control Voltage (modulation signals, typically 0.0 to 1.0) - Orange in UI
CV,
}
/// Port definition for node inputs/outputs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodePort {
pub name: String,
pub signal_type: SignalType,
pub index: usize,
}
impl NodePort {
pub fn new(name: impl Into<String>, signal_type: SignalType, index: usize) -> Self {
Self {
name: name.into(),
signal_type,
index,
}
}
}
/// Node category for UI organization
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NodeCategory {
Input,
Generator,
Effect,
Utility,
Output,
}
/// User-facing parameter definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Parameter {
pub id: u32,
pub name: String,
pub min: f32,
pub max: f32,
pub default: f32,
pub unit: ParameterUnit,
}
impl Parameter {
pub fn new(id: u32, name: impl Into<String>, min: f32, max: f32, default: f32, unit: ParameterUnit) -> Self {
Self {
id,
name: name.into(),
min,
max,
default,
unit,
}
}
}
/// Units for parameter values
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ParameterUnit {
Generic,
Frequency, // Hz
Decibels, // dB
Time, // seconds
Percent, // 0-100
}
/// Errors that can occur during graph operations
#[derive(Debug, Clone)]
pub enum ConnectionError {
TypeMismatch { expected: SignalType, got: SignalType },
InvalidPort,
WouldCreateCycle,
}
impl std::fmt::Display for ConnectionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConnectionError::TypeMismatch { expected, got } => {
write!(f, "Signal type mismatch: expected {:?}, got {:?}", expected, got)
}
ConnectionError::InvalidPort => write!(f, "Invalid port index"),
ConnectionError::WouldCreateCycle => write!(f, "Connection would create a cycle"),
}
}
}
impl std::error::Error for ConnectionError {}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,706 @@
use super::buffer_pool::BufferPool;
use super::clip::{AudioClipInstanceId, Clip};
use super::midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent};
use super::midi_pool::MidiClipPool;
use super::pool::AudioClipPool;
use super::track::{AudioTrack, Metatrack, MidiTrack, RenderContext, TrackId, TrackNode};
use crate::tempo_map::TempoMap;
use crate::time::{Beats, Seconds};
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
/// Project manages the hierarchical track structure and clip pools
///
/// Tracks are stored in a flat HashMap but can be organized into groups,
/// forming a tree structure. Groups render their children recursively.
///
/// Clip content is stored in pools (MidiClipPool), while tracks store
/// clip instances that reference the pool content.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
tracks: HashMap<TrackId, TrackNode>,
next_track_id: TrackId,
root_tracks: Vec<TrackId>, // Top-level tracks (not in any group)
sample_rate: u32, // System sample rate
/// Pool for MIDI clip content
pub midi_clip_pool: MidiClipPool,
/// Next MIDI clip instance ID (for generating unique IDs)
next_midi_clip_instance_id: MidiClipInstanceId,
}
impl Project {
/// Create a new empty project
pub fn new(sample_rate: u32) -> Self {
Self {
tracks: HashMap::new(),
next_track_id: 0,
root_tracks: Vec::new(),
sample_rate,
midi_clip_pool: MidiClipPool::new(),
next_midi_clip_instance_id: 1,
}
}
/// Generate a new unique track ID
fn next_id(&mut self) -> TrackId {
let id = self.next_track_id;
self.next_track_id += 1;
id
}
/// Add an audio track to the project
///
/// # Arguments
/// * `name` - Track name
/// * `parent_id` - Optional parent group ID
///
/// # Returns
/// The new track's ID
pub fn add_audio_track(&mut self, name: String, parent_id: Option<TrackId>) -> TrackId {
let id = self.next_id();
let track = AudioTrack::new(id, name, self.sample_rate);
self.tracks.insert(id, TrackNode::Audio(track));
if let Some(parent) = parent_id {
// Add to parent group
if let Some(TrackNode::Group(group)) = self.tracks.get_mut(&parent) {
group.add_child(id);
}
} else {
// Add to root level
self.root_tracks.push(id);
}
id
}
/// Add a group track to the project
///
/// # Arguments
/// * `name` - Group name
/// * `parent_id` - Optional parent group ID
///
/// # Returns
/// The new group's ID
pub fn add_group_track(&mut self, name: String, parent_id: Option<TrackId>) -> TrackId {
let id = self.next_id();
let group = Metatrack::new(id, name, self.sample_rate);
self.tracks.insert(id, TrackNode::Group(group));
if let Some(parent) = parent_id {
// Add to parent group
if let Some(TrackNode::Group(parent_group)) = self.tracks.get_mut(&parent) {
parent_group.add_child(id);
}
} else {
// Add to root level
self.root_tracks.push(id);
}
id
}
/// Add a MIDI track to the project
///
/// # Arguments
/// * `name` - Track name
/// * `parent_id` - Optional parent group ID
///
/// # Returns
/// The new track's ID
pub fn add_midi_track(&mut self, name: String, parent_id: Option<TrackId>) -> TrackId {
let id = self.next_id();
let track = MidiTrack::new(id, name, self.sample_rate);
self.tracks.insert(id, TrackNode::Midi(track));
if let Some(parent) = parent_id {
// Add to parent group
if let Some(TrackNode::Group(group)) = self.tracks.get_mut(&parent) {
group.add_child(id);
}
} else {
// Add to root level
self.root_tracks.push(id);
}
id
}
/// Remove a track from the project
///
/// If the track is a group, all children are moved to the parent (or root)
pub fn remove_track(&mut self, track_id: TrackId) {
if let Some(node) = self.tracks.remove(&track_id) {
// If it's a group, handle its children
if let TrackNode::Group(group) = node {
// Find the parent of this group
let parent_id = self.find_parent(track_id);
// Move children to parent or root
for child_id in group.children {
if let Some(parent) = parent_id {
if let Some(TrackNode::Group(parent_group)) = self.tracks.get_mut(&parent) {
parent_group.add_child(child_id);
}
} else {
self.root_tracks.push(child_id);
}
}
}
// Remove from parent or root
if let Some(parent_id) = self.find_parent(track_id) {
if let Some(TrackNode::Group(parent)) = self.tracks.get_mut(&parent_id) {
parent.remove_child(track_id);
}
} else {
self.root_tracks.retain(|&id| id != track_id);
}
}
}
/// Find the parent group of a track
fn find_parent(&self, track_id: TrackId) -> Option<TrackId> {
for (id, node) in &self.tracks {
if let TrackNode::Group(group) = node {
if group.children.contains(&track_id) {
return Some(*id);
}
}
}
None
}
/// Move a track to a different group
pub fn move_to_group(&mut self, track_id: TrackId, new_parent_id: TrackId) {
// First remove from current parent
if let Some(old_parent_id) = self.find_parent(track_id) {
if let Some(TrackNode::Group(parent)) = self.tracks.get_mut(&old_parent_id) {
parent.remove_child(track_id);
}
} else {
// Remove from root
self.root_tracks.retain(|&id| id != track_id);
}
// Add to new parent
if let Some(TrackNode::Group(new_parent)) = self.tracks.get_mut(&new_parent_id) {
new_parent.add_child(track_id);
}
}
/// Move a track to the root level (remove from any group)
pub fn move_to_root(&mut self, track_id: TrackId) {
// Remove from current parent if any
if let Some(parent_id) = self.find_parent(track_id) {
if let Some(TrackNode::Group(parent)) = self.tracks.get_mut(&parent_id) {
parent.remove_child(track_id);
}
// Add to root if not already there
if !self.root_tracks.contains(&track_id) {
self.root_tracks.push(track_id);
}
}
}
/// Get a reference to a track node
pub fn get_track(&self, track_id: TrackId) -> Option<&TrackNode> {
self.tracks.get(&track_id)
}
/// Get a mutable reference to a track node
pub fn get_track_mut(&mut self, track_id: TrackId) -> Option<&mut TrackNode> {
self.tracks.get_mut(&track_id)
}
/// Iterate over all tracks in the project.
pub fn track_iter(&self) -> impl Iterator<Item = (TrackId, &TrackNode)> {
self.tracks.iter().map(|(&id, node)| (id, node))
}
/// Get oscilloscope data from a node in a track's graph
pub fn get_oscilloscope_data(&self, track_id: TrackId, node_id: u32, sample_count: usize) -> Option<(Vec<f32>, Vec<f32>)> {
if let Some(TrackNode::Midi(track)) = self.tracks.get(&track_id) {
let graph = &track.instrument_graph;
let node_idx = petgraph::stable_graph::NodeIndex::new(node_id as usize);
// Get audio data
let audio = graph.get_oscilloscope_data(node_idx, sample_count)?;
// Get CV data (may be empty if no CV input or not an oscilloscope node)
let cv = graph.get_oscilloscope_cv_data(node_idx, sample_count).unwrap_or_default();
return Some((audio, cv));
}
None
}
/// Get oscilloscope data from a node inside a VoiceAllocator's best voice
pub fn get_voice_oscilloscope_data(&self, track_id: TrackId, va_node_id: u32, inner_node_id: u32, sample_count: usize) -> Option<(Vec<f32>, Vec<f32>)> {
if let Some(TrackNode::Midi(track)) = self.tracks.get(&track_id) {
let graph = &track.instrument_graph;
let va_idx = petgraph::stable_graph::NodeIndex::new(va_node_id as usize);
let node = graph.get_node(va_idx)?;
let va = node.as_any().downcast_ref::<crate::audio::node_graph::nodes::VoiceAllocatorNode>()?;
return va.get_voice_oscilloscope_data(inner_node_id, sample_count);
}
None
}
/// Get all root-level track IDs
pub fn root_tracks(&self) -> &[TrackId] {
&self.root_tracks
}
/// Get the number of tracks in the project
pub fn track_count(&self) -> usize {
self.tracks.len()
}
/// Check if any track is soloed
pub fn any_solo(&self) -> bool {
self.tracks.values().any(|node| node.is_solo())
}
/// Add a clip to an audio track
pub fn add_clip(&mut self, track_id: TrackId, clip: Clip) -> Result<AudioClipInstanceId, &'static str> {
if let Some(TrackNode::Audio(track)) = self.tracks.get_mut(&track_id) {
let instance_id = clip.id;
track.add_clip(clip);
Ok(instance_id)
} else {
Err("Track not found or is not an audio track")
}
}
/// Add a MIDI clip instance to a MIDI track
/// The clip content should already exist in the midi_clip_pool
pub fn add_midi_clip_instance(&mut self, track_id: TrackId, instance: MidiClipInstance) -> Result<(), &'static str> {
if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) {
track.add_clip_instance(instance);
Ok(())
} else {
Err("Track not found or is not a MIDI track")
}
}
/// Create a new MIDI clip in the pool and add an instance to a track
/// Returns (clip_id, instance_id) on success
pub fn create_midi_clip_with_instance(
&mut self,
track_id: TrackId,
events: Vec<MidiEvent>,
duration: Beats,
name: String,
external_start: Beats,
) -> Result<(MidiClipId, MidiClipInstanceId), &'static str> {
// Verify track exists and is a MIDI track
if !matches!(self.tracks.get(&track_id), Some(TrackNode::Midi(_))) {
return Err("Track not found or is not a MIDI track");
}
// Create clip in pool
let clip_id = self.midi_clip_pool.add_clip(events, duration, name);
// Create instance
let instance_id = self.next_midi_clip_instance_id;
self.next_midi_clip_instance_id += 1;
let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, external_start);
// Add instance to track
if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) {
track.add_clip_instance(instance);
}
Ok((clip_id, instance_id))
}
/// Generate a new unique MIDI clip instance ID
pub fn next_midi_clip_instance_id(&mut self) -> MidiClipInstanceId {
let id = self.next_midi_clip_instance_id;
self.next_midi_clip_instance_id += 1;
id
}
/// Legacy method for backwards compatibility - creates clip and instance from old MidiClip format
pub fn add_midi_clip(&mut self, track_id: TrackId, clip: MidiClip) -> Result<MidiClipInstanceId, &'static str> {
self.add_midi_clip_at(track_id, clip, Beats::ZERO)
}
/// Add a MIDI clip to the pool and create an instance at the given timeline position
pub fn add_midi_clip_at(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) -> Result<MidiClipInstanceId, &'static str> {
// Add the clip to the pool (it already has events and duration)
let duration = clip.duration;
let clip_id = clip.id;
self.midi_clip_pool.add_existing_clip(clip);
// Create an instance that uses the full clip at the given position
let instance_id = self.next_midi_clip_instance_id();
let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time);
self.add_midi_clip_instance(track_id, instance)?;
Ok(instance_id)
}
/// Remove a MIDI clip instance from a track (for undo/redo support)
pub fn remove_midi_clip(&mut self, track_id: TrackId, instance_id: MidiClipInstanceId) -> Result<(), &'static str> {
if let Some(track) = self.get_track_mut(track_id) {
track.remove_midi_clip_instance(instance_id);
Ok(())
} else {
Err("Track not found")
}
}
/// Remove an audio clip instance from a track (for undo/redo support)
pub fn remove_audio_clip(&mut self, track_id: TrackId, instance_id: AudioClipInstanceId) -> Result<(), &'static str> {
if let Some(track) = self.get_track_mut(track_id) {
track.remove_audio_clip_instance(instance_id);
Ok(())
} else {
Err("Track not found")
}
}
/// Render all root tracks into the output buffer.
///
/// When `live_only` is true, MIDI tracks skip clip event collection and only process
/// their live MIDI queue (note-off tails + keyboard input). Audio tracks produce silence.
/// This lets the caller use the same group-hierarchy render path regardless of play state.
pub fn render(
&mut self,
output: &mut [f32],
audio_pool: &AudioClipPool,
buffer_pool: &mut BufferPool,
playhead_seconds: Seconds,
tempo_map: &TempoMap,
sample_rate: u32,
channels: u32,
live_only: bool,
) {
output.fill(0.0);
let any_solo = self.any_solo();
// Create initial render context
let ctx = RenderContext {
live_only,
..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len())
};
// Render each root track (index-based to avoid clone)
for i in 0..self.root_tracks.len() {
let track_id = self.root_tracks[i];
self.render_track(
track_id,
output,
audio_pool,
buffer_pool,
ctx,
any_solo,
false, // root tracks are not inside a soloed parent
);
}
}
/// Recursively render a track (audio or group) into the output buffer
fn render_track(
&mut self,
track_id: TrackId,
output: &mut [f32],
audio_pool: &AudioClipPool,
buffer_pool: &mut BufferPool,
ctx: RenderContext,
any_solo: bool,
parent_is_soloed: bool,
) {
// Check if track should be rendered based on mute/solo
let should_render = match self.tracks.get(&track_id) {
Some(TrackNode::Audio(track)) => {
// If parent is soloed, only check mute state
// Otherwise, check normal solo logic
if parent_is_soloed {
!track.muted
} else {
track.is_active(any_solo)
}
}
Some(TrackNode::Midi(track)) => {
// Same logic for MIDI tracks
if parent_is_soloed {
!track.muted
} else {
track.is_active(any_solo)
}
}
Some(TrackNode::Group(group)) => {
// Same logic for groups
if parent_is_soloed {
!group.muted
} else {
group.is_active(any_solo)
}
}
None => return,
};
if !should_render {
return;
}
// Handle audio track vs MIDI track vs group track
match self.tracks.get_mut(&track_id) {
Some(TrackNode::Audio(track)) => {
// Audio tracks have no live input; skip in live_only mode.
if ctx.live_only {
return;
}
// Render audio track into a temp buffer for peak measurement
let mut track_buffer = buffer_pool.acquire();
track_buffer.resize(output.len(), 0.0);
track_buffer.fill(0.0);
track.render(&mut track_buffer, audio_pool, ctx);
// Accumulate peak level for VU metering (max over meter interval)
let buffer_peak = track_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
track.peak_level = track.peak_level.max(buffer_peak);
// Mix into output
for (out, src) in output.iter_mut().zip(track_buffer.iter()) {
*out += src;
}
buffer_pool.release(track_buffer);
}
Some(TrackNode::Midi(track)) => {
// Render MIDI track into a temp buffer for peak measurement
let mut track_buffer = buffer_pool.acquire();
track_buffer.resize(output.len(), 0.0);
track_buffer.fill(0.0);
track.render(&mut track_buffer, &self.midi_clip_pool, ctx);
// Accumulate peak level for VU metering (max over meter interval)
let buffer_peak = track_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
track.peak_level = track.peak_level.max(buffer_peak);
// Mix into output
for (out, src) in output.iter_mut().zip(track_buffer.iter()) {
*out += src;
}
buffer_pool.release(track_buffer);
}
Some(TrackNode::Group(group)) => {
// Skip rendering if playhead is outside the metatrack's trim window.
// In live_only mode always render so note-off tails pass through the mixer.
if !ctx.live_only && !group.is_active_at_time(ctx.playhead_seconds) {
return;
}
// Read group properties and transform context before any mutable borrows
let num_children = group.children.len();
let this_group_is_soloed = group.solo;
let child_ctx = group.transform_context(ctx);
let children_parent_soloed = parent_is_soloed || this_group_is_soloed;
// Render each child into its own buffer and inject into SubtrackInputsNode.
// One pool buffer is reused per child (no extra allocation per frame).
for i in 0..num_children {
let child_id = match self.tracks.get(&track_id) {
Some(TrackNode::Group(g)) => g.children[i],
_ => break,
};
let mut child_buffer = buffer_pool.acquire();
child_buffer.resize(output.len(), 0.0);
child_buffer.fill(0.0);
self.render_track(
child_id,
&mut child_buffer,
audio_pool,
buffer_pool,
child_ctx,
any_solo,
children_parent_soloed,
);
// Inject into the SubtrackInputsNode slot for this child
if let Some(TrackNode::Group(group)) = self.tracks.get_mut(&track_id) {
use super::node_graph::nodes::SubtrackInputsNode;
let node_indices: Vec<_> = group.audio_graph.node_indices().collect();
for node_idx in node_indices {
if let Some(gn) = group.audio_graph.get_graph_node_mut(node_idx) {
if gn.node.node_type() == "SubtrackInputs" {
if let Some(si) = gn.node.as_any_mut()
.downcast_mut::<SubtrackInputsNode>()
{
if let Some(slot) = si.subtrack_index_for(child_id) {
si.inject_subtrack_audio(slot, &child_buffer);
}
}
break;
}
}
}
}
buffer_pool.release(child_buffer);
}
// Process children's audio through the metatrack's mixing graph
if let Some(TrackNode::Group(group)) = self.tracks.get_mut(&track_id) {
let mut graph_output = buffer_pool.acquire();
graph_output.resize(output.len(), 0.0);
graph_output.fill(0.0);
group.audio_graph.process(&mut graph_output, &[], ctx.playhead_beats());
for (out_sample, graph_sample) in output.iter_mut().zip(graph_output.iter()) {
*out_sample += graph_sample * group.volume;
}
buffer_pool.release(graph_output);
}
}
None => {}
}
}
/// Reset all per-clip read-ahead target frames before a new render cycle.
pub fn reset_read_ahead_targets(&self) {
for track in self.tracks.values() {
if let TrackNode::Audio(audio_track) = track {
for clip in &audio_track.clips {
if let Some(ra) = clip.read_ahead.as_deref() {
ra.reset_target_frame();
}
}
}
}
}
/// Collect per-track peak levels for VU metering and reset accumulators
pub fn collect_track_peaks(&mut self) -> Vec<(TrackId, f32)> {
let mut levels = Vec::new();
for (id, track) in &mut self.tracks {
match track {
TrackNode::Audio(t) => {
levels.push((*id, t.peak_level));
t.peak_level = 0.0;
}
TrackNode::Midi(t) => {
levels.push((*id, t.peak_level));
t.peak_level = 0.0;
}
TrackNode::Group(_) => {}
}
}
levels
}
/// Stop all notes on all MIDI tracks
pub fn stop_all_notes(&mut self) {
for track in self.tracks.values_mut() {
if let TrackNode::Midi(midi_track) = track {
midi_track.stop_all_notes();
}
}
}
/// Set export (blocking) mode on all clip read-ahead buffers.
/// When enabled, `render_from_file` blocks until the disk reader
/// has filled the needed frames instead of returning silence.
pub fn set_export_mode(&self, export: bool) {
for track in self.tracks.values() {
if let TrackNode::Audio(t) = track {
for clip in &t.clips {
if let Some(ref ra) = clip.read_ahead {
ra.set_export_mode(export);
}
}
}
}
}
/// Reset all node graphs (clears effect buffers on seek)
pub fn reset_all_graphs(&mut self) {
for track in self.tracks.values_mut() {
match track {
TrackNode::Audio(t) => t.effects_graph.reset(),
TrackNode::Midi(t) => t.instrument_graph.reset(),
TrackNode::Group(_) => {}
}
}
}
/// Propagate tempo to all audio graphs (for BeatNode sync)
pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) {
for track in self.tracks.values_mut() {
match track {
TrackNode::Audio(t) => t.effects_graph.set_tempo(bpm, beats_per_bar),
TrackNode::Midi(t) => t.instrument_graph.set_tempo(bpm, beats_per_bar),
TrackNode::Group(g) => g.audio_graph.set_tempo(bpm, beats_per_bar),
}
}
}
/// Send a live MIDI note on event to a track's instrument
/// Note: With node-based instruments, MIDI events are handled during the process() call
pub fn send_midi_note_on(&mut self, track_id: TrackId, note: u8, velocity: u8) {
// Queue the MIDI note-on event to the track's live MIDI queue
if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) {
let event = MidiEvent::note_on(Beats::ZERO, 0, note, velocity);
track.queue_live_midi(event);
}
}
/// Send a live MIDI note off event to a track's instrument
pub fn send_midi_note_off(&mut self, track_id: TrackId, note: u8) {
// Queue the MIDI note-off event to the track's live MIDI queue
if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) {
let event = MidiEvent::note_off(Beats::ZERO, 0, note, 0);
track.queue_live_midi(event);
}
}
/// Prepare all tracks for serialization by saving their audio graphs as presets
pub fn prepare_for_save(&mut self) {
for track in self.tracks.values_mut() {
match track {
TrackNode::Audio(audio_track) => {
audio_track.prepare_for_save();
}
TrackNode::Midi(midi_track) => {
midi_track.prepare_for_save();
}
TrackNode::Group(group) => {
group.prepare_for_save();
}
}
}
}
/// Rebuild all audio graphs from presets after deserialization
///
/// This should be called after deserializing a Project to reconstruct
/// the AudioGraph instances from their stored presets.
///
/// # Arguments
/// * `buffer_size` - Buffer size for audio processing (typically 8192)
pub fn rebuild_audio_graphs(&mut self, buffer_size: usize) -> Result<(), String> {
for track in self.tracks.values_mut() {
match track {
TrackNode::Audio(audio_track) => {
audio_track.rebuild_audio_graph(self.sample_rate, buffer_size)?;
}
TrackNode::Midi(midi_track) => {
midi_track.rebuild_audio_graph(self.sample_rate, buffer_size)?;
}
TrackNode::Group(group) => {
group.rebuild_audio_graph(self.sample_rate, buffer_size)?;
}
}
}
Ok(())
}
}
impl Default for Project {
fn default() -> Self {
Self::new(48000) // Use 48kHz as default, will be overridden when created with actual sample rate
}
}

View File

@ -0,0 +1,264 @@
/// Audio recording system for capturing microphone input
use crate::audio::{ClipId, MidiClipId, TrackId};
use crate::io::{WavWriter, WaveformPeak};
use crate::time::{Beats, Seconds};
use std::collections::HashMap;
use std::path::PathBuf;
/// State of an active recording session
pub struct RecordingState {
/// Track being recorded to
pub track_id: TrackId,
/// Clip ID for the intermediate clip
pub clip_id: ClipId,
/// Path to temporary WAV file
pub temp_file_path: PathBuf,
/// WAV file writer (only used at finalization, not during recording)
pub writer: WavWriter,
/// Sample rate of recording
pub sample_rate: u32,
/// Number of channels
pub channels: u32,
/// Timeline start position
pub start_time: Beats,
/// Total frames recorded
pub frames_written: usize,
/// Whether recording is currently paused
pub paused: bool,
/// Number of samples remaining to skip (to discard stale buffer data)
pub samples_to_skip: usize,
/// Waveform peaks generated incrementally during recording
pub waveform: Vec<WaveformPeak>,
/// Temporary buffer for collecting samples for next waveform peak
pub waveform_buffer: Vec<f32>,
/// Number of frames per waveform peak
pub frames_per_peak: usize,
/// All recorded audio data accumulated in memory (written to disk at finalization)
pub audio_data: Vec<f32>,
}
impl RecordingState {
/// Create a new recording state
pub fn new(
track_id: TrackId,
clip_id: ClipId,
temp_file_path: PathBuf,
writer: WavWriter,
sample_rate: u32,
channels: u32,
start_time: Beats,
_flush_interval_seconds: f64, // No longer used - kept for API compatibility
) -> Self {
// Calculate frames per waveform peak
// Target ~300 peaks per second with minimum 1000 samples per peak
let target_peaks_per_second = 300;
let frames_per_peak = (sample_rate / target_peaks_per_second).max(1000) as usize;
Self {
track_id,
clip_id,
temp_file_path,
writer,
sample_rate,
channels,
start_time,
frames_written: 0,
paused: false,
samples_to_skip: 0, // Will be set by engine when it knows buffer size
waveform: Vec::new(),
waveform_buffer: Vec::new(),
frames_per_peak,
audio_data: Vec::new(),
}
}
/// Add samples to the accumulation buffer
/// Returns true if a flush occurred
pub fn add_samples(&mut self, samples: &[f32]) -> Result<bool, std::io::Error> {
if self.paused {
return Ok(false);
}
// Determine which samples to process
let samples_to_process = if self.samples_to_skip > 0 {
let to_skip = self.samples_to_skip.min(samples.len());
self.samples_to_skip -= to_skip;
if to_skip == samples.len() {
// Skip entire batch
return Ok(false);
}
// Skip partial batch and process the rest
&samples[to_skip..]
} else {
samples
};
// Add to audio data (accumulate in memory - disk write happens at finalization only)
self.audio_data.extend_from_slice(samples_to_process);
// Add to waveform buffer and generate peaks incrementally
self.waveform_buffer.extend_from_slice(samples_to_process);
self.generate_waveform_peaks();
// Track frames for duration calculation (no disk I/O in audio callback!)
let frames_added = samples_to_process.len() / self.channels as usize;
self.frames_written += frames_added;
Ok(false)
}
/// Generate waveform peaks from accumulated samples
/// This is called incrementally as samples arrive
fn generate_waveform_peaks(&mut self) {
let samples_per_peak = self.frames_per_peak * self.channels as usize;
while self.waveform_buffer.len() >= samples_per_peak {
let mut min = 0.0f32;
let mut max = 0.0f32;
// Scan all samples for this peak
for sample in &self.waveform_buffer[..samples_per_peak] {
min = min.min(*sample);
max = max.max(*sample);
}
self.waveform.push(WaveformPeak { min, max });
// Remove processed samples from waveform buffer
self.waveform_buffer.drain(..samples_per_peak);
}
}
/// Get current recording duration
pub fn duration(&self) -> Seconds {
Seconds(self.frames_written as f64 / self.sample_rate as f64)
}
/// Finalize the recording and return the temp file path, waveform, and audio data
pub fn finalize(mut self) -> Result<(PathBuf, Vec<WaveformPeak>, Vec<f32>), std::io::Error> {
// Write all audio data to disk at once (outside audio callback - safe to do I/O)
if !self.audio_data.is_empty() {
self.writer.write_samples(&self.audio_data)?;
}
// Generate final waveform peak from any remaining samples
if !self.waveform_buffer.is_empty() {
let mut min = 0.0f32;
let mut max = 0.0f32;
for sample in &self.waveform_buffer {
min = min.min(*sample);
max = max.max(*sample);
}
self.waveform.push(WaveformPeak { min, max });
}
// Finalize the WAV file
self.writer.finalize()?;
Ok((self.temp_file_path, self.waveform, self.audio_data))
}
/// Pause recording
pub fn pause(&mut self) {
self.paused = true;
}
/// Resume recording
pub fn resume(&mut self) {
self.paused = false;
}
}
/// Active MIDI note waiting for its noteOff event
#[derive(Debug, Clone)]
struct ActiveMidiNote {
note: u8,
velocity: u8,
start_time: Beats,
}
/// State of an active MIDI recording session.
pub struct MidiRecordingState {
pub track_id: TrackId,
pub clip_id: MidiClipId,
pub start_time: Beats,
active_notes: HashMap<u8, ActiveMidiNote>,
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats
pub completed_notes: Vec<(Beats, u8, u8, Beats)>,
}
impl MidiRecordingState {
pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: Beats) -> Self {
Self {
track_id,
clip_id,
start_time,
active_notes: HashMap::new(),
completed_notes: Vec::new(),
}
}
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) {
self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time });
}
pub fn note_off(&mut self, note: u8, absolute_time: Beats) {
if let Some(active_note) = self.active_notes.remove(&note) {
if absolute_time <= self.start_time {
return;
}
let note_start = active_note.start_time.max(self.start_time);
self.completed_notes.push((
note_start - self.start_time,
active_note.note,
active_note.velocity,
absolute_time - note_start,
));
}
}
pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] {
&self.completed_notes
}
pub fn note_count(&self) -> usize {
self.completed_notes.len()
}
/// Get all completed notes plus currently-held notes with a provisional duration.
pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
let mut notes = self.completed_notes.clone();
for active in self.active_notes.values() {
let note_start = active.start_time.max(self.start_time);
notes.push((
note_start - self.start_time,
active.note,
active.velocity,
(current_time - note_start).max(Beats::ZERO),
));
}
notes
}
pub fn active_note_numbers(&self) -> Vec<u8> {
self.active_notes.keys().copied().collect()
}
pub fn close_active_notes(&mut self, end_time: Beats) {
let active_notes: Vec<_> = self.active_notes.drain().collect();
for (_note_num, active_note) in active_notes {
let note_start = active_note.start_time.max(self.start_time);
self.completed_notes.push((
note_start - self.start_time,
active_note.note,
active_note.velocity,
end_time - note_start,
));
}
}
}

View File

@ -0,0 +1,306 @@
use symphonia::core::audio::{AudioBufferRef, Signal};
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use std::fs::File;
use std::io::Cursor;
use std::path::Path;
/// Loaded audio sample data
#[derive(Debug, Clone)]
pub struct SampleData {
/// Audio samples (mono, f32 format)
pub samples: Vec<f32>,
/// Original sample rate
pub sample_rate: u32,
}
/// Load an audio file and decode it to mono f32 samples
pub fn load_audio_file(path: impl AsRef<Path>) -> Result<SampleData, String> {
let path = path.as_ref();
let file = File::open(path).map_err(|e| format!("Failed to open file: {}", e))?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
decode_mss(mss, hint)
}
/// Load audio from an in-memory byte slice and decode it to mono f32 samples.
/// Supports WAV, FLAC, MP3, AAC, and any other format Symphonia recognises.
/// `filename_hint` is used to help Symphonia detect the format (e.g. "kick.wav").
pub fn load_audio_from_bytes(bytes: &[u8], filename_hint: &str) -> Result<SampleData, String> {
let cursor = Cursor::new(bytes.to_vec());
let mss = MediaSourceStream::new(Box::new(cursor), Default::default());
let mut hint = Hint::new();
if let Some(ext) = std::path::Path::new(filename_hint).extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
decode_mss(mss, hint)
}
/// Shared decode logic: probe `mss`, find the first audio track, decode to mono f32.
fn decode_mss(mss: MediaSourceStream, hint: Hint) -> Result<SampleData, String> {
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
.map_err(|e| format!("Failed to probe format: {}", e))?;
let mut format = probed.format;
let track = format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or_else(|| "No audio tracks found".to_string())?;
let track_id = track.id;
let sample_rate = track.codec_params.sample_rate.unwrap_or(48000);
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| format!("Failed to create decoder: {}", e))?;
let mut all_samples = Vec::new();
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
break;
}
Err(e) => return Err(format!("Error reading packet: {}", e)),
};
if packet.track_id() != track_id {
continue;
}
let decoded = decoder
.decode(&packet)
.map_err(|e| format!("Failed to decode packet: {}", e))?;
all_samples.extend_from_slice(&convert_to_mono_f32(&decoded));
}
Ok(SampleData { samples: all_samples, sample_rate })
}
/// Convert an audio buffer to mono f32 samples
fn convert_to_mono_f32(buf: &AudioBufferRef) -> Vec<f32> {
match buf {
AudioBufferRef::F32(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
let mut mono = Vec::with_capacity(frames);
if channels == 1 {
// Already mono
mono.extend_from_slice(buf.chan(0));
} else {
// Mix down to mono by averaging all channels
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
sum += buf.chan(ch)[frame];
}
mono.push(sum / channels as f32);
}
}
mono
}
AudioBufferRef::U8(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
let mut mono = Vec::with_capacity(frames);
if channels == 1 {
for &sample in buf.chan(0) {
mono.push((sample as f32 - 128.0) / 128.0);
}
} else {
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
sum += (buf.chan(ch)[frame] as f32 - 128.0) / 128.0;
}
mono.push(sum / channels as f32);
}
}
mono
}
AudioBufferRef::U16(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
let mut mono = Vec::with_capacity(frames);
if channels == 1 {
for &sample in buf.chan(0) {
mono.push((sample as f32 - 32768.0) / 32768.0);
}
} else {
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
sum += (buf.chan(ch)[frame] as f32 - 32768.0) / 32768.0;
}
mono.push(sum / channels as f32);
}
}
mono
}
AudioBufferRef::U24(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
let mut mono = Vec::with_capacity(frames);
if channels == 1 {
for &sample in buf.chan(0) {
mono.push((sample.inner() as f32 - 8388608.0) / 8388608.0);
}
} else {
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
sum += (buf.chan(ch)[frame].inner() as f32 - 8388608.0) / 8388608.0;
}
mono.push(sum / channels as f32);
}
}
mono
}
AudioBufferRef::U32(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
let mut mono = Vec::with_capacity(frames);
if channels == 1 {
for &sample in buf.chan(0) {
mono.push((sample as f32 - 2147483648.0) / 2147483648.0);
}
} else {
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
sum += (buf.chan(ch)[frame] as f32 - 2147483648.0) / 2147483648.0;
}
mono.push(sum / channels as f32);
}
}
mono
}
AudioBufferRef::S8(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
let mut mono = Vec::with_capacity(frames);
if channels == 1 {
for &sample in buf.chan(0) {
mono.push(sample as f32 / 128.0);
}
} else {
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
sum += buf.chan(ch)[frame] as f32 / 128.0;
}
mono.push(sum / channels as f32);
}
}
mono
}
AudioBufferRef::S16(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
let mut mono = Vec::with_capacity(frames);
if channels == 1 {
for &sample in buf.chan(0) {
mono.push(sample as f32 / 32768.0);
}
} else {
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
sum += buf.chan(ch)[frame] as f32 / 32768.0;
}
mono.push(sum / channels as f32);
}
}
mono
}
AudioBufferRef::S24(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
let mut mono = Vec::with_capacity(frames);
if channels == 1 {
for &sample in buf.chan(0) {
mono.push(sample.inner() as f32 / 8388608.0);
}
} else {
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
sum += buf.chan(ch)[frame].inner() as f32 / 8388608.0;
}
mono.push(sum / channels as f32);
}
}
mono
}
AudioBufferRef::S32(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
let mut mono = Vec::with_capacity(frames);
if channels == 1 {
for &sample in buf.chan(0) {
mono.push(sample as f32 / 2147483648.0);
}
} else {
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
sum += buf.chan(ch)[frame] as f32 / 2147483648.0;
}
mono.push(sum / channels as f32);
}
}
mono
}
AudioBufferRef::F64(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
let mut mono = Vec::with_capacity(frames);
if channels == 1 {
for &sample in buf.chan(0) {
mono.push(sample as f32);
}
} else {
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
sum += buf.chan(ch)[frame] as f32;
}
mono.push(sum / channels as f32);
}
}
mono
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,290 @@
//! Waveform chunk cache for scalable multi-resolution waveform generation
//!
//! This module provides a chunk-based waveform caching system that generates
//! waveform data progressively at multiple detail levels, avoiding the limitations
//! of the old fixed 20,000-peak approach.
use crate::io::{WaveformChunk, WaveformChunkKey, WaveformPeak};
use crate::audio::pool::AudioFile;
use std::collections::HashMap;
/// Detail levels for multi-resolution waveform storage
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetailLevel {
Overview = 0, // 1 peak per second
Low = 1, // 10 peaks per second
Medium = 2, // 100 peaks per second
High = 3, // 1000 peaks per second
Max = 4, // Full resolution (sample-accurate)
}
impl DetailLevel {
/// Get peaks per second for this detail level
pub fn peaks_per_second(self) -> usize {
match self {
DetailLevel::Overview => 1,
DetailLevel::Low => 10,
DetailLevel::Medium => 100,
DetailLevel::High => 1000,
DetailLevel::Max => 48000, // Approximate max for sample-accurate
}
}
/// Create from u8 value
pub fn from_u8(value: u8) -> Option<Self> {
match value {
0 => Some(DetailLevel::Overview),
1 => Some(DetailLevel::Low),
2 => Some(DetailLevel::Medium),
3 => Some(DetailLevel::High),
4 => Some(DetailLevel::Max),
_ => None,
}
}
}
/// Priority for chunk generation
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ChunkPriority {
Low = 0, // Background generation
Medium = 1, // Precache adjacent to viewport
High = 2, // Visible in current viewport
}
/// Chunk generation request
#[derive(Debug, Clone)]
pub struct ChunkGenerationRequest {
pub key: WaveformChunkKey,
pub priority: ChunkPriority,
}
/// Waveform chunk cache with multi-resolution support
pub struct WaveformCache {
/// Cached chunks indexed by key
chunks: HashMap<WaveformChunkKey, Vec<WaveformPeak>>,
/// Maximum memory usage in MB (for future LRU eviction)
_max_memory_mb: usize,
/// Current memory usage estimate in bytes
current_memory_bytes: usize,
}
impl WaveformCache {
/// Create a new waveform cache with the specified memory limit
pub fn new(max_memory_mb: usize) -> Self {
Self {
chunks: HashMap::new(),
_max_memory_mb: max_memory_mb,
current_memory_bytes: 0,
}
}
/// Get a chunk from the cache
pub fn get_chunk(&self, key: &WaveformChunkKey) -> Option<&Vec<WaveformPeak>> {
self.chunks.get(key)
}
/// Store a chunk in the cache
pub fn store_chunk(&mut self, key: WaveformChunkKey, peaks: Vec<WaveformPeak>) {
let chunk_size = peaks.len() * std::mem::size_of::<WaveformPeak>();
self.current_memory_bytes += chunk_size;
self.chunks.insert(key, peaks);
// TODO: Implement LRU eviction if memory exceeds limit
}
/// Check if a chunk exists in the cache
pub fn has_chunk(&self, key: &WaveformChunkKey) -> bool {
self.chunks.contains_key(key)
}
/// Clear all chunks for a specific pool index (when file is unloaded)
pub fn clear_pool(&mut self, pool_index: usize) {
self.chunks.retain(|key, peaks| {
if key.pool_index == pool_index {
let chunk_size = peaks.len() * std::mem::size_of::<WaveformPeak>();
self.current_memory_bytes = self.current_memory_bytes.saturating_sub(chunk_size);
false
} else {
true
}
});
}
/// Generate a single waveform chunk for an audio file
///
/// This generates peaks for a specific time range at a specific detail level.
/// The chunk covers a time range based on the detail level and chunk index.
pub fn generate_chunk(
audio_file: &AudioFile,
detail_level: u8,
chunk_index: u32,
) -> Option<WaveformChunk> {
let level = DetailLevel::from_u8(detail_level)?;
let peaks_per_second = level.peaks_per_second();
// Calculate time range for this chunk based on detail level
// Each chunk covers a varying amount of time depending on detail level
let chunk_duration_seconds = match level {
DetailLevel::Overview => 60.0, // 60 seconds per chunk (60 peaks)
DetailLevel::Low => 30.0, // 30 seconds per chunk (300 peaks)
DetailLevel::Medium => 10.0, // 10 seconds per chunk (1000 peaks)
DetailLevel::High => 5.0, // 5 seconds per chunk (5000 peaks)
DetailLevel::Max => 1.0, // 1 second per chunk (48000 peaks)
};
let start_time = chunk_index as f64 * chunk_duration_seconds;
let end_time = start_time + chunk_duration_seconds;
// Check if this chunk is within the audio file duration
let audio_duration = audio_file.duration_seconds();
if start_time >= audio_duration {
return None; // Chunk is completely beyond file end
}
// Clamp end_time to file duration
let end_time = end_time.min(audio_duration);
// Calculate frame range
let start_frame = (start_time * audio_file.sample_rate as f64) as usize;
let end_frame = (end_time * audio_file.sample_rate as f64) as usize;
// Calculate number of peaks for this time range
let duration = end_time - start_time;
let target_peaks = (duration * peaks_per_second as f64).ceil() as usize;
if target_peaks == 0 {
return None;
}
// Generate peaks using the existing method
let peaks = audio_file.generate_waveform_overview_range(
start_frame,
end_frame,
target_peaks,
);
Some(WaveformChunk {
audio_pool_index: 0, // Will be set by caller
detail_level,
chunk_index,
time_range: (start_time, end_time),
peaks,
})
}
/// Generate multiple chunks for an audio file
///
/// This is a convenience method for generating several chunks at once.
pub fn generate_chunks(
audio_file: &AudioFile,
pool_index: usize,
detail_level: u8,
chunk_indices: &[u32],
) -> Vec<WaveformChunk> {
chunk_indices
.iter()
.filter_map(|&chunk_index| {
let mut chunk = Self::generate_chunk(audio_file, detail_level, chunk_index)?;
chunk.audio_pool_index = pool_index;
Some(chunk)
})
.collect()
}
/// Calculate how many chunks are needed for a file at a given detail level
pub fn calculate_chunk_count(duration_seconds: f64, detail_level: u8) -> u32 {
let level = match DetailLevel::from_u8(detail_level) {
Some(l) => l,
None => return 0,
};
let chunk_duration_seconds = match level {
DetailLevel::Overview => 60.0,
DetailLevel::Low => 30.0,
DetailLevel::Medium => 10.0,
DetailLevel::High => 5.0,
DetailLevel::Max => 1.0,
};
((duration_seconds / chunk_duration_seconds).ceil() as u32).max(1)
}
/// Generate all Level 0 (overview) chunks for a file
///
/// This should be called immediately when a file is imported to provide
/// instant thumbnail display.
pub fn generate_overview_chunks(
&mut self,
audio_file: &AudioFile,
pool_index: usize,
) -> Vec<WaveformChunk> {
let duration = audio_file.duration_seconds();
let chunk_count = Self::calculate_chunk_count(duration, 0);
let chunk_indices: Vec<u32> = (0..chunk_count).collect();
let chunks = Self::generate_chunks(audio_file, pool_index, 0, &chunk_indices);
// Store chunks in cache
for chunk in &chunks {
let key = WaveformChunkKey {
pool_index,
detail_level: chunk.detail_level,
chunk_index: chunk.chunk_index,
};
self.store_chunk(key, chunk.peaks.clone());
}
chunks
}
/// Get current memory usage in bytes
pub fn memory_usage_bytes(&self) -> usize {
self.current_memory_bytes
}
/// Get current memory usage in megabytes
pub fn memory_usage_mb(&self) -> f64 {
self.current_memory_bytes as f64 / 1_000_000.0
}
/// Get number of cached chunks
pub fn chunk_count(&self) -> usize {
self.chunks.len()
}
}
impl Default for WaveformCache {
fn default() -> Self {
Self::new(100) // Default 100MB cache
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detail_level_peaks_per_second() {
assert_eq!(DetailLevel::Overview.peaks_per_second(), 1);
assert_eq!(DetailLevel::Low.peaks_per_second(), 10);
assert_eq!(DetailLevel::Medium.peaks_per_second(), 100);
assert_eq!(DetailLevel::High.peaks_per_second(), 1000);
}
#[test]
fn test_chunk_count_calculation() {
// 60 second file, Overview level (60s chunks) = 1 chunk
assert_eq!(WaveformCache::calculate_chunk_count(60.0, 0), 1);
// 120 second file, Overview level (60s chunks) = 2 chunks
assert_eq!(WaveformCache::calculate_chunk_count(120.0, 0), 2);
// 10 second file, Medium level (10s chunks) = 1 chunk
assert_eq!(WaveformCache::calculate_chunk_count(10.0, 2), 1);
// 25 second file, Medium level (10s chunks) = 3 chunks
assert_eq!(WaveformCache::calculate_chunk_count(25.0, 2), 3);
}
}

View File

@ -0,0 +1,3 @@
pub mod types;
pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse};

View File

@ -0,0 +1,535 @@
use crate::audio::{
AudioClipInstanceId, AutomationLaneId, ClipId, CurveType, MidiClip, MidiClipId,
MidiClipInstanceId, ParameterId, TrackId,
};
use crate::audio::midi::MidiEvent;
use crate::audio::buffer_pool::BufferPoolStats;
use crate::audio::node_graph::nodes::LoopMode;
use crate::io::WaveformPeak;
use crate::time::{Beats, Seconds};
/// Commands sent from UI/control thread to audio thread
#[derive(Debug, Clone)]
pub enum Command {
// Transport commands
/// Start playback
Play,
/// Stop playback and reset to beginning
Stop,
/// Pause playback (maintains position)
Pause,
/// Seek to a specific position in seconds
Seek(f64),
// Track management commands
/// Set track volume (0.0 = silence, 1.0 = unity gain)
SetTrackVolume(TrackId, f32),
/// Set track mute state
SetTrackMute(TrackId, bool),
/// Set track solo state
SetTrackSolo(TrackId, bool),
// Clip management commands
/// Move a clip to a new timeline position (track_id, clip_id, new_external_start)
MoveClip(TrackId, ClipId, f64),
/// Trim a clip's internal boundaries (track_id, clip_id, new_internal_start, new_internal_end)
/// This changes which portion of the source content is used
TrimClip(TrackId, ClipId, f64, f64),
/// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration)
/// If duration > internal duration, the clip will loop
ExtendClip(TrackId, ClipId, f64),
// Metatrack management commands
/// Create a new metatrack with a name and optional parent group
CreateMetatrack(String, Option<TrackId>),
/// Add a track to a metatrack (track_id, metatrack_id)
AddToMetatrack(TrackId, TrackId),
/// Remove a track from its parent metatrack
RemoveFromMetatrack(TrackId),
// Metatrack transformation commands
/// Set metatrack time stretch factor (track_id, stretch_factor)
/// 0.5 = half speed, 1.0 = normal, 2.0 = double speed
SetTimeStretch(TrackId, f32),
/// Set metatrack time offset in seconds (track_id, offset)
/// Positive = shift content later, negative = shift earlier
SetOffset(TrackId, f64),
/// Set metatrack pitch shift in semitones (track_id, semitones) - for future use
SetPitchShift(TrackId, f32),
/// Set metatrack trim start in seconds (track_id, trim_start)
/// Children won't hear content before this point
SetTrimStart(TrackId, f64),
/// Set metatrack trim end in seconds (track_id, trim_end)
/// None means no end trim
SetTrimEnd(TrackId, Option<f64>),
// Audio track commands
/// Create a new audio track with a name and optional parent group
CreateAudioTrack(String, Option<TrackId>),
/// Add an audio file to the pool (path, data, channels, sample_rate)
/// Returns the pool index via an AudioEvent
AddAudioFile(String, Vec<f32>, u32, u32),
/// Add a clip to an audio track (track_id, clip_id, pool_index, start_time, duration, offset)
/// The clip_id is pre-assigned by the caller (via EngineController::next_audio_clip_id())
AddAudioClip(TrackId, AudioClipInstanceId, usize, f64, f64, f64),
// MIDI commands
/// Create a new MIDI track with a name and optional parent group
CreateMidiTrack(String, Option<TrackId>),
/// Add a MIDI clip to the pool without placing it on a track
AddMidiClipToPool(MidiClip),
/// Create a new MIDI clip on a track (track_id, start_time, duration)
CreateMidiClip(TrackId, f64, f64),
/// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration)
AddMidiNote(TrackId, MidiClipId, f64, u8, u8, f64),
/// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time)
AddLoadedMidiClip(TrackId, MidiClip, f64),
/// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>)
/// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes
UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>),
/// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing.
UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>),
/// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support
RemoveMidiClip(TrackId, MidiClipInstanceId),
/// Remove an audio clip instance from a track (track_id, instance_id) - for undo/redo support
RemoveAudioClip(TrackId, AudioClipInstanceId),
// Diagnostics commands
/// Request buffer pool statistics
RequestBufferPoolStats,
// Automation commands
/// Create a new automation lane on a track (track_id, parameter_id)
CreateAutomationLane(TrackId, ParameterId),
/// Add an automation point to a lane (track_id, lane_id, time, value, curve)
AddAutomationPoint(TrackId, AutomationLaneId, f64, f32, CurveType),
/// Remove an automation point at a specific time (track_id, lane_id, time, tolerance)
RemoveAutomationPoint(TrackId, AutomationLaneId, f64, f64),
/// Clear all automation points from a lane (track_id, lane_id)
ClearAutomationLane(TrackId, AutomationLaneId),
/// Remove an automation lane (track_id, lane_id)
RemoveAutomationLane(TrackId, AutomationLaneId),
/// Enable/disable an automation lane (track_id, lane_id, enabled)
SetAutomationLaneEnabled(TrackId, AutomationLaneId, bool),
// Recording commands
/// Start recording on a track (track_id, start_time)
StartRecording(TrackId, Beats),
/// Stop the current recording
StopRecording,
/// Pause the current recording
PauseRecording,
/// Resume the current recording
ResumeRecording,
// MIDI Recording commands
/// Start MIDI recording on a track (track_id, clip_id, start_time)
StartMidiRecording(TrackId, MidiClipId, Beats),
/// Stop the current MIDI recording
StopMidiRecording,
// Project commands
/// Reset the entire project (remove all tracks, clear audio pool, reset state)
Reset,
// Live MIDI input commands
/// Send a live MIDI note on event to a track's instrument (track_id, note, velocity)
SendMidiNoteOn(TrackId, u8, u8),
/// Send a live MIDI note off event to a track's instrument (track_id, note)
SendMidiNoteOff(TrackId, u8),
/// Set the active MIDI track for external MIDI input routing (track_id or None)
SetActiveMidiTrack(Option<TrackId>),
// Metronome command
/// Enable or disable the metronome click track
SetMetronomeEnabled(bool),
/// Set project tempo and time signature (bpm, (numerator, denominator))
SetTempo(f32, (u32, u32)),
/// Replace the entire tempo map (multi-entry variable tempo support)
SetTempoMap(crate::TempoMap),
// Node graph commands
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)
GraphAddNode(TrackId, String, f32, f32),
/// Add a node to a VoiceAllocator's template graph (track_id, voice_allocator_node_id, node_type, position_x, position_y)
GraphAddNodeToTemplate(TrackId, u32, String, f32, f32),
/// Remove a node from a track's instrument graph (track_id, node_index)
GraphRemoveNode(TrackId, u32),
/// Connect two nodes in a track's graph (track_id, from_node, from_port, to_node, to_port)
GraphConnect(TrackId, u32, usize, u32, usize),
/// Connect nodes in a VoiceAllocator template (track_id, voice_allocator_node_id, from_node, from_port, to_node, to_port)
GraphConnectInTemplate(TrackId, u32, u32, usize, u32, usize),
/// Disconnect two nodes in a track's graph (track_id, from_node, from_port, to_node, to_port)
GraphDisconnect(TrackId, u32, usize, u32, usize),
/// Disconnect nodes in a VoiceAllocator template (track_id, voice_allocator_node_id, from_node, from_port, to_node, to_port)
GraphDisconnectInTemplate(TrackId, u32, u32, usize, u32, usize),
/// Remove a node from a VoiceAllocator's template graph (track_id, voice_allocator_node_id, node_index)
GraphRemoveNodeFromTemplate(TrackId, u32, u32),
/// Set a parameter on a node (track_id, node_index, param_id, value)
GraphSetParameter(TrackId, u32, u32, f32),
/// Set a parameter on a node in a VoiceAllocator's template graph (track_id, voice_allocator_node_id, node_index, param_id, value)
GraphSetParameterInTemplate(TrackId, u32, u32, u32, f32),
/// Set the UI position of a node (track_id, node_index, x, y)
GraphSetNodePosition(TrackId, u32, f32, f32),
/// Set the UI position of a node in a VoiceAllocator's template (track_id, voice_allocator_id, node_index, x, y)
GraphSetNodePositionInTemplate(TrackId, u32, u32, f32, f32),
/// Set which node receives MIDI events (track_id, node_index, enabled)
GraphSetMidiTarget(TrackId, u32, bool),
/// Set which node is the audio output (track_id, node_index)
GraphSetOutputNode(TrackId, u32),
/// Set frontend-only group definitions on a track's graph (track_id, serialized groups)
GraphSetGroups(TrackId, Vec<crate::audio::node_graph::preset::SerializedGroup>),
/// Set frontend-only group definitions on a VA template graph (track_id, voice_allocator_id, serialized groups)
GraphSetGroupsInTemplate(TrackId, u32, Vec<crate::audio::node_graph::preset::SerializedGroup>),
/// Save current graph as a preset (track_id, preset_path, preset_name, description, tags)
GraphSavePreset(TrackId, String, String, String, Vec<String>),
/// Load a preset into a track's graph (track_id, preset_path)
GraphLoadPreset(TrackId, String),
/// Load a .lbins instrument bundle into a track's graph (track_id, path)
GraphLoadLbins(TrackId, std::path::PathBuf),
/// Save a track's graph as a .lbins instrument bundle (track_id, path, preset_name, description, tags)
GraphSaveLbins(TrackId, std::path::PathBuf, String, String, Vec<String>),
// Metatrack subtrack graph commands
/// Replace a metatrack's mixing graph with the default SubtrackInputs→Mixer→Output layout.
/// (metatrack_id, ordered list of (child_track_id, display_name))
SetMetatrackSubtrackGraph(TrackId, Vec<(TrackId, String)>),
/// Add a new subtrack port to a metatrack's SubtrackInputsNode.
/// (metatrack_id, child_track_id, display_name)
AddMetatrackSubtrack(TrackId, TrackId, String),
/// Remove a subtrack port from a metatrack's SubtrackInputsNode.
/// (metatrack_id, child_track_id)
RemoveMetatrackSubtrack(TrackId, TrackId),
/// Re-associate backend TrackIds with SubtrackInputsNode slots after project reload.
/// (metatrack_id, ordered list of (child_track_id, display_name))
UpdateMetatrackSubtrackIds(TrackId, Vec<(TrackId, String)>),
/// Set or clear the graph_is_default flag on any track (track_id, value)
SetGraphIsDefault(TrackId, bool),
/// Save a VoiceAllocator's template graph as a preset (track_id, voice_allocator_id, preset_path, preset_name)
GraphSaveTemplatePreset(TrackId, u32, String, String),
/// Compile and set a BeamDSP script on a Script node (track_id, node_id, source_code)
GraphSetScript(TrackId, u32, String),
/// Load audio sample data into a Script node's sample slot (track_id, node_id, slot_index, audio_data, sample_rate, name)
GraphSetScriptSample(TrackId, u32, usize, Vec<f32>, u32, String),
/// Load a NAM model into an AmpSim node (track_id, node_id, model_path)
AmpSimLoadModel(TrackId, u32, String),
/// Load a sample into a SimpleSampler node (track_id, node_id, file_path)
SamplerLoadSample(TrackId, u32, String),
/// Load a sample from the audio pool into a SimpleSampler node (track_id, node_id, pool_index)
SamplerLoadFromPool(TrackId, u32, usize),
/// Set the root note (original pitch) for a SimpleSampler node (track_id, node_id, midi_note)
SamplerSetRootNote(TrackId, u32, u8),
/// Add a sample layer to a MultiSampler node (track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode)
MultiSamplerAddLayer(TrackId, u32, String, u8, u8, u8, u8, u8, Option<usize>, Option<usize>, LoopMode),
/// Add a sample layer from the audio pool to a MultiSampler node (track_id, node_id, pool_index, key_min, key_max, root_key)
MultiSamplerAddLayerFromPool(TrackId, u32, usize, u8, u8, u8),
/// Update a MultiSampler layer's configuration (track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode)
MultiSamplerUpdateLayer(TrackId, u32, usize, u8, u8, u8, u8, u8, Option<usize>, Option<usize>, LoopMode),
/// Remove a layer from a MultiSampler node (track_id, node_id, layer_index)
MultiSamplerRemoveLayer(TrackId, u32, usize),
/// Clear all layers from a MultiSampler node (track_id, node_id)
MultiSamplerClearLayers(TrackId, u32),
// Automation Input Node commands
/// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in)
AutomationAddKeyframe(TrackId, u32, f64, f32, String, (f32, f32), (f32, f32)),
/// Remove a keyframe from an AutomationInput node (track_id, node_id, time)
AutomationRemoveKeyframe(TrackId, u32, f64),
/// Set the display name of an AutomationInput node (track_id, node_id, name)
AutomationSetName(TrackId, u32, String),
// Waveform chunk generation commands
/// Generate waveform chunks for an audio file
/// (pool_index, detail_level, chunk_indices, priority)
GenerateWaveformChunks {
pool_index: usize,
detail_level: u8,
chunk_indices: Vec<u32>,
priority: u8, // 0=Low, 1=Medium, 2=High
},
// Input monitoring/gain commands
/// Enable or disable input monitoring (mic level metering)
SetInputMonitoring(bool),
/// Set the input gain multiplier (applied before recording)
SetInputGain(f32),
// Async audio import
/// Import an audio file asynchronously. The engine probes the file format
/// and either memory-maps it (WAV/AIFF) or sets up stream decode
/// (compressed). Emits `AudioFileReady` when playback-ready and
/// `AudioDecodeProgress` for compressed files as waveform data is decoded.
ImportAudio(std::path::PathBuf),
}
/// Events sent from audio thread back to UI/control thread
#[derive(Debug, Clone)]
pub enum AudioEvent {
/// Current playback position in seconds
PlaybackPosition(f64),
/// Playback has stopped (reached end of audio)
PlaybackStopped,
/// Audio buffer underrun detected
BufferUnderrun,
/// A new track was created (track_id, is_metatrack, name)
TrackCreated(TrackId, bool, String),
/// An audio file was added to the pool (pool_index, path)
AudioFileAdded(usize, String),
/// A clip was added to a track (track_id, clip_id)
ClipAdded(TrackId, ClipId),
/// Buffer pool statistics response
BufferPoolStats(BufferPoolStats),
/// Automation lane created (track_id, lane_id, parameter_id)
AutomationLaneCreated(TrackId, AutomationLaneId, ParameterId),
/// Recording started (track_id, clip_id, sample_rate, channels)
RecordingStarted(TrackId, ClipId, u32, u32),
/// Recording progress update (clip_id, current_duration)
RecordingProgress(ClipId, Seconds),
/// Recording stopped (clip_id, pool_index, waveform)
RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
/// Recording error (error_message)
RecordingError(String),
/// MIDI recording stopped (track_id, clip_id, note_count)
MidiRecordingStopped(TrackId, MidiClipId, usize),
/// MIDI recording progress (track_id, clip_id, duration, notes)
/// Notes format: (start_time, note, velocity, duration) — all times in beats
MidiRecordingProgress(TrackId, MidiClipId, Beats, Vec<(Beats, u8, u8, Beats)>),
/// Project has been reset
ProjectReset,
/// MIDI note started playing (note, velocity)
NoteOn(u8, u8),
/// MIDI note stopped playing (note)
NoteOff(u8),
// Node graph events
/// Node added to graph (track_id, node_index, node_type)
GraphNodeAdded(TrackId, u32, String),
/// Connection error occurred (track_id, error_message)
GraphConnectionError(TrackId, String),
/// Graph state changed (for full UI sync)
GraphStateChanged(TrackId),
/// Preset fully loaded (track_id, preset_name) - emitted after all nodes and samples are loaded
GraphPresetLoaded(TrackId, String),
/// Preset has been saved to file (track_id, preset_path)
GraphPresetSaved(TrackId, String),
/// Script compilation result (track_id, node_id, success, error, ui_declaration, source)
ScriptCompiled {
track_id: TrackId,
node_id: u32,
success: bool,
error: Option<String>,
ui_declaration: Option<beamdsp::UiDeclaration>,
source: String,
},
/// Export progress (frames_rendered, total_frames)
ExportProgress {
frames_rendered: usize,
total_frames: usize,
},
/// Export rendering complete, now writing/encoding the output file
ExportFinalizing,
/// Waveform generated for audio pool file (pool_index, waveform)
WaveformGenerated(usize, Vec<WaveformPeak>),
/// Waveform chunks ready for retrieval
/// (pool_index, detail_level, chunks: Vec<(chunk_index, time_range, peaks)>)
WaveformChunksReady {
pool_index: usize,
detail_level: u8,
chunks: Vec<(u32, (f64, f64), Vec<WaveformPeak>)>,
},
/// An audio file has been imported and is ready for playback.
/// For WAV/AIFF: the file is memory-mapped. For compressed: the disk
/// reader is stream-decoding ahead of the playhead.
AudioFileReady {
pool_index: usize,
path: String,
channels: u32,
sample_rate: u32,
duration: f64,
format: crate::io::audio_file::AudioFormat,
},
/// Progressive decode progress for a compressed audio file's waveform data.
/// Carries the samples inline so the UI doesn't need to query back.
AudioDecodeProgress {
pool_index: usize,
samples: Vec<f32>,
sample_rate: u32,
channels: u32,
},
/// Peak amplitude of mic input (for input monitoring meter)
InputLevel(f32),
/// Peak amplitude of mix output (for master meter), stereo (left, right)
OutputLevel(f32, f32),
/// Per-track playback peak levels
TrackLevels(Vec<(TrackId, f32)>),
/// Background waveform decode progress/completion for a compressed audio file.
/// Internal event — consumed by the engine to update the pool, not forwarded to UI.
/// `decoded_frames` < `total_frames` means partial; equal means complete.
WaveformDecodeComplete {
pool_index: usize,
samples: Vec<f32>,
decoded_frames: u64,
total_frames: u64,
},
}
/// Synchronous queries sent from UI thread to audio thread
#[derive(Debug)]
pub enum Query {
/// Get the current graph state as JSON (track_id)
GetGraphState(TrackId),
/// Get a voice allocator's template graph state as JSON (track_id, voice_allocator_id)
GetTemplateState(TrackId, u32),
/// Get oscilloscope data from a node (track_id, node_id, sample_count)
GetOscilloscopeData(TrackId, u32, usize),
/// Get oscilloscope data from a node inside a VoiceAllocator's best voice
/// (track_id, va_node_id, inner_node_id, sample_count)
GetVoiceOscilloscopeData(TrackId, u32, u32, usize),
/// Get MIDI clip data (track_id, clip_id)
GetMidiClip(TrackId, MidiClipId),
/// Get keyframes from an AutomationInput node (track_id, node_id)
GetAutomationKeyframes(TrackId, u32),
/// Get the display name of an AutomationInput node (track_id, node_id)
GetAutomationName(TrackId, u32),
/// Get the value range (min, max) of an AutomationInput node (track_id, node_id)
GetAutomationRange(TrackId, u32),
/// Serialize audio pool for project saving (project_path)
SerializeAudioPool(std::path::PathBuf),
/// Load audio pool from serialized entries (entries, project_path)
LoadAudioPool(Vec<crate::audio::pool::AudioPoolEntry>, std::path::PathBuf),
/// Resolve a missing audio file (pool_index, new_path)
ResolveMissingAudioFile(usize, std::path::PathBuf),
/// Serialize a track's effects/instrument graph (track_id, project_path)
SerializeTrackGraph(TrackId, std::path::PathBuf),
/// Load a track's effects/instrument graph (track_id, preset_json, project_path)
LoadTrackGraph(TrackId, String, std::path::PathBuf),
/// Create a new audio track (name, parent) - returns track ID synchronously
CreateAudioTrackSync(String, Option<TrackId>),
/// Create a new MIDI track (name, parent) - returns track ID synchronously
CreateMidiTrackSync(String, Option<TrackId>),
/// Create a new metatrack/group (name, parent) - returns track ID synchronously
CreateMetatrackSync(String, Option<TrackId>),
/// Get waveform data from audio pool (pool_index, target_peaks)
GetPoolWaveform(usize, usize),
/// Get file info from audio pool (pool_index) - returns (duration, sample_rate, channels)
GetPoolFileInfo(usize),
/// Export audio to file (settings, output_path)
ExportAudio(crate::audio::ExportSettings, std::path::PathBuf),
/// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID
AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, f64),
/// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID
/// The clip must already exist in the MidiClipPool
AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance),
/// Add an audio file to the pool synchronously (path, data, channels, sample_rate) - returns pool index
AddAudioFileSync(String, Vec<f32>, u32, u32),
/// Import an audio file synchronously (path) - returns pool index.
/// Does the same work as Command::ImportAudio (mmap for PCM, streaming
/// setup for compressed) but returns the real pool index in the response.
/// NOTE: briefly blocks the UI thread during file setup (sub-ms for PCM
/// mmap; a few ms for compressed streaming init). If this becomes a
/// problem for very large files, switch to async import with event-based
/// pool index reconciliation.
ImportAudioSync(std::path::PathBuf),
/// Get raw audio samples from pool (pool_index) - returns (samples, sample_rate, channels)
GetPoolAudioSamples(usize),
/// Get a clone of the current project for serialization
GetProject,
/// Set the project (replaces current project state)
SetProject(Box<crate::audio::project::Project>),
/// Duplicate a MIDI clip in the pool, returning the new clip's ID
DuplicateMidiClipSync(MidiClipId),
/// Get whether a track's graph is still the auto-generated default
GetGraphIsDefault(TrackId),
/// Get the pitch bend range (in semitones) for the instrument on a MIDI track.
/// Searches for MidiToCVNode (in VA templates) or MultiSamplerNode (direct).
GetPitchBendRange(TrackId),
}
/// Oscilloscope data from a node
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct OscilloscopeData {
/// Audio samples
pub audio: Vec<f32>,
/// CV samples (may be empty if no CV input)
pub cv: Vec<f32>,
}
/// MIDI clip data for serialization
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MidiClipData {
pub duration: f64,
pub events: Vec<crate::audio::midi::MidiEvent>,
}
/// Automation keyframe data for serialization
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AutomationKeyframeData {
pub time: f64,
pub value: f32,
pub interpolation: String,
pub ease_out: (f32, f32),
pub ease_in: (f32, f32),
}
/// Responses to synchronous queries
#[derive(Debug)]
pub enum QueryResponse {
/// Graph state as JSON string
GraphState(Result<String, String>),
/// Oscilloscope data samples
OscilloscopeData(Result<OscilloscopeData, String>),
/// MIDI clip data
MidiClipData(Result<MidiClipData, String>),
/// Automation keyframes
AutomationKeyframes(Result<Vec<AutomationKeyframeData>, String>),
/// Automation node name
AutomationName(Result<String, String>),
/// Automation node value range (min, max)
AutomationRange(Result<(f32, f32), String>),
/// Serialized audio pool entries
AudioPoolSerialized(Result<Vec<crate::audio::pool::AudioPoolEntry>, String>),
/// Audio pool loaded (returns list of missing pool indices)
AudioPoolLoaded(Result<Vec<usize>, String>),
/// Audio file resolved
AudioFileResolved(Result<(), String>),
/// Track graph serialized as JSON
TrackGraphSerialized(Result<String, String>),
/// Track graph loaded
TrackGraphLoaded(Result<(), String>),
/// Track created (returns track ID)
TrackCreated(Result<TrackId, String>),
/// Pool waveform data
PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>),
/// Pool file info (duration, sample_rate, channels)
PoolFileInfo(Result<(f64, u32, u32), String>),
/// Audio exported
AudioExported(Result<(), String>),
/// MIDI clip instance added (returns instance ID)
MidiClipInstanceAdded(Result<MidiClipInstanceId, String>),
/// Audio file added to pool (returns pool index)
AudioFileAddedSync(Result<usize, String>),
/// Audio file imported to pool (returns pool index)
AudioImportedSync(Result<usize, String>),
/// Raw audio samples from pool (samples, sample_rate, channels)
PoolAudioSamples(Result<(Vec<f32>, u32, u32), String>),
/// Project retrieved
ProjectRetrieved(Result<Box<crate::audio::project::Project>, String>),
/// Project set
ProjectSet(Result<(), String>),
/// MIDI clip duplicated (returns new clip ID)
MidiClipDuplicated(Result<MidiClipId, String>),
/// Whether a track's graph is the auto-generated default
GraphIsDefault(bool),
/// Pitch bend range in semitones for the track's instrument
PitchBendRange(f32),
}

View File

@ -0,0 +1,175 @@
use std::f32::consts::PI;
/// Biquad filter implementation (2-pole IIR filter)
///
/// Transfer function: H(z) = (b0 + b1*z^-1 + b2*z^-2) / (1 + a1*z^-1 + a2*z^-2)
#[derive(Clone)]
pub struct BiquadFilter {
// Filter coefficients
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
// State variables (per channel, supporting up to 2 channels)
x1: [f32; 2],
x2: [f32; 2],
y1: [f32; 2],
y2: [f32; 2],
}
impl BiquadFilter {
/// Create a new biquad filter with unity gain (pass-through)
pub fn new() -> Self {
Self {
b0: 1.0,
b1: 0.0,
b2: 0.0,
a1: 0.0,
a2: 0.0,
x1: [0.0; 2],
x2: [0.0; 2],
y1: [0.0; 2],
y2: [0.0; 2],
}
}
/// Create a lowpass filter
///
/// # Arguments
/// * `frequency` - Cutoff frequency in Hz
/// * `q` - Quality factor (resonance), typically 0.707 for Butterworth
/// * `sample_rate` - Sample rate in Hz
pub fn lowpass(frequency: f32, q: f32, sample_rate: f32) -> Self {
let mut filter = Self::new();
filter.set_lowpass(frequency, q, sample_rate);
filter
}
/// Create a highpass filter
///
/// # Arguments
/// * `frequency` - Cutoff frequency in Hz
/// * `q` - Quality factor (resonance), typically 0.707 for Butterworth
/// * `sample_rate` - Sample rate in Hz
pub fn highpass(frequency: f32, q: f32, sample_rate: f32) -> Self {
let mut filter = Self::new();
filter.set_highpass(frequency, q, sample_rate);
filter
}
/// Create a peaking EQ filter
///
/// # Arguments
/// * `frequency` - Center frequency in Hz
/// * `q` - Quality factor (bandwidth)
/// * `gain_db` - Gain in decibels
/// * `sample_rate` - Sample rate in Hz
pub fn peaking(frequency: f32, q: f32, gain_db: f32, sample_rate: f32) -> Self {
let mut filter = Self::new();
filter.set_peaking(frequency, q, gain_db, sample_rate);
filter
}
/// Set coefficients for a lowpass filter
pub fn set_lowpass(&mut self, frequency: f32, q: f32, sample_rate: f32) {
let omega = 2.0 * PI * frequency / sample_rate;
let sin_omega = omega.sin();
let cos_omega = omega.cos();
let alpha = sin_omega / (2.0 * q);
let a0 = 1.0 + alpha;
self.b0 = ((1.0 - cos_omega) / 2.0) / a0;
self.b1 = (1.0 - cos_omega) / a0;
self.b2 = ((1.0 - cos_omega) / 2.0) / a0;
self.a1 = (-2.0 * cos_omega) / a0;
self.a2 = (1.0 - alpha) / a0;
}
/// Set coefficients for a highpass filter
pub fn set_highpass(&mut self, frequency: f32, q: f32, sample_rate: f32) {
let omega = 2.0 * PI * frequency / sample_rate;
let sin_omega = omega.sin();
let cos_omega = omega.cos();
let alpha = sin_omega / (2.0 * q);
let a0 = 1.0 + alpha;
self.b0 = ((1.0 + cos_omega) / 2.0) / a0;
self.b1 = -(1.0 + cos_omega) / a0;
self.b2 = ((1.0 + cos_omega) / 2.0) / a0;
self.a1 = (-2.0 * cos_omega) / a0;
self.a2 = (1.0 - alpha) / a0;
}
/// Set coefficients for a peaking EQ filter
pub fn set_peaking(&mut self, frequency: f32, q: f32, gain_db: f32, sample_rate: f32) {
let omega = 2.0 * PI * frequency / sample_rate;
let sin_omega = omega.sin();
let cos_omega = omega.cos();
let a_gain = 10.0_f32.powf(gain_db / 40.0);
let alpha = sin_omega / (2.0 * q);
let a0 = 1.0 + alpha / a_gain;
self.b0 = (1.0 + alpha * a_gain) / a0;
self.b1 = (-2.0 * cos_omega) / a0;
self.b2 = (1.0 - alpha * a_gain) / a0;
self.a1 = (-2.0 * cos_omega) / a0;
self.a2 = (1.0 - alpha / a_gain) / a0;
}
/// Process a single sample
///
/// # Arguments
/// * `input` - Input sample
/// * `channel` - Channel index (0 or 1)
///
/// # Returns
/// Filtered output sample
#[inline]
pub fn process_sample(&mut self, input: f32, channel: usize) -> f32 {
let channel = channel.min(1); // Clamp to 0 or 1
// Direct Form II Transposed implementation
let output = self.b0 * input + self.x1[channel];
self.x1[channel] = self.b1 * input - self.a1 * output + self.x2[channel];
self.x2[channel] = self.b2 * input - self.a2 * output;
output
}
/// Process a buffer of interleaved samples
///
/// # Arguments
/// * `buffer` - Interleaved audio samples
/// * `channels` - Number of channels
pub fn process_buffer(&mut self, buffer: &mut [f32], channels: usize) {
if channels == 1 {
// Mono
for sample in buffer.iter_mut() {
*sample = self.process_sample(*sample, 0);
}
} else if channels == 2 {
// Stereo
for frame in buffer.chunks_exact_mut(2) {
frame[0] = self.process_sample(frame[0], 0);
frame[1] = self.process_sample(frame[1], 1);
}
}
}
/// Reset filter state (clear delay lines)
pub fn reset(&mut self) {
self.x1 = [0.0; 2];
self.x2 = [0.0; 2];
self.y1 = [0.0; 2];
self.y2 = [0.0; 2];
}
}
impl Default for BiquadFilter {
fn default() -> Self {
Self::new()
}
}

View File

@ -0,0 +1,5 @@
pub mod biquad;
pub mod svf;
pub use biquad::BiquadFilter;
pub use svf::SvfFilter;

135
daw-backend/src/dsp/svf.rs Normal file
View File

@ -0,0 +1,135 @@
use std::f32::consts::PI;
/// State Variable Filter mode
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SvfMode {
Lowpass = 0,
Highpass = 1,
Bandpass = 2,
Notch = 3,
}
impl SvfMode {
pub fn from_f32(value: f32) -> Self {
match value.round() as i32 {
1 => SvfMode::Highpass,
2 => SvfMode::Bandpass,
3 => SvfMode::Notch,
_ => SvfMode::Lowpass,
}
}
}
/// Linear trapezoidal integrated State Variable Filter (Simper/Cytomic)
///
/// Zero-delay feedback topology. Per-sample cutoff modulation is cheap —
/// just update `g` and `k` coefficients (no per-sample trig needed if
/// cutoff hasn't changed).
#[derive(Clone)]
pub struct SvfFilter {
// Coefficients
g: f32, // frequency warping: tan(π * cutoff / sample_rate)
k: f32, // damping: 2 - 2*resonance
a1: f32, // 1 / (1 + g*(g+k))
a2: f32, // g * a1
// State per channel (up to 2 for stereo)
ic1eq: [f32; 2],
ic2eq: [f32; 2],
mode: SvfMode,
}
impl SvfFilter {
/// Create a new SVF with default parameters (1kHz lowpass, no resonance)
pub fn new() -> Self {
let mut filter = Self {
g: 0.0,
k: 2.0,
a1: 0.0,
a2: 0.0,
ic1eq: [0.0; 2],
ic2eq: [0.0; 2],
mode: SvfMode::Lowpass,
};
filter.set_params(1000.0, 0.0, 44100.0);
filter
}
/// Set filter parameters
///
/// # Arguments
/// * `cutoff_hz` - Cutoff frequency in Hz (clamped to valid range)
/// * `resonance` - Resonance 0.0 (none) to 1.0 (self-oscillation)
/// * `sample_rate` - Sample rate in Hz
#[inline]
pub fn set_params(&mut self, cutoff_hz: f32, resonance: f32, sample_rate: f32) {
// Clamp cutoff to avoid instability near Nyquist
let cutoff = cutoff_hz.clamp(5.0, sample_rate * 0.49);
let resonance = resonance.clamp(0.0, 1.0);
self.g = (PI * cutoff / sample_rate).tan();
self.k = 2.0 - 2.0 * resonance;
self.a1 = 1.0 / (1.0 + self.g * (self.g + self.k));
self.a2 = self.g * self.a1;
}
/// Set filter mode
pub fn set_mode(&mut self, mode: SvfMode) {
self.mode = mode;
}
/// Process a single sample, returning all four outputs: (lowpass, highpass, bandpass, notch)
#[inline]
pub fn process_sample_quad(&mut self, input: f32, channel: usize) -> (f32, f32, f32, f32) {
let ch = channel.min(1);
let v3 = input - self.ic2eq[ch];
let v1 = self.a1 * self.ic1eq[ch] + self.a2 * v3;
let v2 = self.ic2eq[ch] + self.g * v1;
self.ic1eq[ch] = 2.0 * v1 - self.ic1eq[ch];
self.ic2eq[ch] = 2.0 * v2 - self.ic2eq[ch];
let hp = input - self.k * v1 - v2;
(v2, hp, v1, hp + v2)
}
/// Process a single sample with a selected mode
#[inline]
pub fn process_sample(&mut self, input: f32, channel: usize) -> f32 {
let (lp, hp, bp, notch) = self.process_sample_quad(input, channel);
match self.mode {
SvfMode::Lowpass => lp,
SvfMode::Highpass => hp,
SvfMode::Bandpass => bp,
SvfMode::Notch => notch,
}
}
/// Process a buffer of interleaved samples
pub fn process_buffer(&mut self, buffer: &mut [f32], channels: usize) {
if channels == 1 {
for sample in buffer.iter_mut() {
*sample = self.process_sample(*sample, 0);
}
} else if channels == 2 {
for frame in buffer.chunks_exact_mut(2) {
frame[0] = self.process_sample(frame[0], 0);
frame[1] = self.process_sample(frame[1], 1);
}
}
}
/// Reset filter state (clear delay lines)
pub fn reset(&mut self) {
self.ic1eq = [0.0; 2];
self.ic2eq = [0.0; 2];
}
}
impl Default for SvfFilter {
fn default() -> Self {
Self::new()
}
}

View File

@ -0,0 +1,35 @@
/// Audio effect processor trait
///
/// All effects must be Send to be usable in the audio thread.
/// Effects should be real-time safe: no allocations, no blocking operations.
pub trait Effect: Send {
/// Process audio buffer in-place
///
/// # Arguments
/// * `buffer` - Interleaved audio samples to process
/// * `channels` - Number of audio channels (2 for stereo)
/// * `sample_rate` - Sample rate in Hz
fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32);
/// Set an effect parameter
///
/// # Arguments
/// * `id` - Parameter identifier
/// * `value` - Parameter value (normalized or specific units depending on parameter)
fn set_parameter(&mut self, id: u32, value: f32);
/// Get an effect parameter value
///
/// # Arguments
/// * `id` - Parameter identifier
///
/// # Returns
/// Current parameter value
fn get_parameter(&self, id: u32) -> f32;
/// Reset effect state (clear delays, resonances, etc.)
fn reset(&mut self);
/// Get the effect name
fn name(&self) -> &str;
}

View File

@ -0,0 +1,148 @@
use super::Effect;
use crate::dsp::BiquadFilter;
/// Simple 3-band EQ (low shelf, mid peak, high shelf)
///
/// Parameters:
/// - 0: Low gain in dB (-12.0 to +12.0)
/// - 1: Mid gain in dB (-12.0 to +12.0)
/// - 2: High gain in dB (-12.0 to +12.0)
/// - 3: Low frequency in Hz (default: 250)
/// - 4: Mid frequency in Hz (default: 1000)
/// - 5: High frequency in Hz (default: 8000)
pub struct SimpleEQ {
low_gain: f32,
mid_gain: f32,
high_gain: f32,
low_freq: f32,
mid_freq: f32,
high_freq: f32,
low_filter: BiquadFilter,
mid_filter: BiquadFilter,
high_filter: BiquadFilter,
sample_rate: f32,
}
impl SimpleEQ {
/// Create a new SimpleEQ with flat response
pub fn new() -> Self {
Self {
low_gain: 0.0,
mid_gain: 0.0,
high_gain: 0.0,
low_freq: 250.0,
mid_freq: 1000.0,
high_freq: 8000.0,
low_filter: BiquadFilter::new(),
mid_filter: BiquadFilter::new(),
high_filter: BiquadFilter::new(),
sample_rate: 48000.0, // Default, will be updated on first process
}
}
/// Set low band gain in decibels
pub fn set_low_gain(&mut self, gain_db: f32) {
self.low_gain = gain_db.clamp(-12.0, 12.0);
self.update_filters();
}
/// Set mid band gain in decibels
pub fn set_mid_gain(&mut self, gain_db: f32) {
self.mid_gain = gain_db.clamp(-12.0, 12.0);
self.update_filters();
}
/// Set high band gain in decibels
pub fn set_high_gain(&mut self, gain_db: f32) {
self.high_gain = gain_db.clamp(-12.0, 12.0);
self.update_filters();
}
/// Set low band frequency
pub fn set_low_freq(&mut self, freq: f32) {
self.low_freq = freq.clamp(20.0, 500.0);
self.update_filters();
}
/// Set mid band frequency
pub fn set_mid_freq(&mut self, freq: f32) {
self.mid_freq = freq.clamp(200.0, 5000.0);
self.update_filters();
}
/// Set high band frequency
pub fn set_high_freq(&mut self, freq: f32) {
self.high_freq = freq.clamp(2000.0, 20000.0);
self.update_filters();
}
/// Update filter coefficients based on current parameters
fn update_filters(&mut self) {
// Only update if sample rate has been set
if self.sample_rate > 0.0 {
// Use peaking filters for all bands
// Q factor of 1.0 gives a moderate bandwidth
self.low_filter.set_peaking(self.low_freq, 1.0, self.low_gain, self.sample_rate);
self.mid_filter.set_peaking(self.mid_freq, 1.0, self.mid_gain, self.sample_rate);
self.high_filter.set_peaking(self.high_freq, 1.0, self.high_gain, self.sample_rate);
}
}
}
impl Default for SimpleEQ {
fn default() -> Self {
Self::new()
}
}
impl Effect for SimpleEQ {
fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32) {
// Update sample rate if it changed
let sr = sample_rate as f32;
if (self.sample_rate - sr).abs() > 0.1 {
self.sample_rate = sr;
self.update_filters();
}
// Process through each filter in series
self.low_filter.process_buffer(buffer, channels);
self.mid_filter.process_buffer(buffer, channels);
self.high_filter.process_buffer(buffer, channels);
}
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
0 => self.set_low_gain(value),
1 => self.set_mid_gain(value),
2 => self.set_high_gain(value),
3 => self.set_low_freq(value),
4 => self.set_mid_freq(value),
5 => self.set_high_freq(value),
_ => {}
}
}
fn get_parameter(&self, id: u32) -> f32 {
match id {
0 => self.low_gain,
1 => self.mid_gain,
2 => self.high_gain,
3 => self.low_freq,
4 => self.mid_freq,
5 => self.high_freq,
_ => 0.0,
}
}
fn reset(&mut self) {
self.low_filter.reset();
self.mid_filter.reset();
self.high_filter.reset();
}
fn name(&self) -> &str {
"SimpleEQ"
}
}

View File

@ -0,0 +1,97 @@
use super::Effect;
/// Simple gain/volume effect
///
/// Parameters:
/// - 0: Gain in dB (-60.0 to +12.0)
pub struct GainEffect {
gain_db: f32,
gain_linear: f32,
}
impl GainEffect {
/// Create a new gain effect with 0 dB (unity) gain
pub fn new() -> Self {
Self {
gain_db: 0.0,
gain_linear: 1.0,
}
}
/// Create a gain effect with a specific dB value
pub fn with_gain_db(gain_db: f32) -> Self {
let gain_linear = db_to_linear(gain_db);
Self {
gain_db,
gain_linear,
}
}
/// Set gain in decibels
pub fn set_gain_db(&mut self, gain_db: f32) {
self.gain_db = gain_db.clamp(-60.0, 12.0);
self.gain_linear = db_to_linear(self.gain_db);
}
/// Get current gain in decibels
pub fn gain_db(&self) -> f32 {
self.gain_db
}
}
impl Default for GainEffect {
fn default() -> Self {
Self::new()
}
}
impl Effect for GainEffect {
fn process(&mut self, buffer: &mut [f32], _channels: usize, _sample_rate: u32) {
for sample in buffer.iter_mut() {
*sample *= self.gain_linear;
}
}
fn set_parameter(&mut self, id: u32, value: f32) {
if id == 0 {
self.set_gain_db(value);
}
}
fn get_parameter(&self, id: u32) -> f32 {
if id == 0 {
self.gain_db
} else {
0.0
}
}
fn reset(&mut self) {
// Gain has no state to reset
}
fn name(&self) -> &str {
"Gain"
}
}
/// Convert decibels to linear gain
#[inline]
fn db_to_linear(db: f32) -> f32 {
if db <= -60.0 {
0.0
} else {
10.0_f32.powf(db / 20.0)
}
}
/// Convert linear gain to decibels
#[inline]
#[allow(dead_code)]
fn linear_to_db(linear: f32) -> f32 {
if linear <= 0.0 {
-60.0
} else {
20.0 * linear.log10()
}
}

View File

@ -0,0 +1,11 @@
pub mod effect_trait;
pub mod eq;
pub mod gain;
pub mod pan;
pub mod synth;
pub use effect_trait::Effect;
pub use eq::SimpleEQ;
pub use gain::GainEffect;
pub use pan::PanEffect;
pub use synth::SimpleSynth;

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