Merge branch 'release' of github.com:skykooler/Lightningbeam into release
This commit is contained in:
commit
73d5d554c7
|
|
@ -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 }}
|
||||||
|
|
@ -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 }}
|
|
||||||
|
|
@ -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/
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "vendor/NeuralAudio"]
|
||||||
|
path = vendor/NeuralAudio
|
||||||
|
url = https://github.com/mikeoliphant/NeuralAudio.git
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
70
Changelog.md
70
Changelog.md
|
|
@ -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:
|
# 0.7.14-alpha:
|
||||||
Changes:
|
Changes:
|
||||||
- Moving frames can now be undone
|
- Moving frames can now be undone
|
||||||
|
|
|
||||||
|
|
@ -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
128
README.md
|
|
@ -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`
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
|
@ -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!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
@ -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};
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ¬e in &self.prev_active_notes {
|
||||||
|
if !new_notes.contains(¬e) {
|
||||||
|
midi_outputs[0].push(MidiEvent::note_off(Beats::ZERO, 0, note, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note-on for newly active notes
|
||||||
|
for ¬e in &new_notes {
|
||||||
|
if !self.prev_active_notes.contains(¬e) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(¬e) {
|
||||||
|
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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse};
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod biquad;
|
||||||
|
pub mod svf;
|
||||||
|
|
||||||
|
pub use biquad::BiquadFilter;
|
||||||
|
pub use svf::SvfFilter;
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in New Issue