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:
|
||||
Changes:
|
||||
- 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